"""每台手机独立的、可移植的设备档案。坐标全部是 0..1 的归一化值。""" from __future__ import annotations import json import re import hashlib from datetime import datetime, timezone from pathlib import Path from .paths import PROFILES, ensure_workdirs class ProfileError(RuntimeError): pass # 核验/确认常处于 WebView,XML 录制会被标记为 other;这三页足以确认设备、列表和结果路径。 REQUIRED_SAMPLE_PAGES = {"list", "invoice_info", "success"} def safe_id(serial: str) -> str: return re.sub(r"[^A-Za-z0-9_.-]+", "_", serial) def path_for(serial: str) -> Path: return PROFILES / f"{safe_id(serial)}_{hashlib.sha256(serial.encode()).hexdigest()[:10]}.json" def load(serial: str) -> dict | None: path = path_for(serial) if not path.exists(): return None return json.loads(path.read_text(encoding="utf-8")) def save(profile: dict) -> Path: serial = str(profile.get("device", {}).get("serial", "")) if not serial: raise ProfileError("设备档案缺少 device.serial") ensure_workdirs() profile["updated_at"] = datetime.now(timezone.utc).isoformat() destination = path_for(serial) destination.write_text(json.dumps(profile, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return destination def normalized_point(point: tuple[float, float], screen: tuple[int, int]) -> tuple[int, int]: x, y = point if not 0 <= x <= 1 or not 0 <= y <= 1: raise ProfileError("坐标必须是 0 到 1 的归一化值") width, height = screen return round(x * (width - 1)), round(y * (height - 1)) def compatibility(profile: dict | None, current: dict[str, str]) -> dict: if not profile: return {"status": "missing", "reasons": ["该手机尚未采样"]} saved = profile.get("device", {}) reasons: list[str] = [] if saved.get("serial") != current.get("serial"): reasons.append("设备序列号不一致") for key, label in (("orientation", "屏幕方向"), ("screen", "屏幕尺寸"), ("density", "屏幕密度")): if saved.get(key) and saved.get(key) != current.get(key): reasons.append(f"{label}已变化") return {"status": "ready" if not reasons and profile.get("status") == "verified" else "draft", "reasons": reasons}