feat: publish workphone SDK deployment and API docs
This commit is contained in:
265
sdk/agent/local_device.py
Normal file
265
sdk/agent/local_device.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
本地设备控制器 — 在 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
|
||||
Reference in New Issue
Block a user