- 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>
220 lines
8.2 KiB
Python
220 lines
8.2 KiB
Python
#!/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
|