#!/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