266 lines
8.8 KiB
Python
266 lines
8.8 KiB
Python
"""
|
||
本地设备控制器 — 在 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
|