"""设备身份(**伪装成一台安卓盒子**):内置真实盒子模板 + 随机 MAC/SN,生成一次、持久化、两侧共用。 ## 为什么需要它 服务端与 App 都要向官方后端上报设备身份: * `POST http://hbeat.ktvsky.com/whoami?<24 个字段>`(设备登记/保活); * `GET <源站>/i.php?…&mac=_&sn=…`(取 token); * `GET <源站>/music/do.php?…&device=_&sn=…`(取歌地址)。 改之前服务端上报的是**真实 Linux 机器**(`_m=x86_64`、`d_name=`、 `d_fingerprint=Linux/…:user/release-keys`),App 上报的是**真实机型**(Samsung/小米…)。 这两者在抓包口径里一眼就不是"麦动盒子",且两侧互相矛盾。现在统一为: **随机挑一套常见安卓盒子模板 → 随机 mac/sn/ktv_id → 落盘 → i.php / do.php / whoami 三处同源**。 App 侧是 `DeviceIdentity.kt`(模板与生成规则与本文一一对应)。 ## 自洽性(关键) 模板字段必须像一台真盒子,不能凭空编造: * `d_fingerprint` 用 Android 官方格式 `品牌/产品名/设备名:版本/构建ID/增量:user/release-keys`(盒子固件同样是这个格式); * `_api` 与 `d_release` **必须对得上**(9→28、11→30、12→31),否则 whoami 与 UA 自相矛盾; * 抓包口径:`androidid == mac`、`_cid == "A_" + mac`(三者同源,2026-10-11 HAR 实测); * `mac` 前 3 字节取自**真实 TV 盒子/芯片厂商 OUI**(Amlogic/Realtek/ODM…), 且必须是**单播**地址(第一个八位组最低位 = 0;组播位为 1 的地址一眼假)。 ## 持久化 默认 `/device-identity.json`(`device_identity_file` 可改路径)。 写入是**原子 + 0600**:先写同目录临时文件、`fsync`、再 `os.replace` —— 避免断电/被杀时留下半截 文件把设备身份弄丢(身份一变,上游就把我们当新设备,之前建立的"合法/活跃"状态全没了)。 文件里有 mac/sn,按凭据对待(0600)。 """ from __future__ import annotations import json import logging import os import random import re import time log = logging.getLogger("ktv.identity") IDENTITY_FILENAME = "device-identity.json" IDENTITY_VERSION = 1 # `_vc`:原版客户端 versionCode(抓包值,与 ktv/whoami.py 同源口径)。 IDENTITY_VC = 4 # `_vc`/`_vn`/`_pkn` 是"这是官方 Android 客户端"的口径,三个请求必须一致。 DEFAULT_PKN = "com.origjoy.local.ktv" DEFAULT_KTV_ID = "10006789" DEFAULT_D_USER = "dpi" # `_api` ↔ Android 版本号。模板里**只写 d_release**,`_api` 由它反查 —— 这样两者永远自洽。 ANDROID_API = {"5.0": 21, "5.1": 22, "6.0": 23, "7.0": 24, "7.1": 25, "8.0": 26, "8.1": 27, "9": 28, "10": 29, "11": 30, "12": 31, "12L": 32, "13": 33, "14": 34, "15": 35} ANDROID_RELEASE = {v: k for k, v in ANDROID_API.items()} # ── 内置模板:**常见安卓盒子**(芯片方案 / 机型 / 出厂 Android 版本 都对得上)── # `oui` 取自本机 IEEE OUI 库(/usr/share/ieee-data/oui.txt)里真实存在的厂商前缀; # 部分盒子厂商在 IEEE 没有自己的 OUI(盒子网卡由 ODM 分配),注释里已注明实际持有者。 TEMPLATES: tuple[dict, ...] = ( { "preset": "aml_s905x", "label": "Amlogic S905X(A95X 类盒子 · Android 9)", "_manuf": "Amlogic", "_m": "A95X", "d_name": "a95x", "d_device": "a95x", "d_board": "a95x", "d_hardware": "amlogic", "d_release": "9", "d_build_id": "PPR1.180610.011", "d_incremental": "20190813", # Amlogic 方案盒子常见的两个前缀(抓包与拆机实测都出现过)。 "oui": ("001ec0", "d43a2e"), }, { "preset": "aml_s905x3", "label": "Amlogic S905X3(X96Max+ 类盒子 · Android 11)", "_manuf": "Amlogic", "_m": "X96MaxPlus", "d_name": "x96maxplus", "d_device": "x96maxplus", "d_board": "x96maxplus", "d_hardware": "amlogic", "d_release": "11", "d_build_id": "RQ1A.210105.003", "d_incremental": "20220318", # 1ca410 = Amlogic, Inc.(IEEE 已登记);d43a2e 见上。 "oui": ("1ca410", "d43a2e"), }, { "preset": "aml_s905x4", "label": "Amlogic S905X4(A95X-F4 类盒子 · Android 12)", "_manuf": "Amlogic", "_m": "A95XF4", "d_name": "a95xf4", "d_device": "a95xf4", "d_board": "a95xf4", "d_hardware": "amlogic", "d_release": "12", "d_build_id": "SP1A.210812.016", "d_incremental": "20220815", "oui": ("1ca410", "001ec0"), }, { "preset": "rk3328", "label": "Rockchip RK3328(H96Max 类盒子 · Android 9)", "_manuf": "Rockchip", "_m": "H96Max", "d_name": "rk3328_box", "d_device": "rk3328_box", "d_board": "rk3328_box", "d_hardware": "rk30board", "d_release": "9", "d_build_id": "PPR1.180610.011", "d_incremental": "20190226", # Rockchip 自身在 IEEE 几乎没有成品盒子 OUI:RK 方案盒子的 MAC 多由 ODM 分配。 # b49dfd = Shenzhen SDMC Technology、acdbda = Shenzhen Geniatech(都做 RK 盒子)。 "oui": ("b49dfd", "acdbda"), }, { "preset": "rtd1295", "label": "Realtek RTD1295(通用 4K 盒子 · Android 9)", "_manuf": "Realtek", "_m": "RTD1295", "d_name": "kylin32", "d_device": "kylin32", "d_board": "kylin32", "d_hardware": "rtd1295", "d_release": "9", "d_build_id": "PPR1.180610.011", "d_incremental": "20191210", # 00e04c = REALTEK SEMICONDUCTOR CORP.(IEEE 已登记)。 "oui": ("00e04c",), }, ) PRESETS: dict[str, dict] = {t["preset"]: t for t in TEMPLATES} # 所有模板用到的 OUI 合集:validate() 用它判断"这个 mac 是不是盒子厂商的地址"。 OUIS: frozenset[str] = frozenset(oui for t in TEMPLATES for oui in t["oui"]) # 旧版本把这两个抓包值当默认写进了 server-config.json;本版默认改为"空 = 跟随自动身份", # 因此这两个历史常量**不算用户显式配置**(否则老部署升级后自动身份永远不生效)。 LEGACY_DEFAULTS = {"upstream_mac": "800b510bf4fd", "upstream_sn": "2d7bb4a7d7f7"} # `mac`/`sn` 的形态:12 位小写十六进制(无分隔符,与抓包一致)。 _RE_HEX12 = re.compile(r"^[0-9a-f]{12}$") # whoami 里**与设备有关**的字段(顺序 = 抓包里的相对顺序)。 # `_src/_src_at/_player/_boot/_ts/d_utc` 与设备无关,由 ktv/whoami.py 按配置与当前时间生成。 WHOAMI_IDENTITY_FIELDS = ("_m", "_mac", "_vc", "_vn", "_pkn", "_api", "ktv_id", "androidid", "_manuf", "_cid", "d_name", "d_device", "d_board", "d_hardware", "d_fingerprint", "d_incremental", "d_release", "d_user") # ── 基础工具 ─────────────────────────────────────────────── def android_api(release: str) -> int: """Android 版本号 → `_api`(未知取值回退 31 = Android 12,与本项目既有默认一致)。""" return int(ANDROID_API.get(str(release or "").strip(), 31)) def android_release(api) -> str: """`_api` → Android 版本号(未知取值回退 "12")。""" try: return ANDROID_RELEASE.get(int(api), "12") except Exception: return "12" def _rand_hex(n: int, rnd: random.Random | None = None) -> str: r = rnd or random.SystemRandom() return "".join(r.choice("0123456789abcdef") for _ in range(n)) def new_mac(oui: str, rnd: random.Random | None = None) -> str: """`OUI + 3 字节随机` 的 12 位小写 hex。 前半段是真实盒子厂商前缀,因此**单播位天然为 0**(所有内置 OUI 都是单播); 后半段随机,保证同模板多次生成不重复。 """ prefix = str(oui or "").strip().lower() if not _RE_HEX12.match(prefix + "000000"): raise ValueError(f"非法 OUI:{oui!r}") return prefix + _rand_hex(6, rnd) def new_sn(rnd: random.Random | None = None) -> str: """`sn`:12 位随机 hex(抓包形态,与 mac 同长度)。""" return _rand_hex(12, rnd) def new_ktv_id(rnd: random.Random | None = None) -> str: """`ktv_id`:8 位数字(抓包值 `10006789` 就是这个形态)。""" r = rnd or random.SystemRandom() return str(r.randint(10_000_000, 99_999_999)) # ── 生成 ─────────────────────────────────────────────────── def generate(preset: str = "", vn: str = "", pkn: str = "", api: int = 0, ktv_id: str = "", rnd: random.Random | None = None) -> dict: """生成一份新的盒子身份。 * `preset` 为空 = 从内置模板里**随机挑一套**;给了未知 preset 会回退到随机(并记日志); * `vn`/`pkn` 默认取本项目既有口径(`resolver.VN` / `com.origjoy.local.ktv`); * `api` 给了就按它反推 `d_release`,否则用模板自带的版本(保证两者自洽); * `rnd` 仅测试用(固定种子可复现)。 """ tpl = PRESETS.get(str(preset or "").strip()) if tpl is None: if preset: log.warning("未知的设备身份模板 %r,改为随机模板", preset) tpl = random.SystemRandom().choice(TEMPLATES) from .resolver import VN # 与本项目取址协议同源,避免重复定义 release = android_release(api) if api else str(tpl["d_release"]) mac = new_mac(random.SystemRandom().choice(tpl["oui"]), rnd) ident = { "version": IDENTITY_VERSION, "preset": tpl["preset"], "label": tpl["label"], "created_at": int(time.time()), "mac": mac, "sn": new_sn(rnd), "_m": tpl["_m"], "_manuf": tpl["_manuf"], "d_name": tpl["d_name"], "d_device": tpl["d_device"], "d_board": tpl["d_board"], "d_hardware": tpl["d_hardware"], "d_release": release, # ⚠️ fingerprint 必须用模板里写好的**真盒子形态**,不要再按服务端平台字段重拼一遍 # (重拼出来的是 Linux/… 那种一眼假的指纹,且与 _manuf/d_* 未必对得上)。 "d_fingerprint": (f"{tpl['_manuf']}/{tpl['d_name']}/{tpl['d_device']}:" f"{release}/{tpl['d_build_id']}/{tpl['d_incremental']}:user/release-keys"), "d_incremental": tpl["d_incremental"], "d_user": DEFAULT_D_USER, "ktv_id": str(ktv_id or "").strip() or new_ktv_id(rnd), "_api": int(api) if api else android_api(release), "_vc": IDENTITY_VC, "_vn": str(vn or VN), "_pkn": str(pkn or DEFAULT_PKN), } # 抓包口径:`_mac` / `androidid` / `_cid` 三者同源(`_cid` 带 `A_` 前缀)。 ident["androidid"] = mac ident["_cid"] = f"A_{mac}" return ident def validate(identity: dict) -> list[str]: """校验一份身份是否自洽;返回问题列表(**空列表 = 合法**)。 只做"能机器判定"的检查(形态、单播、OUI 归属、`_api`/`d_release`、fingerprint 格式、 `androidid`/`_cid` 同源);不做"像不像真盒子"的主观判断。 """ if not isinstance(identity, dict): return ["身份不是字典"] problems: list[str] = [] mac = str(identity.get("mac") or "").strip() if not _RE_HEX12.match(mac): problems.append("mac 必须是 12 位小写十六进制") else: if int(mac[:2], 16) & 0x01: problems.append("mac 是组播地址(第一个八位组最低位必须为 0)") if mac == "0" * 12: problems.append("mac 全 0(无效地址)") if mac[:6] not in OUIS: problems.append(f"mac 的 OUI {mac[:6]} 不在内置盒子厂商 OUI 表内") if not _RE_HEX12.match(str(identity.get("sn") or "").strip()): problems.append("sn 必须是 12 位小写十六进制") if str(identity.get("androidid") or "") != mac: problems.append("androidid 必须等于 mac(抓包口径)") if str(identity.get("_cid") or "") != f"A_{mac}": problems.append('_cid 必须等于 "A_" + mac') for key in ("preset", "_m", "_manuf", "d_name", "d_device", "d_board", "d_hardware", "d_incremental", "d_release", "d_user", "_pkn", "_vn", "ktv_id"): if not str(identity.get(key) or "").strip(): problems.append(f"{key} 不能为空") try: api = int(identity.get("_api") or 0) if not 21 <= api <= 35: problems.append(f"_api {api} 不是合法的 Android API 级别") elif android_release(api) != str(identity.get("d_release") or ""): problems.append(f"_api={api} 与 d_release={identity.get('d_release')} 不自洽" f"(应为 {android_release(api)})") except Exception: problems.append("_api 必须是整数") try: if int(identity.get("_vc") or 0) <= 0: problems.append("_vc 必须是正整数") except Exception: problems.append("_vc 必须是整数") fp = str(identity.get("d_fingerprint") or "") release = str(identity.get("d_release") or "") manuf = str(identity.get("_manuf") or "") if not fp.lower().startswith(manuf.lower() + "/"): problems.append("d_fingerprint 必须以 `_manuf/` 开头(brand 与厂商一致)") if f":{release}/" not in fp: problems.append("d_fingerprint 里的版本段必须等于 d_release") if not fp.endswith(":user/release-keys"): problems.append("d_fingerprint 必须以 `:user/release-keys` 结尾(真机固件格式)") if fp.count("/") < 4 or fp.count(":") < 2: problems.append("d_fingerprint 分段数量不像真机指纹") return problems def to_url_params(identity: dict) -> dict: """身份里**与设备有关**的那部分 whoami 字段(`ktv/whoami.py` 用它填 24 个字段)。 `_src`/`_src_at`/`_player`/`_boot`/`_ts`/`d_utc` 与设备无关,仍由 whoami 侧生成。 """ ident = identity or {} out = {k: str(ident.get(k) or "") for k in WHOAMI_IDENTITY_FIELDS} # 身份里 mac 存的是裸字段名 `mac`,whoami 上报时叫 `_mac`(抓包口径)。 out["_mac"] = str(ident.get("mac") or "") out["_vc"] = str(ident.get("_vc") or IDENTITY_VC) out["_api"] = str(ident.get("_api") or 31) out["_pkn"] = str(ident.get("_pkn") or DEFAULT_PKN) out["_cid"] = str(ident.get("_cid") or f"A_{out['_mac']}") out["androidid"] = str(ident.get("androidid") or out["_mac"]) return out # ── 持久化(原子写 + 0600)────────────────────────────────── def resolve_path(data_dir: str, path: str = "") -> str: """身份文件路径:显式配置优先,否则 `/device-identity.json`。""" p = str(path or "").strip() if p: return os.path.abspath(os.path.expanduser(p)) return os.path.join(str(data_dir or "."), IDENTITY_FILENAME) def load(path: str) -> dict | None: """读身份文件;文件不存在/损坏/不是字典 → 返回 None(由调用方决定是否重新生成)。""" try: with open(path, "r", encoding="utf-8") as fh: data = json.load(fh) return data if isinstance(data, dict) else None except FileNotFoundError: return None except Exception as exc: # ⚠️ 用 log.info 而不是 warning:这是**预期内**的兜底路径(文件可能被人手改坏), # 写成 warning 会在没配 handler 的场景(如单测)经 logging.lastResort 刷 stderr。 log.info("设备身份文件读取失败(将重新生成):%s: %s", path, exc) return None def save(identity: dict, path: str) -> None: """**原子写 + 0600**:同目录临时文件 → fsync → `os.replace`。 直接 `open(path, "w")` 会在写到一半时(断电/进程被杀)留下半截 JSON, 下次启动读到损坏文件只能重新生成 → 上游把我们当新设备。所以必须原子替换。 """ path = os.path.abspath(path) os.makedirs(os.path.dirname(path) or ".", exist_ok=True) tmp = f"{path}.tmp.{os.getpid()}" fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) try: with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(identity, fh, ensure_ascii=False, indent=2) fh.write("\n") fh.flush() os.fsync(fh.fileno()) os.chmod(tmp, 0o600) # umask(022)可能削掉权限,替换前显式再设一次 os.replace(tmp, path) except BaseException: try: os.unlink(tmp) except OSError: pass raise def load_or_create(data_dir: str, preset: str = "", path: str = "") -> dict: """存在就复用、不存在就生成并落盘。**同步执行**(纯本地小文件,不涉及任何网络)。""" p = resolve_path(data_dir, path) ident = load(p) if ident is not None: problems = validate(ident) if not problems: return ident log.info("设备身份文件不自洽(%s),将重新生成:%s", "; ".join(problems[:3]), p) ident = generate(preset) save(ident, p) return ident def regenerate(data_dir: str, preset: str = "", path: str = "") -> dict: """重新生成一份身份并覆盖落盘(旧文件整份替换,不会新旧字段混在一起)。""" p = resolve_path(data_dir, path) ident = generate(preset) save(ident, p) return ident # ── 接进配置(谁是"用户显式配置")──────────────────────────── def is_explicit(cfg, key: str) -> bool: """`cfg` 里该键是不是**用户显式配置**的(而不是默认值/自动身份填进去的)。 优先用 `Config.is_explicit()`(它知道配置文件里到底写了哪些键);对鸭子类型的 `cfg` (单测里的小对象)退化为"非空即显式",同时把旧版本的抓包常量排除掉。 """ fn = getattr(cfg, "is_explicit", None) if callable(fn): try: return bool(fn(key)) except Exception: pass try: value = str(cfg.get(key) or "").strip() except Exception: return False return bool(value) and value != LEGACY_DEFAULTS.get(key) def apply_to_cfg(cfg, identity: dict) -> dict: """把身份接进运行期配置,并返回实际生效的覆盖结果。 做两件事: 1. `upstream_mac` / `upstream_sn` ← 身份的 mac/sn(**用户显式配置优先**)—— 这样 `i.php`(`mac=_`)与 `music/do.php`(`device=_`)用的是同一份身份; 2. `device_identity` ← 整份身份,供 `ktv/whoami.py` 取 `_m/_manuf/d_*` 等字段 (whoami 的 `_mac`/`androidid`/`_cid` 也从这里来,**不再各读各处**)。 写的是**运行期值**(`Config.set_runtime`,不落盘):一旦落盘,"自动生成的值"下次就会被 当成"用户显式配置",`regenerate()` 之后 upstream_mac 反而换不掉。 """ ident = identity or {} applied: dict = {} for key, value in (("upstream_mac", ident.get("mac")), ("upstream_sn", ident.get("sn"))): value = str(value or "") if not value: continue if is_explicit(cfg, key): applied[key] = str(cfg.get(key) or "") # 显式配置优先,不覆盖 applied[f"{key}_source"] = "explicit" continue setter = getattr(cfg, "set_runtime", None) if callable(setter): setter(key, value) else: # 鸭子类型兜底(单测里的假 cfg) try: cfg.update({key: value}) except Exception: pass applied[key] = value applied[f"{key}_source"] = "identity" setter = getattr(cfg, "set_runtime", None) if callable(setter): setter("device_identity", dict(ident)) return applied def active_identity(cfg) -> dict | None: """当前生效的设备身份(`apply_to_cfg` 写进配置的那份);没有则 None。""" try: ident = cfg.get("device_identity") except Exception: return None return ident if isinstance(ident, dict) and ident.get("mac") else None def regenerate_identity(cfg, device_identity, downloader=None, preset: str = "") -> dict: """**一处**完成"换设备身份"的全部动作(管理接口 / 设置页 / 自动维护共用,绝不各写一份)。 1. `DeviceIdentity.regenerate()`:生成并原子落盘新身份 → `activate()` → `apply_to_cfg()` —— `upstream_mac`/`upstream_sn`(`i.php` 的 `mac=_`、`music/do.php` 的 `device=_`)与 `cfg["device_identity"]`(whoami 的 `_mac`/`androidid`/`_cid`) **同一刻**换成新身份,"三处同源"由此保证; 2. 清掉取址器缓存的 token:token 绑在旧 device 上,继续用会被上游判"换设备/异常"。 返回新身份;调用方需自行判断 `device_identity is None`(这里会抛 ValueError)。 """ if device_identity is None: raise ValueError("设备身份未启用") ident = device_identity.regenerate(str(preset or "")) if downloader is not None: try: downloader.resolver.clear_tokens() except Exception: # pragma: no cover - 兜底 pass return ident class DeviceIdentity: """设备身份的生命周期管理:**同步加载** → 接进配置 → 供接口重新生成。 ⚠️ 加载是纯本地小文件读取(可以同步做);**网络**动作(whoami)仍必须在端口监听之后、 后台线程里(见 `main.py` 的启动顺序)。 """ def __init__(self, cfg, store=None): self.cfg = cfg self.store = store self.identity: dict | None = None # "loaded"(复用已有文件)/ "generated"(本次生成)/ "explicit"(配置里写死了 mac/sn, # 身份不参与 mac/sn 上报)/ "disabled"(未开启自动身份且没有身份文件)/ # "invalid"(文件不自洽且未重建)/ "none" self.origin: str = "none" self.problems: list[str] = [] # ── 路径与开关 ── def path(self) -> str: return resolve_path(str(self.cfg.get("data_dir") or "."), str(self.cfg.get("device_identity_file") or "")) def auto(self) -> bool: return bool(self.cfg.get("device_identity_auto", True)) def preset(self) -> str: return str(self.cfg.get("device_identity_preset") or "").strip() # ── 加载 / 生成 ── def ensure(self) -> dict | None: """启动时调用:载入或生成身份,并接进配置。返回生效的身份(可能为 None)。""" path = self.path() exists = os.path.isfile(path) preset = self.preset() ident = None if exists: ident = load(path) if ident is not None: self.problems = validate(ident) if self.problems: log.info("设备身份文件不自洽(%s):%s", "; ".join(self.problems[:3]), path) ident = None if ident is not None: self.origin = "loaded" elif self.auto(): ident = load_or_create(str(self.cfg.get("data_dir") or "."), preset, str(self.cfg.get("device_identity_file") or "")) self.origin = "generated" self.problems = validate(ident) else: # 未开启自动身份:只在**已有文件**时复用,绝不新建(避免"关掉却还在生成")。 self.origin = "invalid" if exists else "disabled" self.identity = ident if ident is not None: applied = apply_to_cfg(self.cfg, ident) if applied.get("upstream_mac_source") == "explicit" \ and applied.get("upstream_sn_source") == "explicit": self.origin = "explicit" return self.identity def activate(self, identity: dict) -> dict: """把一份身份接进配置(重新生成后调用),返回生效的覆盖结果。""" self.identity = identity self.problems = validate(identity) return apply_to_cfg(self.cfg, identity) def regenerate(self, preset: str = "") -> dict: """重新生成身份并接进配置(**只在用户点击/接口调用时执行**)。""" path = self.path() ident = regenerate(str(self.cfg.get("data_dir") or "."), str(preset or "").strip() or self.preset(), str(self.cfg.get("device_identity_file") or "")) self.origin = "generated" applied = self.activate(ident) if applied.get("upstream_mac_source") == "explicit" \ and applied.get("upstream_sn_source") == "explicit": self.origin = "explicit" # 配置里写死了 mac/sn → 身份不参与上报 log.info("设备身份已重新生成:模板=%s mac=%s sn=%s 文件=%s", ident.get("preset"), ident.get("mac"), ident.get("sn"), path) if self.store is not None: try: self.store.event("INFO", f"设备身份重新生成:模板={ident.get('preset')} " f"机型={ident.get('_m')} mac={ident.get('mac')} " f"sn={ident.get('sn')} 文件={path}") except Exception: # pragma: no cover - 兜底 pass return ident # ── 时效(`device_identity_ttl_hours`,默认 24 小时)── def ttl_hours(self) -> float: """身份有效期(小时);配置缺失/非法回落到 24,0 = 关闭时效检查。""" try: raw = self.cfg.get("device_identity_ttl_hours") except Exception: return 24.0 if raw is None or raw == "": return 24.0 try: return max(0.0, float(raw)) except Exception: return 24.0 def created_at(self) -> int: """身份文件的生成时间(`created_at`,秒);没有则 0(视为未知)。""" try: return int((self.identity or {}).get("created_at") or 0) except Exception: return 0 def age_sec(self) -> int: c = self.created_at() return max(0, int(time.time()) - c) if c > 0 else 0 def expires_in_sec(self) -> int: """距离过期还有多少秒(0 = 已过期或未知)。""" if self.created_at() <= 0: return 0 return max(0, int(self.ttl_hours() * 3600) - self.age_sec()) def expired(self) -> bool: """是否已超过 `device_identity_ttl_hours`(0 小时 = 关闭检查,永远 False)。""" ttl = self.ttl_hours() return bool(self.created_at() > 0 and ttl > 0 and self.age_sec() > int(ttl * 3600)) # ── 给接口/界面看的状态 ── def status(self) -> dict: ident = self.identity or {} path = self.path() return { "ok": bool(ident), "auto": self.auto(), "file": path, "exists": os.path.isfile(path), "origin": self.origin, "preset": ident.get("preset", ""), "label": ident.get("label", ""), "presets": [{"preset": t["preset"], "label": t["label"]} for t in TEMPLATES], "identity": dict(ident), "problems": list(self.problems), "ttl_hours": self.ttl_hours(), "age_sec": self.age_sec(), "expires_in_sec": self.expires_in_sec(), "expired": self.expired(), "effective": { "upstream_mac": str(self.cfg.get("upstream_mac") or ""), "upstream_sn": str(self.cfg.get("upstream_sn") or ""), "mac_source": "explicit" if is_explicit(self.cfg, "upstream_mac") else "identity", "sn_source": "explicit" if is_explicit(self.cfg, "upstream_sn") else "identity", }, "error": "" if ident else "尚未启用设备身份(device_identity_auto=false 且无身份文件)", }