"""板侧协议在服务器这一侧的镜像(需求文档 §10.8「A12 / A14 定稿」)。 改这里之前先看固件: · 命令与错误码 —— firmware/main/cmd.h(数字冻结,只许追加) · 事件类型 —— firmware/main/evlog.h 的 log_ev_t(数字就是 flash 格式,冻结) · topic —— firmware/main/mlink.h 两边对不上时**不会报错**:事件会被显示成别的类型、命令会被板子回 unknown_cmd。 """ import re import time from typing import Dict, Optional, Tuple TOPIC_ROOT = "phonerail" TOPIC_RE = re.compile(r"^phonerail/([0-9a-f]{12})/(cmd|rsp|tele|state)$") RAILS = 5 # log_ev_t(firmware/main/evlog.h)。⚠️ 数字冻结。 EVENT_TYPES: Dict[int, Tuple[str, str]] = { 0: ("NONE", "(空槽位)"), 1: ("BOOT", "板子上电"), 2: ("LOST", "事件丢失"), 3: ("OVER_VOLT", "过压关路"), 4: ("UNDER_VOLT", "欠压关路"), 5: ("REVERSE", "反灌断 VBUS"), 6: ("PUSHED_UP", "顶高断 VBUS"), 7: ("EFUSE_FAULT", "eFuse 故障"), 8: ("PG_CHANGE", "PG 变化"), 9: ("I2C_LOST", "I²C 失联"), 10: ("THERMAL_DERATE", "过温降载"), 11: ("FAN_STALL", "风扇停转"), 12: ("REBOOT", "重启序列"), 13: ("CMD_REJECT", "命令被拒"), 14: ("MUX_LOST", "mux 失联全断"), 15: ("SLOT_EMPTY", "刀片不在位"), 16: ("PD_LIMIT", "PD 限功率"), } # 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。 EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14} # cmd_id_t(firmware/main/cmd.h),CMD_REJECT 事件的 arg 就是它。 CMD_NAMES = {1: "status", 2: "set_voltage", 3: "power_cycle", 4: "key", 5: "edl", 6: "vbus", 7: "fan", 8: "events", 9: "power", 10: "wifi", 11: "mqtt", 12: "net"} KEYS = ("pwr", "vup", "vdn") KEY_MAX_MS = 15000 MV_MIN, MV_MAX = 3524, 4010 class CmdError(ValueError): """请求在发出去之前就被服务器拒了(参数不对)。""" def _rail(v) -> int: if not isinstance(v, int) or isinstance(v, bool) or not 1 <= v <= RAILS: raise CmdError("rail 必须是 1~%d" % RAILS) return v def _bool(v, name: str) -> bool: if not isinstance(v, bool): raise CmdError("%s 必须是 true/false" % name) return v def build_command(req: dict) -> dict: """网页 / 自动恢复发来的请求 → 发给板子的命令(不含 seq)。 **白名单**:只放行管理界面该有的命令。`wifi` / `mqtt` 板子本来就只收 USB, 这里再拦一道,免得界面上出现一个永远失败的按钮;`events` 由服务器自己同步。 服务器这一侧先校验一遍,是为了让操作者立刻看到中文的错误,而不是等板子回 `range`; **板子的校验仍然是唯一的权威**(SR-13 在固件里)。 """ cmd = req.get("cmd") if cmd == "status" or cmd == "net": return {"cmd": cmd} if cmd == "set_voltage": mv = req.get("mv") if not isinstance(mv, int) or isinstance(mv, bool) or not MV_MIN <= mv <= MV_MAX: raise CmdError("电压必须是 %d~%d mV" % (MV_MIN, MV_MAX)) return {"cmd": cmd, "rail": _rail(req.get("rail")), "mv": mv} if cmd in ("power_cycle", "edl"): return {"cmd": cmd, "rail": _rail(req.get("rail"))} if cmd == "key": keys = req.get("keys") if not isinstance(keys, str) or not keys or any(k not in KEYS for k in keys.split("+")): raise CmdError("keys 只能是 pwr / vup / vdn 用 + 连起来") ms = req.get("ms") if not isinstance(ms, int) or isinstance(ms, bool) or not 1 <= ms <= KEY_MAX_MS: raise CmdError("按键时长必须是 1~%d ms" % KEY_MAX_MS) return {"cmd": cmd, "rail": _rail(req.get("rail")), "keys": keys, "ms": ms} if cmd in ("vbus", "power"): return {"cmd": cmd, "rail": _rail(req.get("rail")), "on": _bool(req.get("on"), "on")} if cmd == "fan": duty = req.get("duty") if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000: raise CmdError("风扇下限必须是 0~1000‰(0 = 自动)") return {"cmd": cmd, "duty": duty} raise CmdError("不支持的命令:%r" % (cmd,)) def is_mutating(cmd: str) -> bool: return cmd not in ("status", "events", "net") class SeqGen: """seq 生成器:以毫秒时间戳为底、进程内严格递增,落在 1~2^32−1。 板子的判重窗口按「值相等」判(cmd.h),不要求单调,只要求**不撞**: 服务重启之后从当前时间重新起步,不会撞上重启前刚用过的那几十个号。 """ def __init__(self, clock=time.time): self._clock = clock self._last = 0 def next(self) -> int: now = int(self._clock() * 1000) v = max(self._last + 1, now) self._last = v return (v - 1) % 0xFFFFFFFF + 1 def parse_topic(topic: str) -> Optional[Tuple[str, str]]: m = TOPIC_RE.match(topic) return (m.group(1), m.group(2)) if m else None def topic(board: str, kind: str) -> str: return "%s/%s/%s" % (TOPIC_ROOT, board, kind)