66 lines
2.3 KiB
Python
66 lines
2.3 KiB
Python
"""每台手机独立的、可移植的设备档案。坐标全部是 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}
|