合并自建歌曲源支线(selfhost-source)+ 版本号按约定修正为 1.6.0

版本号:
- 1.5.11 越界(规则 3 规定 patch ≤ 10)→ 修正为 1.6.0(进位到 minor、patch 归 0)
- versionCode 43 不变:43 从未对外发布,本次修正只是把命名拉回约定

合并支线(原在工作区根目录、App 仓库之外,DESIGN.md 自称"支线"):
- selfhost-source/server/   自建歌曲源服务器(纯 Python 标准库、零 pip 依赖)
- selfhost-source/deploy/、DESIGN.md、LIBRARY.md、build_inventory.py
- selfhost-source/inventory/ 小清单(playlists.csv、song-stats.md 等)
- ⚠️ inventory 生成物(songs-*.csv / singers-top.csv,共 84MB)不入库,
  已写入 .gitignore;需要时用 build_inventory.py 依据 muse.db 重新生成

同时入库的 App 侧支线改动(此前为工作树未提交状态):
- Whoami.kt(whoami 设备心跳)、DeviceIdentity.kt(设备身份伪装)
- PushDownload.kt / PushDownloadClient.kt(点歌推送)
- AdVideoFilter 带 query/fragment 的地址闸门;KTV_BRIDGE_API 11 → 13
- 修 ktv_api.js 的 global.KtvBridgeApiVersion(12 → 13,与注释及 Kotlin 两处校验一致)
- 对应单测:WhoamiTest / DeviceIdentityTest / PushDownloadTest / AdVideoFilterTest(共 39 例)

文档:
- DESIGN.md 的"在 App 仓库之外"约定改为"已并入仓库统一版本化"
- AGENTS.md 版本号行更新为 1.6.0/43,并新增支线并入说明

验证:assembleRelease + testReleaseUnitTest 全过(39 例)
This commit is contained in:
zijunchiang
2026-10-14 02:06:28 +08:00
parent e060ae5ebe
commit 87aa979abd
68 changed files with 21305 additions and 105 deletions
+637
View File
@@ -0,0 +1,637 @@
"""设备身份(**伪装成一台安卓盒子**):内置真实盒子模板 + 随机 MAC/SN,生成一次、持久化、两侧共用。
## 为什么需要它
服务端与 App 都要向官方后端上报设备身份:
* `POST http://hbeat.ktvsky.com/whoami?<24 个字段>`(设备登记/保活);
* `GET <源站>/i.php?…&mac=<mac>_<sn>&sn=<sn>…`(取 token);
* `GET <源站>/music/do.php?…&device=<mac>_<sn>&sn=<sn>…`(取歌地址)。
改之前服务端上报的是**真实 Linux 机器**(`_m=x86_64`、`d_name=<hostname>`、
`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 的地址一眼假)。
## 持久化
默认 `<data_dir>/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:
"""身份文件路径:显式配置优先,否则 `<data_dir>/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=<mac>_<sn>`)与 `music/do.php`(`device=<mac>_<sn>`)用的是同一份身份;
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=<mac>_<sn>`、`music/do.php` 的
`device=<mac>_<sn>`)与 `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 且无身份文件)",
}