autotrain/scripts/device_runtime.py
xinxin6623 9eb6778a6f feat: web UI enhancements, device calibration, phone control manual, and skills directory
- web_ui.py: major refactor with improved runner, mode selection, progress tracking
- scripts: add device_runtime, device_calibrate, calibration_trace, record_android_flow
- docs: add phone-control-manual, update device-adaptation and windows-migration
- invoice_tool: enhance H5 automation flow and visual fallback
- calibrate.py: expand calibration capabilities
- add skills/ and tests/ directories
- remove obsolete .opencode/skills/invoice-wrapup

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 22:14:56 +08:00

220 lines
8.2 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.

#!/usr/bin/env python3
"""跨平台 ADB 与设备档案的统一入口。
项目一次只允许操作一台处于 ``device`` 状态的 Android 设备。所有调用都
带上该设备的 serial避免换机或同时连接多台设备时误操作。
"""
from __future__ import annotations
import json
import ipaddress
import os
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
WORK_DIR = PROJECT_ROOT / "work"
PROFILE_REGISTRY_PATH = WORK_DIR / "device_profiles.json"
LEGACY_PROFILE_PATH = WORK_DIR / "device_profile.json"
MACOS_ADB = "/opt/homebrew/share/android-commandlinetools/platform-tools/adb"
class DeviceError(RuntimeError):
"""设备未就绪、数量不正确或 ADB 不可用。"""
def adb_binary() -> str:
"""按环境变量、PATH、旧 macOS 安装位置的顺序发现 adb。"""
configured = os.environ.get("AUTOTRAIN_ADB", "").strip()
if configured:
return configured
from_path = shutil.which("adb")
if from_path:
return from_path
if Path(MACOS_ADB).exists():
return MACOS_ADB
return "adb"
def _raw_adb(arguments: list[str]) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run([adb_binary(), *arguments], capture_output=True, text=True, check=False)
except FileNotFoundError as exc:
raise DeviceError("未找到 adb请安装 Android Platform Tools并将 adb 加入 PATH也可设置 AUTOTRAIN_ADB。") from exc
def list_devices() -> list[dict[str, str]]:
result = _raw_adb(["devices", "-l"])
if result.returncode != 0:
raise DeviceError(result.stderr.strip() or "adb devices 执行失败")
devices: list[dict[str, str]] = []
for line in result.stdout.splitlines()[1:]:
parts = line.split()
if len(parts) < 2:
continue
info = {"serial": parts[0], "state": parts[1]}
for token in parts[2:]:
if ":" in token:
key, value = token.split(":", 1)
info[key] = value
devices.append(info)
return devices
def connect_wireless(host: str, port: int | str = 5555) -> dict[str, object]:
"""连接指定的无线 ADB 地址;只接受 IP 地址,避免把任意命令交给 adb。"""
try:
parsed_host = ipaddress.ip_address(host.strip())
except ValueError as exc:
raise DeviceError("请输入合法的手机局域网 IP例如 192.168.1.11") from exc
try:
parsed_port = int(port)
except (TypeError, ValueError) as exc:
raise DeviceError("端口必须是 1 到 65535 的数字") from exc
if not 1 <= parsed_port <= 65535:
raise DeviceError("端口必须是 1 到 65535 的数字")
endpoint = f"[{parsed_host}]:{parsed_port}" if parsed_host.version == 6 else f"{parsed_host}:{parsed_port}"
# 清掉同地址的 offline 记录,再重新连接。
_raw_adb(["disconnect", endpoint])
result = _raw_adb(["connect", endpoint])
devices = list_devices()
connected = any(item.get("serial") == endpoint and item.get("state") == "device" for item in devices)
return {
"connected": connected,
"endpoint": endpoint,
"stdout": result.stdout.strip(),
"stderr": result.stderr.strip(),
"devices": devices,
}
def require_single_device() -> dict[str, str]:
devices = list_devices()
usable = [item for item in devices if item.get("state") == "device"]
if len(usable) == 1:
return usable[0]
if not usable:
details = ", ".join(f"{d['serial']}({d['state']})" for d in devices) or "无设备"
raise DeviceError(f"未检测到可用 Android 设备({details})。请连接并授权一台手机。")
names = ", ".join(f"{d['serial']}({d.get('model', 'unknown')})" for d in usable)
raise DeviceError(f"同时连接了多台手机:{names}。为避免误开票,请只保留一台设备后重试。")
def run_adb(arguments: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
device = require_single_device()
result = _raw_adb(["-s", device["serial"], *arguments])
if check and result.returncode != 0:
raise DeviceError(result.stderr.strip() or result.stdout.strip() or "adb command failed")
return result
def run_adb_bytes(arguments: list[str], *, check: bool = True) -> subprocess.CompletedProcess[bytes]:
"""执行会返回二进制内容的 ADB 命令(如 screencap"""
device = require_single_device()
try:
result = subprocess.run([adb_binary(), "-s", device["serial"], *arguments], capture_output=True, check=False)
except FileNotFoundError as exc:
raise DeviceError("未找到 adb请安装 Android Platform Tools并将 adb 加入 PATH也可设置 AUTOTRAIN_ADB。") from exc
if check and result.returncode != 0:
raise DeviceError(result.stderr.decode("utf-8", "ignore").strip() or "adb command failed")
return result
def _shell_text(command: str) -> str:
return run_adb(["shell", command], check=False).stdout.strip()
def _wm_value(kind: str) -> str:
text = _shell_text(f"wm {kind}")
physical = ""
for line in text.splitlines():
if "Override" in line:
return line.split(":", 1)[-1].strip()
if "Physical" in line:
physical = line.split(":", 1)[-1].strip()
return physical
def device_fingerprint() -> dict[str, str]:
device = require_single_device()
return {
"serial": device["serial"],
"manufacturer": _shell_text("getprop ro.product.manufacturer"),
"model": _shell_text("getprop ro.product.model") or device.get("model", ""),
"android_version": _shell_text("getprop ro.build.version.release"),
"physical_size": _wm_value("size"),
"density": _wm_value("density"),
"orientation": _shell_text("settings get system user_rotation"),
}
def _empty_registry() -> dict:
return {"schema_version": 2, "devices": {}}
def load_registry() -> dict:
if not PROFILE_REGISTRY_PATH.exists():
return _empty_registry()
try:
data = json.loads(PROFILE_REGISTRY_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return _empty_registry()
if not isinstance(data, dict) or not isinstance(data.get("devices"), dict):
return _empty_registry()
data.setdefault("schema_version", 2)
return data
def save_registry(registry: dict) -> None:
WORK_DIR.mkdir(parents=True, exist_ok=True)
PROFILE_REGISTRY_PATH.write_text(json.dumps(registry, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def current_profile() -> dict:
fingerprint = device_fingerprint()
record = load_registry().get("devices", {}).get(fingerprint["serial"], {})
return {"fingerprint": fingerprint, "profile": record}
def profile_status() -> dict:
current = current_profile()
fingerprint = current["fingerprint"]
profile = current["profile"]
saved = profile.get("fingerprint") or {}
changed = [key for key in ("physical_size", "density", "orientation") if saved.get(key) and saved.get(key) != fingerprint.get(key)]
status = "missing" if not profile else ("stale" if changed else profile.get("status", "unverified"))
return {
"device": fingerprint,
"status": status,
"changed_fields": changed,
"profile": profile,
}
def upsert_profile(profile: dict) -> dict:
fingerprint = device_fingerprint()
registry = load_registry()
record = dict(profile)
record["fingerprint"] = fingerprint
record["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
registry["devices"][fingerprint["serial"]] = record
save_registry(registry)
return record
def migrate_legacy_profile() -> bool:
"""首次升级时把旧单机档案复制到当前唯一设备,原文件保留。"""
if PROFILE_REGISTRY_PATH.exists() or not LEGACY_PROFILE_PATH.exists():
return False
try:
legacy = json.loads(LEGACY_PROFILE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
legacy["status"] = "unverified"
legacy["migrated_from"] = LEGACY_PROFILE_PATH.name
upsert_profile(legacy)
return True