Files
workphone-sdk/sdk/agent/local_device.py

266 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
本地设备控制器 — 在 Termux 中运行时替代 uiautomator2
当 Agent 运行在手机本地TermuxADB 不可用。
此模块通过 ATX HTTP API + subprocess 直接控制设备,
提供与 uiautomator2 Device 兼容的接口。
ATX 服务端口9008由 u2 init 从电脑推送启动)
"""
import json
import logging
import subprocess
import time
from typing import Any, Dict, Optional
from urllib import request, error, parse
logger = logging.getLogger(__name__)
_ATX_PORTS = [9008, 7912]
class ShellResult:
"""兼容 u2 的 shell 返回值"""
def __init__(self, output: str, exit_code: int = 0):
self.output = output
self.exit_code = exit_code
class _Settings(dict):
"""兼容 u2 的 settings 字典"""
pass
class LocalDevice:
"""
本地设备控制器 —— 兼容 uiautomator2.Device 接口的子集
在手机本地运行时shell 命令直接用 subprocess 执行,
UI 操作通过 ATX HTTP server 完成。
"""
def __init__(self, atx_url: Optional[str] = None):
self._atx_url = atx_url or self._find_atx()
self.settings = _Settings({
'operation_delay': (0, 0),
'operation_delay_methods': [],
})
self._implicit_wait = 10.0
self._verify_atx()
@staticmethod
def _find_atx() -> str:
for port in _ATX_PORTS:
url = f"http://127.0.0.1:{port}"
try:
resp = request.urlopen(f"{url}/ping", timeout=2)
if resp.status == 200:
return url
except Exception:
continue
return f"http://127.0.0.1:{_ATX_PORTS[0]}"
def _verify_atx(self):
try:
self._http_get("/info")
logger.info(f"ATX 服务连接成功: {self._atx_url}")
except Exception as e:
logger.warning(f"ATX 服务不可用 ({self._atx_url}): {e}")
raise ConnectionError(f"ATX server not reachable at {self._atx_url}")
def _http_get(self, path: str, timeout: float = 10) -> Any:
resp = request.urlopen(f"{self._atx_url}{path}", timeout=timeout)
return json.loads(resp.read().decode())
def _http_post(self, path: str, data: dict = None, timeout: float = 10) -> Any:
body = json.dumps(data or {}).encode()
req = request.Request(
f"{self._atx_url}{path}",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
resp = request.urlopen(req, timeout=timeout)
return json.loads(resp.read().decode())
def implicitly_wait(self, timeout: float):
self._implicit_wait = timeout
@property
def info(self) -> dict:
return self._http_get("/info")
@property
def serial(self) -> str:
try:
info = self.info
return info.get("serial", "local")
except Exception:
return "local"
def shell(self, cmd: str, timeout: float = 30) -> ShellResult:
"""执行 shell 命令(本地直接运行,不需要 ADB"""
try:
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True,
timeout=timeout,
)
return ShellResult(result.stdout + result.stderr, result.returncode)
except subprocess.TimeoutExpired:
return ShellResult("", -1)
except Exception as e:
return ShellResult(str(e), -1)
def click(self, x: int, y: int):
self._http_post("/click", {"x": x, "y": y})
def long_click(self, x: int, y: int, duration: float = 0.5):
self._http_post("/click", {"x": x, "y": y, "duration": duration})
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float = 0.5):
self._http_post("/swipe", {
"x1": x1, "y1": y1, "x2": x2, "y2": y2,
"duration": duration,
})
def swipe_ext(self, direction: str, scale: float = 0.8):
info = self.info
w = info.get("displayWidth", 1080)
h = info.get("displayHeight", 2400)
cx, cy = w // 2, h // 2
dist_x = int(w * scale / 2)
dist_y = int(h * scale / 2)
moves = {
"up": (cx, cy + dist_y, cx, cy - dist_y),
"down": (cx, cy - dist_y, cx, cy + dist_y),
"left": (cx + dist_x, cy, cx - dist_x, cy),
"right": (cx - dist_x, cy, cx + dist_x, cy),
}
coords = moves.get(direction, moves["up"])
self.swipe(*coords)
def send_keys(self, text: str):
self._http_post("/keys", {"keys": text})
def clear_text(self):
self._http_post("/clear")
def press(self, key: str):
key_map = {
"home": 3, "back": 4, "menu": 82, "power": 26,
"volume_up": 24, "volume_down": 25, "enter": 66,
"recent": 187, "search": 84,
}
code = key_map.get(key.lower(), key)
self.shell(f"input keyevent {code}")
def app_start(self, package: str, activity: str = None):
if activity:
self.shell(f"am start -n {package}/{activity}")
else:
self.shell(
f"monkey -p {package} -c android.intent.category.LAUNCHER 1"
)
def app_stop(self, package: str):
self.shell(f"am force-stop {package}")
def dump_hierarchy(self) -> str:
result = self._http_get("/dump/hierarchy")
return result if isinstance(result, str) else json.dumps(result)
def screenshot(self, format: str = "raw") -> bytes:
url = f"{self._atx_url}/screenshot/0"
resp = request.urlopen(url, timeout=15)
return resp.read()
def __call__(self, **kwargs):
"""兼容 u2 的选择器语法: d(text="xxx"), d(resourceId="xxx")"""
return _Selector(self, kwargs)
class _Selector:
"""兼容 u2 的 UI 选择器"""
def __init__(self, device: LocalDevice, criteria: dict):
self._device = device
self._criteria = criteria
def _build_xpath(self) -> str:
parts = []
if "text" in self._criteria:
parts.append(f'@text="{self._criteria["text"]}"')
if "textContains" in self._criteria:
parts.append(f'contains(@text, "{self._criteria["textContains"]}")')
if "textMatches" in self._criteria:
parts.append(f'matches(@text, "{self._criteria["textMatches"]}")')
if "resourceId" in self._criteria:
parts.append(f'@resource-id="{self._criteria["resourceId"]}"')
if "className" in self._criteria:
parts.append(f'@class="{self._criteria["className"]}"')
if "description" in self._criteria:
parts.append(f'@content-desc="{self._criteria["description"]}"')
if parts:
return f'//*[{" and ".join(parts)}]'
return '//*'
def exists(self, timeout: float = None) -> bool:
t = timeout if timeout is not None else self._device._implicit_wait
end = time.time() + t
while time.time() < end:
try:
hierarchy = self._device.dump_hierarchy()
xpath = self._build_xpath()
if self._match_in_hierarchy(hierarchy, xpath):
return True
except Exception:
pass
time.sleep(0.5)
return False
def click(self, timeout: float = None):
t = timeout if timeout is not None else self._device._implicit_wait
end = time.time() + t
while time.time() < end:
try:
resp = self._device._http_post("/xpath", {
"xpath": self._build_xpath(),
"action": "click",
})
return resp
except Exception:
pass
time.sleep(0.5)
raise TimeoutError(f"Element not found: {self._criteria}")
def get_text(self, timeout: float = None) -> str:
t = timeout if timeout is not None else self._device._implicit_wait
end = time.time() + t
while time.time() < end:
try:
resp = self._device._http_post("/xpath", {
"xpath": self._build_xpath(),
"action": "get_text",
})
return resp.get("text", "")
except Exception:
pass
time.sleep(0.5)
return ""
@staticmethod
def _match_in_hierarchy(hierarchy: str, xpath: str) -> bool:
try:
import xml.etree.ElementTree as ET
root = ET.fromstring(hierarchy) if isinstance(hierarchy, str) else hierarchy
return len(root.findall(f".{xpath}")) > 0
except Exception:
import re
if '@text=' in xpath:
text = xpath.split('@text="')[1].split('"')[0]
return text in hierarchy
return False