Files
maidong-ktv/selfhost-source/server/ktv/identity.py
T
zijunchiang 87aa979abd 合并自建歌曲源支线(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 例)
2026-10-14 02:06:28 +08:00

638 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设备身份(**伪装成一台安卓盒子**):内置真实盒子模板 + 随机 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 且无身份文件)",
}