""" 本地设备控制器 — 在 Termux 中运行时替代 uiautomator2 当 Agent 运行在手机本地(Termux)时,ADB 不可用。 此模块通过 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