rack:刷机模式界面与事件;事件 21 新增 254「断电自动退出」

- 单板页「更多 → 刷机模式(1 小时)/ 加钟 30 分钟」,chg>0 时蓝色「刷机模式 · 充电中」、FLT 显示为「反向阻断」
- 协议白名单 flash_mode;事件 21 命名:开 / 手动关 / 到期 / 加钟 / 断电自动退出
- ⚠️ 254 带 0x80 位,必须先于「加钟」判断,否则显示成「加钟 126 分钟」

48 例全绿,已部署。

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
这个提交包含在:
zhou yang
2026-09-29 08:16:48 +08:00
共同撰写人 Claude Opus 5.5
父节点 9fb5bba367
当前提交 86d9e2a7d4
共修改 8 个文件,包含 777 行新增和 637 行删除
+7
查看文件
@@ -188,6 +188,13 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
if d["arg"] == 3: if d["arg"] == 3:
d["name"] += ":" + protocol.OTA_ERR_NAMES.get(d["mv"], str(d["mv"])) d["name"] += ":" + protocol.OTA_ERR_NAMES.get(d["mv"], str(d["mv"]))
d["severe"] = d["arg"] in (3, 5) d["severe"] = d["arg"] in (3, 5)
elif d["type"] == 21:
a = d["arg"]
d["name"] += "(%s)" % ("手动关" if a == protocol.CHG_EV_OFF else
"到期" if a == protocol.CHG_EV_EXPIRED else
"断电自动退出" if a == protocol.CHG_EV_DROPPED else
"加钟 %d 分钟" % (a & 0x7F) if a & protocol.CHG_EV_EXTEND else
"开 %d 分钟" % a)
elif d["type"] == 19: elif d["type"] == 19:
failed = bool(d["arg"] & protocol.BOOT_CHECK_FAILED) failed = bool(d["arg"] & protocol.BOOT_CHECK_FAILED)
d["name"] += "(按了 %d 次,%s)" % (d["arg"] & 0x0F, "没开起来" if failed else "开起来了") d["name"] += "(按了 %d 次,%s)" % (d["arg"] & 0x0F, "没开起来" if failed else "开起来了")
+167 -143
查看文件
@@ -1,143 +1,167 @@
"""板侧协议在服务器这一侧的镜像(需求文档 §10.8「A12 / A14 定稿」)。 """板侧协议在服务器这一侧的镜像(需求文档 §10.8「A12 / A14 定稿」)。
改这里之前先看固件: 改这里之前先看固件:
· 命令与错误码 —— firmware/main/cmd.h(数字冻结,只许追加) · 命令与错误码 —— firmware/main/cmd.h(数字冻结,只许追加)
· 事件类型 —— firmware/main/evlog.h 的 log_ev_t(数字就是 flash 格式,冻结) · 事件类型 —— firmware/main/evlog.h 的 log_ev_t(数字就是 flash 格式,冻结)
· topic —— firmware/main/mlink.h · topic —— firmware/main/mlink.h
两边对不上时**不会报错**:事件会被显示成别的类型、命令会被板子回 unknown_cmd。 两边对不上时**不会报错**:事件会被显示成别的类型、命令会被板子回 unknown_cmd。
""" """
import re import re
import time import time
from typing import Dict, Optional, Tuple from typing import Dict, Optional, Tuple
TOPIC_ROOT = "phonerail" TOPIC_ROOT = "phonerail"
TOPIC_RE = re.compile(r"^phonerail/([0-9a-f]{12})/(cmd|rsp|tele|state)$") TOPIC_RE = re.compile(r"^phonerail/([0-9a-f]{12})/(cmd|rsp|tele|state)$")
RAILS = 5 RAILS = 5
# log_ev_t(firmware/main/evlog.h)。⚠️ 数字冻结。 # log_ev_t(firmware/main/evlog.h)。⚠️ 数字冻结。
EVENT_TYPES: Dict[int, Tuple[str, str]] = { EVENT_TYPES: Dict[int, Tuple[str, str]] = {
0: ("NONE", "(空槽位)"), 0: ("NONE", "(空槽位)"),
1: ("BOOT", "板子上电"), 1: ("BOOT", "板子上电"),
2: ("LOST", "事件丢失"), 2: ("LOST", "事件丢失"),
3: ("OVER_VOLT", "过压关路"), 3: ("OVER_VOLT", "过压关路"),
4: ("UNDER_VOLT", "欠压关路"), 4: ("UNDER_VOLT", "欠压关路"),
5: ("REVERSE", "反灌断 VBUS"), 5: ("REVERSE", "反灌断 VBUS"),
6: ("PUSHED_UP", "顶高断 VBUS"), 6: ("PUSHED_UP", "顶高断 VBUS"),
7: ("EFUSE_FAULT", "eFuse 故障"), 7: ("EFUSE_FAULT", "eFuse 故障"),
8: ("PG_CHANGE", "PG 变化"), 8: ("PG_CHANGE", "PG 变化"),
9: ("I2C_LOST", "I²C 失联"), 9: ("I2C_LOST", "I²C 失联"),
10: ("THERMAL_DERATE", "过温降载"), 10: ("THERMAL_DERATE", "过温降载"),
11: ("FAN_STALL", "风扇停转"), 11: ("FAN_STALL", "风扇停转"),
12: ("REBOOT", "重启序列"), 12: ("REBOOT", "重启序列"),
13: ("CMD_REJECT", "命令被拒"), 13: ("CMD_REJECT", "命令被拒"),
14: ("MUX_LOST", "mux 失联全断"), 14: ("MUX_LOST", "mux 失联全断"),
15: ("SLOT_EMPTY", "刀片不在位"), 15: ("SLOT_EMPTY", "刀片不在位"),
16: ("PD_LIMIT", "PD 限功率"), 16: ("PD_LIMIT", "PD 限功率"),
17: ("PG_TIMEOUT", "上电等不到 PG"), # A15:arg bit0 = FLT# 在报,bit1 = 按电压判 17: ("PG_TIMEOUT", "上电等不到 PG"), # A15:arg bit0 = FLT# 在报,bit1 = 按电压判
18: ("OTA", "固件升级"), # C2:arg 1 开始 / 2 就绪 / 3 失败(mV 列=原因) / 4 自证健康 / 5 回滚 / 6 为换固件重启 18: ("OTA", "固件升级"), # C2:arg 1 开始 / 2 就绪 / 3 失败(mV 列=原因) / 4 自证健康 / 5 回滚 / 6 为换固件重启
19: ("BOOT_CHECK", "开机重按"), 19: ("BOOT_CHECK", "开机重按"), # 0.1.2:arg bit0~3 = 按了几次,bit7 = 最终没开起来(电源键弹片接触不良)
20: ("BLADE_MUTE", "刀片不应答关路"), # 0.1.4:eFuse 开着、本路 INA226 + PCA9555 连续 200 ms 都不应答 # 0.1.2:arg bit0~3 = 按了几次,bit7 = 最终没开起来(电源键弹片接触不良) 20: ("BLADE_MUTE", "刀片不应答关路"), # 0.1.4:eFuse 开着、本路 INA226 + PCA9555 连续 200 ms 都不应答
} 21: ("CHG_MODE", "刷机模式"), # 0.1.5:arg 1~120 = 开(分钟),0 = 手动关,255 = 到期;0.1.6:0x80|分钟 = 加钟;0.1.13:254 = 断电自动退出
}
# LOG_EV_OTA 的 arg(firmware/main/ota.h 的 ota_ev_t)与失败原因(ota_err_t,失败那条的 mV 列)。⚠️ 冻结。
OTA_EV_NAMES = {1: "开始下载", 2: "已就绪", 3: "失败", 4: "新固件自证健康", 5: "回滚", 6: "为换固件重启", # LOG_EV_CHG_MODE 的 arg(firmware/main/chgmode.h)。⚠️ 冻结。
7: "计划内重启:接管、没断电"} CHG_EV_OFF, CHG_EV_EXPIRED, CHG_EV_EXTEND = 0, 255, 0x80
OTA_ERR_NAMES = {1: "下载失败", 2: "大小不对", 3: "哈希不对", 4: "写 flash 失败", 5: "镜像校验不过", CHG_EV_DROPPED = 254 # 0.1.13:这一路断了电(刀片拔走 / 安全判据关路),自动退出。⚠️ 带 0x80 位,要先于「加钟」判
6: "切启动分区失败", 7: "没有备用槽 / 新固件未自证"} FLASH_MODE_MAX_MIN = 120
# 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。 # LOG_EV_OTA 的 arg(firmware/main/ota.h 的 ota_ev_t)与失败原因(ota_err_t,失败那条的 mV 列)。⚠️ 冻结。
EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14, 17, 20} # OTA 的失败 / 回滚、开机重按的「没开起来」在 api.events 里按 arg 单独标红 OTA_EV_NAMES = {1: "开始下载", 2: "已就绪", 3: "失败", 4: "新固件自证健康", 5: "回滚", 6: "为换固件重启",
BOOT_CHECK_FAILED = 0x80 # LOG_EV_BOOT_CHECK 的 arg bit7 7: "计划内重启:接管、没断电"}
OTA_ERR_NAMES = {1: "下载失败", 2: "大小不对", 3: "哈希不对", 4: "写 flash 失败", 5: "镜像校验不过",
# cmd_id_t(firmware/main/cmd.h),CMD_REJECT 事件的 arg 就是它。 6: "切启动分区失败", 7: "没有备用槽 / 新固件未自证"}
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", # 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。
13: "ota"} EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14, 17, 20} # OTA 的失败 / 回滚、开机重按的「没开起来」在 api.events 里按 arg 单独标红
BOOT_CHECK_FAILED = 0x80 # LOG_EV_BOOT_CHECK 的 arg bit7
KEYS = ("pwr", "vup", "vdn")
KEY_MAX_MS = 15000 # cmd_id_t(firmware/main/cmd.h),CMD_REJECT 事件的 arg 就是它。
MV_MIN, MV_MAX = 3524, 4010 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",
13: "ota", 14: "flash_mode"}
class CmdError(ValueError):
"""请求在发出去之前就被服务器拒了(参数不对)。""" KEYS = ("pwr", "vup", "vdn")
KEY_MAX_MS = 15000
MV_MIN, MV_MAX = 3524, 4010
def _rail(v) -> int:
if not isinstance(v, int) or isinstance(v, bool) or not 1 <= v <= RAILS:
raise CmdError("rail 必须是 1~%d" % RAILS) class CmdError(ValueError):
return v """请求在发出去之前就被服务器拒了(参数不对)。"""
def _bool(v, name: str) -> bool: def _rail(v) -> int:
if not isinstance(v, bool): if not isinstance(v, int) or isinstance(v, bool) or not 1 <= v <= RAILS:
raise CmdError("%s 必须是 true/false" % name) raise CmdError("rail 必须是 1~%d" % RAILS)
return v return v
def build_command(req: dict) -> dict: def _bool(v, name: str) -> bool:
"""网页 / 自动恢复发来的请求 → 发给板子的命令(不含 seq)。 if not isinstance(v, bool):
raise CmdError("%s 必须是 true/false" % name)
**白名单**:只放行管理界面该有的命令。`wifi` / `mqtt` 板子本来就只收 USB, return v
这里再拦一道,免得界面上出现一个永远失败的按钮;`events` 由服务器自己同步。
服务器这一侧先校验一遍,是为了让操作者立刻看到中文的错误,而不是等板子回 `range`;
**板子的校验仍然是唯一的权威**(SR-13 在固件里)。 def build_command(req: dict) -> dict:
""" """网页 / 自动恢复发来的请求 → 发给板子的命令(不含 seq)。
cmd = req.get("cmd")
if cmd == "status" or cmd == "net": **白名单**:只放行管理界面该有的命令。`wifi` / `mqtt` 板子本来就只收 USB,
return {"cmd": cmd} 这里再拦一道,免得界面上出现一个永远失败的按钮;`events` 由服务器自己同步。
if cmd == "set_voltage": 服务器这一侧先校验一遍,是为了让操作者立刻看到中文的错误,而不是等板子回 `range`;
mv = req.get("mv") **板子的校验仍然是唯一的权威**(SR-13 在固件里)。
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)) cmd = req.get("cmd")
return {"cmd": cmd, "rail": _rail(req.get("rail")), "mv": mv} if cmd == "status" or cmd == "net":
if cmd in ("power_cycle", "edl"): return {"cmd": cmd}
return {"cmd": cmd, "rail": _rail(req.get("rail"))} if cmd == "set_voltage":
if cmd == "key": mv = req.get("mv")
keys = req.get("keys") if not isinstance(mv, int) or isinstance(mv, bool) or not MV_MIN <= mv <= MV_MAX:
if not isinstance(keys, str) or not keys or any(k not in KEYS for k in keys.split("+")): raise CmdError("电压必须是 %d~%d mV" % (MV_MIN, MV_MAX))
raise CmdError("keys 只能是 pwr / vup / vdn 用 + 连起来") return {"cmd": cmd, "rail": _rail(req.get("rail")), "mv": mv}
ms = req.get("ms") if cmd in ("power_cycle", "edl"):
if not isinstance(ms, int) or isinstance(ms, bool) or not 1 <= ms <= KEY_MAX_MS: return {"cmd": cmd, "rail": _rail(req.get("rail"))}
raise CmdError("按键时长必须是 1~%d ms" % KEY_MAX_MS) if cmd == "key":
return {"cmd": cmd, "rail": _rail(req.get("rail")), "keys": keys, "ms": ms} keys = req.get("keys")
if cmd in ("vbus", "power"): if not isinstance(keys, str) or not keys or any(k not in KEYS for k in keys.split("+")):
return {"cmd": cmd, "rail": _rail(req.get("rail")), "on": _bool(req.get("on"), "on")} raise CmdError("keys 只能是 pwr / vup / vdn 用 + 连起来")
if cmd == "fan": ms = req.get("ms")
duty = req.get("duty") if not isinstance(ms, int) or isinstance(ms, bool) or not 1 <= ms <= KEY_MAX_MS:
if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000: raise CmdError("按键时长必须是 1~%d ms" % KEY_MAX_MS)
raise CmdError("风扇下限必须是 0~1000‰(0 = 自动)") return {"cmd": cmd, "rail": _rail(req.get("rail")), "keys": keys, "ms": ms}
return {"cmd": cmd, "duty": duty} if cmd in ("vbus", "power"):
raise CmdError("不支持的命令:%r" % (cmd,)) return {"cmd": cmd, "rail": _rail(req.get("rail")), "on": _bool(req.get("on"), "on")}
if cmd == "flash_mode":
# 0.1.5 刷机模式:VBUS 通着时允许手机充电、不断 USB。板子最多同时 2 路(多了回 state)。
def is_mutating(cmd: str) -> bool: # 0.1.6 加钟:{"add": 分钟},与 on / min 互斥;只给开着的窗口加,剩余最多 120 分钟(板子截断)。
return cmd not in ("status", "events", "net") if req.get("add") is not None:
a = req.get("add")
if not isinstance(a, int) or isinstance(a, bool) or not 1 <= a <= FLASH_MODE_MAX_MIN:
class SeqGen: raise CmdError("加钟必须是 1~%d 分钟" % FLASH_MODE_MAX_MIN)
"""seq 生成器:以毫秒时间戳为底、进程内严格递增,落在 1~2^32−1。 if req.get("on") is not None or req.get("min") is not None:
raise CmdError("加钟不能和 on / min 一起用")
板子的判重窗口按「值相等」判(cmd.h),不要求单调,只要求**不撞**: return {"cmd": cmd, "rail": _rail(req.get("rail")), "add": a}
服务重启之后从当前时间重新起步,不会撞上重启前刚用过的那几十个号。 on = _bool(req.get("on"), "on")
""" out = {"cmd": cmd, "rail": _rail(req.get("rail")), "on": on}
if on and req.get("min") is not None:
def __init__(self, clock=time.time): m = req.get("min")
self._clock = clock if not isinstance(m, int) or isinstance(m, bool) or not 1 <= m <= FLASH_MODE_MAX_MIN:
self._last = 0 raise CmdError("刷机模式时长必须是 1~%d 分钟" % FLASH_MODE_MAX_MIN)
out["min"] = m
def next(self) -> int: return out
now = int(self._clock() * 1000) if cmd == "fan":
v = max(self._last + 1, now) duty = req.get("duty")
self._last = v if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000:
return (v - 1) % 0xFFFFFFFF + 1 raise CmdError("风扇下限必须是 0~1000‰(0 = 自动)")
return {"cmd": cmd, "duty": duty}
raise CmdError("不支持的命令:%r" % (cmd,))
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 is_mutating(cmd: str) -> bool:
return cmd not in ("status", "events", "net")
def topic(board: str, kind: str) -> str:
return "%s/%s/%s" % (TOPIC_ROOT, board, kind) 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)
+28 -1
查看文件
@@ -30,6 +30,7 @@ class SimRail:
self.busy_until = 0 self.busy_until = 0
self.act = "idle" self.act = "idle"
self.pending: Optional[str] = None # 序列结束时要做的事 self.pending: Optional[str] = None # 序列结束时要做的事
self.chg_until = 0 # 刷机模式截止(模拟时钟 ms),0 = 没开
class SimBoard: class SimBoard:
@@ -132,7 +133,9 @@ class SimBoard:
rails.append({"r": r.rail, "pr": r.present, "en": r.en, "vb": r.vb, "set": r.set_mv, rails.append({"r": r.rail, "pr": r.present, "en": r.en, "vb": r.vb, "set": r.set_mv,
"mv": (r.set_mv if r.en else 0) if ok else None, "mv": (r.set_mv if r.en else 0) if ok else None,
"ua": r.ua if ok else None, "pg": r.en if ok else None, "ua": r.ua if ok else None, "pg": r.en if ok else None,
"flt": False if ok else None, "st": r.act, "res": "-"}) "flt": False if ok else None,
"chg": max(0, (r.chg_until - self.now + 999) // 1000) if r.chg_until else 0,
"st": r.act, "res": "-"})
return {"ok": True, "fw": self.fw, "up": self.up_ms, "in": {"mv": 12030, "ua": 1500000}, return {"ok": True, "fw": self.fw, "up": self.up_ms, "in": {"mv": 12030, "ua": 1500000},
"rails": rails, "temp": [35000, 36500], "rails": rails, "temp": [35000, 36500],
"fan": {"duty": max(300, self.fan_floor), "floor": self.fan_floor, "en": True, "fan": {"duty": max(300, self.fan_floor), "floor": self.fan_floor, "en": True,
@@ -231,7 +234,31 @@ class SimBoard:
r.en = req["on"] r.en = req["on"]
if not r.en: if not r.en:
r.vb = False r.vb = False
if r.chg_until > self.now:
r.chg_until = 0
self._ev(rail, 21, 0)
return {"ok": True} return {"ok": True}
if cmd == "flash_mode": # 固件 chgmode.h 的规则:最多 2 路、关着电不许开、关电一并退出
if "add" in req: # 0.1.6 加钟:只给开着的加,剩余截断到 120 分钟
if r.chg_until <= self.now:
return {"ok": False, "err": "state"}
r.chg_until = min(r.chg_until + req["add"] * 60000, self.now + 120 * 60000)
self._ev(rail, 21, 0x80 | req["add"])
return {"ok": True, "left": (r.chg_until - self.now + 999) // 1000}
if not req["on"]:
if r.chg_until > self.now:
self._ev(rail, 21, 0)
r.chg_until = 0
return {"ok": True, "left": 0}
if not r.en:
return {"ok": False, "err": "state"}
active = sum(1 for x in self.rails if x is not r and x.chg_until > self.now)
if active >= 2:
return {"ok": False, "err": "state"}
m = req.get("min", 60)
r.chg_until = self.now + m * 60000
self._ev(rail, 21, m)
return {"ok": True, "left": m * 60}
if cmd == "set_voltage": if cmd == "set_voltage":
r.set_mv = req["mv"] r.set_mv = req["mv"]
return {"ok": True, "set": r.set_mv} return {"ok": True, "set": r.set_mv}
+220 -187
查看文件
@@ -1,187 +1,220 @@
"""HTTP / WebSocket 接口:用 TestClient 起完整的应用,Hub 经进程内假 broker 连模拟板子。""" """HTTP / WebSocket 接口:用 TestClient 起完整的应用,Hub 经进程内假 broker 连模拟板子。"""
import time import time
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from prrack.api import create_app from prrack.api import create_app
from prrack.bus import MemBroker, MemBus from prrack.bus import MemBroker, MemBus
from prrack.db import Db, now_ms from prrack.db import Db, now_ms
from prrack.hub import Hub from prrack.hub import Hub
from prrack.recovery import RecoveryManager from prrack.recovery import RecoveryManager
from prrack.simboard import SimBoard from prrack.simboard import SimBoard
B = "b81f3fc9c8bc" B = "b81f3fc9c8bc"
@pytest.fixture @pytest.fixture
def env(tmp_path): def env(tmp_path):
broker = MemBroker() broker = MemBroker()
db = Db(str(tmp_path / "a.db")) db = Db(str(tmp_path / "a.db"))
hub = Hub(db, MemBus(broker), attempt_timeout=0.2) hub = Hub(db, MemBus(broker), attempt_timeout=0.2)
rec = RecoveryManager(hub, now_ms) rec = RecoveryManager(hub, now_ms)
sim = SimBoard(MemBus(broker), B, present=(True, True, True, False, True)) sim = SimBoard(MemBus(broker), B, present=(True, True, True, False, True))
dist = tmp_path / "dist" dist = tmp_path / "dist"
(dist / "assets").mkdir(parents=True) (dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text("<html>rack</html>", encoding="utf-8") (dist / "index.html").write_text("<html>rack</html>", encoding="utf-8")
async def up(): async def up():
await hub.start() await hub.start()
sim.start() sim.start()
sim.tick(now_ms()) sim.tick(now_ms())
app = create_app(hub, rec, str(dist), startup=[up], shutdown=[hub.stop]) app = create_app(hub, rec, str(dist), startup=[up], shutdown=[hub.stop])
with TestClient(app) as c: with TestClient(app) as c:
deadline = time.time() + 2 deadline = time.time() + 2
while B not in hub.latest and time.time() < deadline: while B not in hub.latest and time.time() < deadline:
time.sleep(0.01) time.sleep(0.01)
yield c, hub, sim, db yield c, hub, sim, db
def test_boards_and_snapshot(env): def test_boards_and_snapshot(env):
c, hub, sim, db = env c, hub, sim, db = env
boards = c.get("/api/boards").json() boards = c.get("/api/boards").json()
assert [b["id"] for b in boards] == [B] assert [b["id"] for b in boards] == [B]
assert boards[0]["online"] is True and len(boards[0]["phones"]) == 5 assert boards[0]["online"] is True and len(boards[0]["phones"]) == 5
assert c.get("/api/boards/nope").status_code == 404 assert c.get("/api/boards/nope").status_code == 404
def test_command_records_the_portal_user(env): def test_command_records_the_portal_user(env):
c, hub, sim, db = env c, hub, sim, db = env
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": False}, r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": False},
headers={"X-Auth-User": "zhouyang"}) headers={"X-Auth-User": "zhouyang"})
assert r.status_code == 200 and r.json()["ok"] is True assert r.status_code == 200 and r.json()["ok"] is True
assert sim.rails[0].en is False assert sim.rails[0].en is False
a = c.get("/api/audit").json()[0] a = c.get("/api/audit").json()[0]
assert a["user"] == "zhouyang" and a["request"]["cmd"] == "power" and a["ok"] == 1 assert a["user"] == "zhouyang" and a["request"]["cmd"] == "power" and a["ok"] == 1
# 没有门户头(服务器本机直接 curl)记作 local # 没有门户头(服务器本机直接 curl)记作 local
c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": True}) c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": True})
assert c.get("/api/audit").json()[0]["user"] == "local" assert c.get("/api/audit").json()[0]["user"] == "local"
def test_bad_commands_never_reach_the_board(env): def test_bad_commands_never_reach_the_board(env):
c, hub, sim, db = env c, hub, sim, db = env
n = len(sim.received) n = len(sim.received)
for body in ({"cmd": "wifi", "ssid": "x"}, {"cmd": "set_voltage", "rail": 1, "mv": 4300}, for body in ({"cmd": "wifi", "ssid": "x"}, {"cmd": "set_voltage", "rail": 1, "mv": 4300},
{"cmd": "key", "rail": 1, "keys": "pwr", "ms": 60000}, {"cmd": "rm -rf"}): {"cmd": "key", "rail": 1, "keys": "pwr", "ms": 60000}, {"cmd": "rm -rf"}):
r = c.post("/api/boards/%s/cmd" % B, json=body) r = c.post("/api/boards/%s/cmd" % B, json=body)
assert r.status_code == 400, body assert r.status_code == 400, body
assert len(sim.received) == n assert len(sim.received) == n
def test_board_rejection_is_passed_through(env): def test_board_rejection_is_passed_through(env):
c, hub, sim, db = env c, hub, sim, db = env
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power_cycle", "rail": 4}) r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power_cycle", "rail": 4})
assert r.status_code == 200 and r.json()["err"] == "empty" assert r.status_code == 200 and r.json()["err"] == "empty"
def test_timeout_is_504(env): def test_timeout_is_504(env):
c, hub, sim, db = env c, hub, sim, db = env
sim.drop_next_replies = 99 sim.drop_next_replies = 99
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 2, "on": True}) r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 2, "on": True})
assert r.status_code == 504 assert r.status_code == 504
def test_phone_settings_toggle_auto_recovery(env): def test_phone_settings_toggle_auto_recovery(env):
c, hub, sim, db = env c, hub, sim, db = env
r = c.patch("/api/boards/%s/rails/3" % B, json={"label": "A-03", "auto_recover": True}) r = c.patch("/api/boards/%s/rails/3" % B, json={"label": "A-03", "auto_recover": True})
assert r.status_code == 200 assert r.status_code == 200
ph = [p for p in r.json()["phones"] if p["rail"] == 3][0] ph = [p for p in r.json()["phones"] if p["rail"] == 3][0]
assert ph["label"] == "A-03" and ph["auto_recover"] == 1 assert ph["label"] == "A-03" and ph["auto_recover"] == 1
rec = c.get("/api/recovery").json() rec = c.get("/api/recovery").json()
assert rec["params"]["window_s"] == 300 assert rec["params"]["window_s"] == 300
r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1}) r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1})
assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 5000 # 负数不收 assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 5000 # 负数不收
def test_history_after_telemetry(env): def test_history_after_telemetry(env):
c, hub, sim, db = env c, hub, sim, db = env
for _ in range(3): for _ in range(3):
sim.tick(now_ms()) sim.tick(now_ms())
time.sleep(0.1) time.sleep(0.1)
hub.flush() hub.flush()
h = c.get("/api/boards/%s/rails/1/history" % B, params={"start": now_ms() - 60000}).json() h = c.get("/api/boards/%s/rails/1/history" % B, params={"start": now_ms() - 60000}).json()
assert h["resolution"] == "raw" and len(h["points"]) >= 1 assert h["resolution"] == "raw" and len(h["points"]) >= 1
assert h["points"][0][2] is not None assert h["points"][0][2] is not None
def test_websocket_hello_and_live_tele(env): def test_websocket_hello_and_live_tele(env):
c, hub, sim, db = env c, hub, sim, db = env
with c.websocket_connect("/api/ws", headers={"X-Auth-User": "zhouyang"}) as ws: with c.websocket_connect("/api/ws", headers={"X-Auth-User": "zhouyang"}) as ws:
hello = ws.receive_json() hello = ws.receive_json()
assert hello["t"] == "hello" and hello["user"] == "zhouyang" and hello["boards"][0]["id"] == B assert hello["t"] == "hello" and hello["user"] == "zhouyang" and hello["boards"][0]["id"] == B
sim.tick(now_ms()) sim.tick(now_ms())
msg = ws.receive_json() msg = ws.receive_json()
while msg["t"] != "tele": while msg["t"] != "tele":
msg = ws.receive_json() msg = ws.receive_json()
assert msg["board"] == B and len(msg["status"]["rails"]) == 5 assert msg["board"] == B and len(msg["status"]["rails"]) == 5
def test_spa_fallback_is_not_cached(env): def test_spa_fallback_is_not_cached(env):
c, hub, sim, db = env c, hub, sim, db = env
r = c.get("/some/deep/route") r = c.get("/some/deep/route")
assert r.status_code == 200 and "rack" in r.text assert r.status_code == 200 and "rack" in r.text
assert r.headers["cache-control"] == "no-cache" assert r.headers["cache-control"] == "no-cache"
def test_a_real_websocket_library_is_installed(): def test_a_real_websocket_library_is_installed():
"""TestClient 自带 WS 实现,上面那条 WS 用例在没装 websockets 的机器上也是绿的; """TestClient 自带 WS 实现,上面那条 WS 用例在没装 websockets 的机器上也是绿的;
而真 uvicorn 缺了它会把 /api/ws 当普通 GET(落到 SPA 兜底,回 200 HTML)。 而真 uvicorn 缺了它会把 /api/ws 当普通 GET(落到 SPA 兜底,回 200 HTML)。
deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。""" deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。"""
from prrack.main import ws_library_ok from prrack.main import ws_library_ok
assert ws_library_ok() assert ws_library_ok()
def test_board_history_raw_and_minute(env): def test_board_history_raw_and_minute(env):
c, hub, sim, db = env c, hub, sim, db = env
base = now_ms() // 60000 * 60000 - 3 * 60000 base = now_ms() // 60000 * 60000 - 3 * 60000
rows = [(base + i * 1000, B, 12000, 1500000, 35000 + i, 40000 - i, 300) for i in range(120)] rows = [(base + i * 1000, B, 12000, 1500000, 35000 + i, 40000 - i, 300) for i in range(120)]
db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)", rows) db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)", rows)
h = c.get("/api/boards/%s/history" % B, params={"start": base, "end": base + 120000}).json() h = c.get("/api/boards/%s/history" % B, params={"start": base, "end": base + 120000}).json()
assert h["resolution"] == "raw" and len(h["points"]) >= 100 assert h["resolution"] == "raw" and len(h["points"]) >= 100
p = h["points"][0] p = h["points"][0]
assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000 assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000
# 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾 # 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾
assert db.downsample(base + 180000) > 0 assert db.downsample(base + 180000) > 0
m = db.board_history(B, base, base + 120000 + 1) # 跨度小也能强制走聚合:t0 早于原始保留期时 m = db.board_history(B, base, base + 120000 + 1) # 跨度小也能强制走聚合:t0 早于原始保留期时
rows_min = db.q("SELECT * FROM board_tele_min WHERE board_id=? ORDER BY ts", (B,)) rows_min = db.q("SELECT * FROM board_tele_min WHERE board_id=? ORDER BY ts", (B,))
assert [r["t0_min"] for r in rows_min][:2] == [35000, 35060] assert [r["t0_min"] for r in rows_min][:2] == [35000, 35060]
assert [r["t0_max"] for r in rows_min][:2] == [35059, 35119] assert [r["t0_max"] for r in rows_min][:2] == [35059, 35119]
assert rows_min[0]["t1_min"] == 40000 - 59 and rows_min[0]["fan_avg"] == 300 assert rows_min[0]["t1_min"] == 40000 - 59 and rows_min[0]["fan_avg"] == 300
assert m["resolution"] == "raw" assert m["resolution"] == "raw"
old = db.board_history(B, base - 10 * 86400000, base + 120000) old = db.board_history(B, base - 10 * 86400000, base + 120000)
# 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大 # 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大
assert old["resolution"] == "minute" and old["points"][0][2] == 35000 assert old["resolution"] == "minute" and old["points"][0][2] == 35000
assert max(p[3] for p in old["points"]) == 35119 assert max(p[3] for p in old["points"]) == 35119
assert c.get("/api/boards/nope/history", params={"start": 0}).status_code == 404 assert c.get("/api/boards/nope/history", params={"start": 0}).status_code == 404
def test_boot_check_event_is_named_and_only_the_failure_is_severe(env): def test_boot_check_event_is_named_and_only_the_failure_is_severe(env):
# 固件 0.1.2 LOG_EV_BOOT_CHECK:arg 低 4 位 = 按了几次,bit7 = 最终没开起来 # 固件 0.1.2 LOG_EV_BOOT_CHECK:arg 低 4 位 = 按了几次,bit7 = 最终没开起来
c, hub, sim, db = env c, hub, sim, db = env
now = now_ms() now = now_ms()
db.many("INSERT INTO events (board_id, seq, t_ms, rail, type, arg, mv, ua, received) VALUES (?,?,?,?,?,?,?,?,?)", db.many("INSERT INTO events (board_id, seq, t_ms, rail, type, arg, mv, ua, received) VALUES (?,?,?,?,?,?,?,?,?)",
[(B, 9001, 1000, 1, 19, 2, 3931, 210000, now), [(B, 9001, 1000, 1, 19, 2, 3931, 210000, now),
(B, 9002, 2000, 1, 19, 0x83, 3931, 1500, now + 1)]) (B, 9002, 2000, 1, 19, 0x83, 3931, 1500, now + 1)])
ev = {e["seq"]: e for e in c.get("/api/events", params={"board": B}).json()} ev = {e["seq"]: e for e in c.get("/api/events", params={"board": B}).json()}
assert ev[9001]["code"] == "BOOT_CHECK" and ev[9001]["severe"] is False assert ev[9001]["code"] == "BOOT_CHECK" and ev[9001]["severe"] is False
assert "按了 2 次,开起来了" in ev[9001]["name"] assert "按了 2 次,开起来了" in ev[9001]["name"]
assert ev[9002]["severe"] is True and "按了 3 次,没开起来" in ev[9002]["name"] assert ev[9002]["severe"] is True and "按了 3 次,没开起来" in ev[9002]["name"]
def test_board_history_sums_rail_power(env): def test_board_history_sums_rail_power(env):
# 五路主板合计功率:同一时刻各路 mv×ua 求和再按桶平均;没有主板遥测的时刻是 None # 五路主板合计功率:同一时刻各路 mv×ua 求和再按桶平均;没有主板遥测的时刻是 None
c, hub, sim, db = env c, hub, sim, db = env
base = now_ms() // 60000 * 60000 - 3 * 60000 base = now_ms() // 60000 * 60000 - 3 * 60000
db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)", db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)",
[(base + i * 1000, B, 12000, 1000000, 35000, 35000, 0) for i in range(120)]) [(base + i * 1000, B, 12000, 1000000, 35000, 35000, 0) for i in range(120)])
# 前 60 s 有两路:4.0 V × 500 mA + 3.9 V × 1 A = 5.9 W;后 60 s 没有主板数据 # 前 60 s 有两路:4.0 V × 500 mA + 3.9 V × 1 A = 5.9 W;后 60 s 没有主板数据
db.many("INSERT INTO tele VALUES (?,?,?,?,?,?,?,?,?,?)", db.many("INSERT INTO tele VALUES (?,?,?,?,?,?,?,?,?,?)",
[(base + i * 1000, B, r, mv, ua, 1, 1, 1, 1, 0) [(base + i * 1000, B, r, mv, ua, 1, 1, 1, 1, 0)
for i in range(60) for r, mv, ua in ((1, 4000, 500000), (2, 3900, 1000000))]) for i in range(60) for r, mv, ua in ((1, 4000, 500000), (2, 3900, 1000000))])
h = db.board_history(B, base, base + 120000) h = db.board_history(B, base, base + 120000)
assert h["resolution"] == "raw" assert h["resolution"] == "raw"
assert {p[10] for p in h["points"] if p[0] < base + 60000} == {5900} assert {p[10] for p in h["points"] if p[0] < base + 60000} == {5900}
assert {p[10] for p in h["points"] if p[0] >= base + 60000} == {None} assert {p[10] for p in h["points"] if p[0] >= base + 60000} == {None}
assert db.downsample(base + 180000) > 0 assert db.downsample(base + 180000) > 0
old = db.board_history(B, base - 10 * 86400000, base + 120000) old = db.board_history(B, base - 10 * 86400000, base + 120000)
assert old["resolution"] == "minute" and old["points"][0][10] == 5900 assert old["resolution"] == "minute" and old["points"][0][10] == 5900
def test_flash_mode_round_trip_and_two_rail_limit(env):
c, hub, sim, db = env
post = lambda body: c.post("/api/boards/%s/cmd" % B, json=body).json()
for rail in (1, 2, 3):
assert post({"cmd": "power", "rail": rail, "on": True})["ok"] is True
r = post({"cmd": "flash_mode", "rail": 1, "on": True, "min": 10})
assert r["ok"] is True and r["left"] == 600
assert post({"cmd": "flash_mode", "rail": 2, "on": True})["ok"] is True
r = post({"cmd": "flash_mode", "rail": 3, "on": True})
assert r["ok"] is False and r["err"] == "state" # 第 3 路被板子拒
assert post({"cmd": "flash_mode", "rail": 1, "on": False})["left"] == 0
assert post({"cmd": "flash_mode", "rail": 3, "on": True})["ok"] is True # 腾出名额
st = post({"cmd": "status"})
assert [x["chg"] > 0 for x in st["rails"]][:3] == [False, True, True]
def test_flash_mode_extend_round_trip(env):
c, hub, sim, db = env
post = lambda body: c.post("/api/boards/%s/cmd" % B, json=body).json()
assert post({"cmd": "power", "rail": 1, "on": True})["ok"] is True
r = post({"cmd": "flash_mode", "rail": 1, "add": 30})
assert r["ok"] is False and r["err"] == "state" # 没开着不能加钟
assert post({"cmd": "flash_mode", "rail": 1, "on": True})["left"] == 3600 # 默认 1 小时
assert post({"cmd": "flash_mode", "rail": 1, "add": 30})["left"] == 5400
assert post({"cmd": "flash_mode", "rail": 1, "add": 120})["left"] == 7200 # 截断
now = 10 ** 13
db.many("INSERT INTO events (board_id, seq, t_ms, rail, type, arg, mv, ua, received) VALUES (?,?,?,?,?,?,?,?,?)",
[(B, 9101, 1, 1, 21, 0x80 | 30, 0, 0, now), (B, 9102, 2, 2, 21, 254, 0, 0, now)])
ev = {e["seq"]: e for e in c.get("/api/events", params={"board": B}).json()}
assert "加钟 30 分钟" in ev[9101]["name"]
assert "断电自动退出" in ev[9102]["name"] # 0.1.13:254 带 0x80 位,不能被当成「加钟 126 分钟」
+330 -304
查看文件
@@ -1,304 +1,330 @@
"""协议镜像 + Hub(经进程内假 broker 对接模拟板子)+ 自动恢复状态机。 """协议镜像 + Hub(经进程内假 broker 对接模拟板子)+ 自动恢复状态机。
和固件测试同一个口径:断言打在「板子那头真的收到 / 执行了什么」上(SimBoard.received、 和固件测试同一个口径:断言打在「板子那头真的收到 / 执行了什么」上(SimBoard.received、
它的 rails 状态),不看 Hub 自己回了什么 —— 重传换了 seq 的现象恰恰是 Hub 这边一切正常、 它的 rails 状态),不看 Hub 自己回了什么 —— 重传换了 seq 的现象恰恰是 Hub 这边一切正常、
手机被按了两遍。""" 手机被按了两遍。"""
import asyncio import asyncio
import json import json
import pytest import pytest
from prrack import protocol from prrack import protocol
from prrack.bus import MemBroker, MemBus, topic_matches from prrack.bus import MemBroker, MemBus, topic_matches
from prrack.db import Db from prrack.db import Db
from prrack.hub import CmdTimeout, Hub from prrack.hub import CmdTimeout, Hub
from prrack.recovery import GAVE_UP, HARD, IDLE, SOFT, Params, PhoneRecovery, RecoveryManager from prrack.recovery import GAVE_UP, HARD, IDLE, SOFT, Params, PhoneRecovery, RecoveryManager
from prrack.simboard import HUNG, NORMAL, OFF, SimBoard from prrack.simboard import HUNG, NORMAL, OFF, SimBoard
B = "b81f3fc9c8bc" B = "b81f3fc9c8bc"
def run(coro): def run(coro):
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
try: try:
return loop.run_until_complete(coro) return loop.run_until_complete(coro)
finally: # 收掉 Hub 的后台任务(flusher / maint),免得退出时刷一屏 pending 警告 finally: # 收掉 Hub 的后台任务(flusher / maint),免得退出时刷一屏 pending 警告
tasks = asyncio.all_tasks(loop) tasks = asyncio.all_tasks(loop)
for t in tasks: for t in tasks:
t.cancel() t.cancel()
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
loop.close() loop.close()
async def settle(): async def settle():
for _ in range(5): for _ in range(5):
await asyncio.sleep(0) await asyncio.sleep(0)
def rig(tmp_path, present=(True,) * 5, timeout=0.05): def rig(tmp_path, present=(True,) * 5, timeout=0.05):
broker = MemBroker() broker = MemBroker()
db = Db(str(tmp_path / "t.db")) db = Db(str(tmp_path / "t.db"))
hub = Hub(db, MemBus(broker), attempt_timeout=timeout) hub = Hub(db, MemBus(broker), attempt_timeout=timeout)
sim = SimBoard(MemBus(broker), B, present=present) sim = SimBoard(MemBus(broker), B, present=present)
return hub, sim, db return hub, sim, db
# ---- 协议镜像 ----------------------------------------------------------------- # ---- 协议镜像 -----------------------------------------------------------------
def test_build_command_whitelist_and_ranges(): def test_build_command_whitelist_and_ranges():
assert protocol.build_command({"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}) == \ assert protocol.build_command({"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}) == \
{"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000} {"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}
bad = [ bad = [
{"cmd": "wifi", "ssid": "x"}, # 只收 USB 的命令不给界面 {"cmd": "wifi", "ssid": "x"}, # 只收 USB 的命令不给界面
{"cmd": "events"}, # 服务器自己同步 {"cmd": "events"}, # 服务器自己同步
{"cmd": "set_voltage", "rail": 1, "mv": 4011}, # SR-13 之上 {"cmd": "set_voltage", "rail": 1, "mv": 4011}, # SR-13 之上
{"cmd": "set_voltage", "rail": 1, "mv": "3900"}, {"cmd": "set_voltage", "rail": 1, "mv": "3900"},
{"cmd": "key", "rail": 1, "keys": "pwr+home", "ms": 100}, {"cmd": "key", "rail": 1, "keys": "pwr+home", "ms": 100},
{"cmd": "key", "rail": 1, "keys": "pwr", "ms": 15001}, {"cmd": "key", "rail": 1, "keys": "pwr", "ms": 15001},
{"cmd": "power", "rail": 6, "on": True}, {"cmd": "power", "rail": 6, "on": True},
{"cmd": "power", "rail": True, "on": True}, # bool 不是 int {"cmd": "power", "rail": True, "on": True}, # bool 不是 int
{"cmd": "vbus", "rail": 1, "on": 1}, {"cmd": "vbus", "rail": 1, "on": 1},
{"cmd": "fan", "duty": 1001}, {"cmd": "fan", "duty": 1001},
] ]
for b in bad: for b in bad:
with pytest.raises(protocol.CmdError): with pytest.raises(protocol.CmdError):
protocol.build_command(b) protocol.build_command(b)
def test_seq_is_strictly_increasing_and_in_range(): def test_seq_is_strictly_increasing_and_in_range():
t = [1700000000.0] t = [1700000000.0]
g = protocol.SeqGen(clock=lambda: t[0]) g = protocol.SeqGen(clock=lambda: t[0])
a = [g.next() for _ in range(5)] a = [g.next() for _ in range(5)]
assert a == sorted(set(a)) and all(1 <= x <= 0xFFFFFFFF for x in a) assert a == sorted(set(a)) and all(1 <= x <= 0xFFFFFFFF for x in a)
t[0] -= 100 # 时钟往回跳也不许重复 t[0] -= 100 # 时钟往回跳也不许重复
assert g.next() > a[-1] or g.next() == 1 assert g.next() > a[-1] or g.next() == 1
def test_event_table_matches_firmware_numbers(): def test_event_table_matches_firmware_numbers():
# firmware/main/evlog.h 冻结的数字(抽几个钉住) # firmware/main/evlog.h 冻结的数字(抽几个钉住)
assert protocol.EVENT_TYPES[3][0] == "OVER_VOLT" assert protocol.EVENT_TYPES[3][0] == "OVER_VOLT"
assert protocol.EVENT_TYPES[12][0] == "REBOOT" assert protocol.EVENT_TYPES[12][0] == "REBOOT"
assert protocol.EVENT_TYPES[13][0] == "CMD_REJECT" assert protocol.EVENT_TYPES[13][0] == "CMD_REJECT"
assert protocol.EVENT_TYPES[16][0] == "PD_LIMIT" assert protocol.EVENT_TYPES[16][0] == "PD_LIMIT"
assert protocol.CMD_NAMES[9] == "power" and protocol.CMD_NAMES[12] == "net" assert protocol.CMD_NAMES[9] == "power" and protocol.CMD_NAMES[12] == "net"
assert protocol.EVENT_TYPES[18][0] == "OTA" and protocol.CMD_NAMES[13] == "ota" # C2 assert protocol.EVENT_TYPES[18][0] == "OTA" and protocol.CMD_NAMES[13] == "ota" # C2
assert protocol.EVENT_TYPES[19][0] == "BOOT_CHECK" and protocol.BOOT_CHECK_FAILED == 0x80 # 0.1.2 assert protocol.EVENT_TYPES[19][0] == "BOOT_CHECK" and protocol.BOOT_CHECK_FAILED == 0x80 # 0.1.2
def test_topic_matching(): def test_topic_matching():
assert topic_matches("phonerail/+/rsp", "phonerail/%s/rsp" % B) assert topic_matches("phonerail/+/rsp", "phonerail/%s/rsp" % B)
assert not topic_matches("phonerail/+/rsp", "phonerail/%s/tele" % B) assert not topic_matches("phonerail/+/rsp", "phonerail/%s/tele" % B)
assert topic_matches("phonerail/#", "phonerail/x/y") assert topic_matches("phonerail/#", "phonerail/x/y")
assert protocol.parse_topic("phonerail/%s/tele" % B) == (B, "tele") assert protocol.parse_topic("phonerail/%s/tele" % B) == (B, "tele")
assert protocol.parse_topic("evse/123/state") is None assert protocol.parse_topic("evse/123/state") is None
# ---- Hub --------------------------------------------------------------------- # ---- Hub ---------------------------------------------------------------------
def test_tele_is_ingested_and_board_discovered(tmp_path): def test_tele_is_ingested_and_board_discovered(tmp_path):
hub, sim, db = rig(tmp_path) hub, sim, db = rig(tmp_path)
async def go(): async def go():
await hub.start() await hub.start()
sim.start() sim.start()
sim.tick(1000) sim.tick(1000)
await settle() await settle()
hub.flush() hub.flush()
run(go()) run(go())
snap = hub.snapshot(B) snap = hub.snapshot(B)
assert snap["online"] and snap["fw"] == "0.1.0-sim" assert snap["online"] and snap["fw"] == "0.1.0-sim"
assert snap["status"]["rails"][0]["en"] is True assert snap["status"]["rails"][0]["en"] is True
assert db.q("SELECT COUNT(*) c FROM tele")[0]["c"] == 5 assert db.q("SELECT COUNT(*) c FROM tele")[0]["c"] == 5
assert len(snap["phones"]) == 5 and snap["phones"][0]["auto_recover"] == 0 assert len(snap["phones"]) == 5 and snap["phones"][0]["auto_recover"] == 0
def test_command_round_trip_and_audit(tmp_path): def test_command_round_trip_and_audit(tmp_path):
hub, sim, db = rig(tmp_path) hub, sim, db = rig(tmp_path)
async def go(): async def go():
await hub.start() await hub.start()
sim.start() sim.start()
return await hub.call(B, {"cmd": "power", "rail": 2, "on": False}, "alice") return await hub.call(B, {"cmd": "power", "rail": 2, "on": False}, "alice")
rsp = run(go()) rsp = run(go())
assert rsp["ok"] is True assert rsp["ok"] is True
assert sim.rails[1].en is False assert sim.rails[1].en is False
row = db.q("SELECT * FROM audit")[0] row = db.q("SELECT * FROM audit")[0]
assert row["user"] == "alice" and row["rail"] == 2 and row["ok"] == 1 assert row["user"] == "alice" and row["rail"] == 2 and row["ok"] == 1
def test_retransmit_reuses_the_same_seq_and_executes_once(tmp_path): def test_retransmit_reuses_the_same_seq_and_executes_once(tmp_path):
"""⭐ 回应丢了一次:Hub 用**同一个 seq** 重发,板子判重,只执行一次。""" """⭐ 回应丢了一次:Hub 用**同一个 seq** 重发,板子判重,只执行一次。"""
hub, sim, db = rig(tmp_path) hub, sim, db = rig(tmp_path)
sim.drop_next_replies = 1 sim.drop_next_replies = 1
async def go(): async def go():
await hub.start() await hub.start()
sim.start() sim.start()
return await hub.call(B, {"cmd": "power_cycle", "rail": 1}, "bob") return await hub.call(B, {"cmd": "power_cycle", "rail": 1}, "bob")
rsp = run(go()) rsp = run(go())
assert rsp["ok"] is True and rsp.get("dup") is True assert rsp["ok"] is True and rsp.get("dup") is True
sent = [r for r in sim.received if r.get("cmd") == "power_cycle"] sent = [r for r in sim.received if r.get("cmd") == "power_cycle"]
assert len(sent) == 2 and sent[0]["seq"] == sent[1]["seq"] assert len(sent) == 2 and sent[0]["seq"] == sent[1]["seq"]
assert [e[3] for e in sim.events].count(12) == 1 # 重启事件只记了一次 assert [e[3] for e in sim.events].count(12) == 1 # 重启事件只记了一次
def test_timeout_is_audited_and_raised(tmp_path): def test_timeout_is_audited_and_raised(tmp_path):
hub, sim, db = rig(tmp_path) hub, sim, db = rig(tmp_path)
sim.drop_next_replies = 99 sim.drop_next_replies = 99
async def go(): async def go():
await hub.start() await hub.start()
sim.start() sim.start()
with pytest.raises(CmdTimeout): with pytest.raises(CmdTimeout):
await hub.call(B, {"cmd": "power", "rail": 1, "on": True}, "carol") await hub.call(B, {"cmd": "power", "rail": 1, "on": True}, "carol")
run(go()) run(go())
row = db.q("SELECT * FROM audit")[0] row = db.q("SELECT * FROM audit")[0]
assert row["ok"] == 0 and json.loads(row["response"])["err"] == "timeout" assert row["ok"] == 0 and json.loads(row["response"])["err"] == "timeout"
assert len({r["seq"] for r in sim.received}) == 1 # 三次都是同一个 seq assert len({r["seq"] for r in sim.received}) == 1 # 三次都是同一个 seq
def test_events_are_synced_incrementally(tmp_path): def test_events_are_synced_incrementally(tmp_path):
hub, sim, db = rig(tmp_path) hub, sim, db = rig(tmp_path)
for i in range(19): for i in range(19):
sim._ev(1, 8, i) # 加上 BOOT 共 20 条:要分 3 页 sim._ev(1, 8, i) # 加上 BOOT 共 20 条:要分 3 页
async def go(): async def go():
await hub.start() await hub.start()
sim.start() sim.start()
sim.tick(1000) sim.tick(1000)
for _ in range(20): for _ in range(20):
await settle() await settle()
sim._ev(2, 3, 0, 4200, 1) # 又来一条过压 sim._ev(2, 3, 0, 4200, 1) # 又来一条过压
sim.tick(2000) sim.tick(2000)
for _ in range(20): for _ in range(20):
await settle() await settle()
run(go()) run(go())
seqs = [r["seq"] for r in db.q("SELECT seq FROM events ORDER BY seq")] seqs = [r["seq"] for r in db.q("SELECT seq FROM events ORDER BY seq")]
assert seqs == list(range(21)) assert seqs == list(range(21))
assert db.q("SELECT ev_cursor FROM boards")[0]["ev_cursor"] == 21 assert db.q("SELECT ev_cursor FROM boards")[0]["ev_cursor"] == 21
def test_empty_slot_reply_passes_through(tmp_path): def test_empty_slot_reply_passes_through(tmp_path):
hub, sim, db = rig(tmp_path, present=(True, True, True, False, True)) hub, sim, db = rig(tmp_path, present=(True, True, True, False, True))
async def go(): async def go():
await hub.start() await hub.start()
sim.start() sim.start()
return await hub.call(B, {"cmd": "power", "rail": 4, "on": True}, "dave") return await hub.call(B, {"cmd": "power", "rail": 4, "on": True}, "dave")
assert run(go()) == {"seq": sim.received[-1]["seq"], "ok": False, "err": "empty"} assert run(go()) == {"seq": sim.received[-1]["seq"], "ok": False, "err": "empty"}
# ---- 自动恢复:状态机 ------------------------------------------------------------ # ---- 自动恢复:状态机 ------------------------------------------------------------
P = Params(window_s=60, flat_ua=5000, low_ua=30000, settle_s=30, max_per_hour=3) P = Params(window_s=60, flat_ua=5000, low_ua=30000, settle_s=30, max_per_hour=3)
def feed(ph, t0, secs, ua_fn): def feed(ph, t0, secs, ua_fn):
out = [] out = []
for s in range(secs): for s in range(secs):
st, act = ph.feed(t0 + s * 1000, ua_fn(s), 3) st, act = ph.feed(t0 + s * 1000, ua_fn(s), 3)
if st or act: if st or act:
out.append((t0 + s * 1000, st, act)) out.append((t0 + s * 1000, st, act))
return out return out
def test_flat_current_escalates_soft_then_hard_then_gives_up(): def test_flat_current_escalates_soft_then_hard_then_gives_up():
ph = PhoneRecovery(P) ph = PhoneRecovery(P)
r = feed(ph, 0, 61, lambda s: 412000) r = feed(ph, 0, 61, lambda s: 412000)
assert r[-1][1] == SOFT and r[-1][2][1] == {"cmd": "key", "rail": 3, "keys": "pwr", "ms": 10000} assert r[-1][1] == SOFT and r[-1][2][1] == {"cmd": "key", "rail": 3, "keys": "pwr", "ms": 10000}
t = r[-1][0] t = r[-1][0]
# settle 期间的样本一概不看(哪怕是平线) # settle 期间的样本一概不看(哪怕是平线)
assert feed(ph, t + 1000, 29, lambda s: 412000) == [] assert feed(ph, t + 1000, 29, lambda s: 412000) == []
r = feed(ph, t + 30000, 62, lambda s: 412000) r = feed(ph, t + 30000, 62, lambda s: 412000)
assert r[-1][1] == HARD and r[-1][2][1] == {"cmd": "power_cycle", "rail": 3} assert r[-1][1] == HARD and r[-1][2][1] == {"cmd": "power_cycle", "rail": 3}
t = r[-1][0] t = r[-1][0]
r = feed(ph, t + 30000, 62, lambda s: 412000) r = feed(ph, t + 30000, 62, lambda s: 412000)
assert r[-1][1] == GAVE_UP and r[-1][2] is None assert r[-1][1] == GAVE_UP and r[-1][2] is None
assert feed(ph, t + 200000, 100, lambda s: 412000) == [] # 放弃之后再也不动 assert feed(ph, t + 200000, 100, lambda s: 412000) == [] # 放弃之后再也不动
def test_recovers_after_soft_when_current_moves_again(): def test_recovers_after_soft_when_current_moves_again():
ph = PhoneRecovery(P) ph = PhoneRecovery(P)
t = feed(ph, 0, 61, lambda s: 412000)[-1][0] t = feed(ph, 0, 61, lambda s: 412000)[-1][0]
r = feed(ph, t + 30000, 70, lambda s: 300000 + (s % 7) * 40000) r = feed(ph, t + 30000, 70, lambda s: 300000 + (s % 7) * 40000)
assert r[-1][1] == IDLE and ph.reason == "recovered" assert r[-1][1] == IDLE and ph.reason == "recovered"
def test_low_current_is_detected_and_normal_is_not(): def test_low_current_is_detected_and_normal_is_not():
ph = PhoneRecovery(P) ph = PhoneRecovery(P)
assert feed(ph, 0, 200, lambda s: 300000 + (s % 5) * 50000) == [] assert feed(ph, 0, 200, lambda s: 300000 + (s % 5) * 50000) == []
ph2 = PhoneRecovery(P) ph2 = PhoneRecovery(P)
assert feed(ph2, 0, 61, lambda s: 800 + s % 3)[-1][2][0] == "low" assert feed(ph2, 0, 61, lambda s: 800 + s % 3)[-1][2][0] == "low"
def test_rate_limit_gives_up(): def test_rate_limit_gives_up():
ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2)) ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2))
t = 0 t = 0
for _ in range(2): # 两次软重启都「恢复」了,但每次很快又卡 for _ in range(2): # 两次软重启都「恢复」了,但每次很快又卡
assert feed(ph, t, 65, lambda s: 412000)[-1][1] == SOFT assert feed(ph, t, 65, lambda s: 412000)[-1][1] == SOFT
t += 65000 # 下一段接着上一段喂完的时刻往后走(时间不倒退) t += 65000 # 下一段接着上一段喂完的时刻往后走(时间不倒退)
# 前 30 s 还在 settle 里不算数,之后要攒满一个 60 s 窗口才判「恢复」 # 前 30 s 还在 settle 里不算数,之后要攒满一个 60 s 窗口才判「恢复」
assert feed(ph, t, 100, lambda s: 300000 + (s % 7) * 40000)[-1][1] == IDLE assert feed(ph, t, 100, lambda s: 300000 + (s % 7) * 40000)[-1][1] == IDLE
t += 100000 t += 100000
r = feed(ph, t, 65, lambda s: 412000) r = feed(ph, t, 65, lambda s: 412000)
assert r[-1][1] == GAVE_UP and ph.reason == "rate_limit" assert r[-1][1] == GAVE_UP and ph.reason == "rate_limit"
# ---- 自动恢复:接到 Hub + 模拟板上 ------------------------------------------------- # ---- 自动恢复:接到 Hub + 模拟板上 -------------------------------------------------
def test_auto_recovery_end_to_end_with_sim(tmp_path): def test_auto_recovery_end_to_end_with_sim(tmp_path):
"""⭐ 模拟板上第 3 台卡死:开了自动恢复 → 5 分钟后下长按电源 → 板子执行 → 恢复。 """⭐ 模拟板上第 3 台卡死:开了自动恢复 → 5 分钟后下长按电源 → 板子执行 → 恢复。
没开自动恢复的第 2 台(也卡死)一个命令都不收到。""" 没开自动恢复的第 2 台(也卡死)一个命令都不收到。"""
hub, sim, db = rig(tmp_path) hub, sim, db = rig(tmp_path)
clock = [1_000_000] clock = [1_000_000]
rec = RecoveryManager(hub, lambda: clock[0]) rec = RecoveryManager(hub, lambda: clock[0])
sim.set_phone(3, HUNG) sim.set_phone(3, HUNG)
sim.set_phone(2, HUNG) sim.set_phone(2, HUNG)
async def go(): async def go():
await hub.start() await hub.start()
sim.start() sim.start()
await settle() # 等 Hub 处理完上线消息、建好这块板的 phones 行 await settle() # 等 Hub 处理完上线消息、建好这块板的 phones 行
db.x("UPDATE phones SET auto_recover=1 WHERE rail=3") db.x("UPDATE phones SET auto_recover=1 WHERE rail=3")
rec.reload() rec.reload()
for s in range(300 + 130 + 320): for s in range(300 + 130 + 320):
clock[0] += 1000 clock[0] += 1000
sim.tick(clock[0]) sim.tick(clock[0])
await settle() await settle()
run(go()) run(go())
keys = [r for r in sim.received if r.get("cmd") == "key"] keys = [r for r in sim.received if r.get("cmd") == "key"]
assert len(keys) == 1 and keys[0]["rail"] == 3 and keys[0]["ms"] == 10000 assert len(keys) == 1 and keys[0]["rail"] == 3 and keys[0]["ms"] == 10000
assert not [r for r in sim.received if r.get("rail") == 2] assert not [r for r in sim.received if r.get("rail") == 2]
assert sim.rails[2].phone == NORMAL assert sim.rails[2].phone == NORMAL
states = [r["state"] for r in db.q("SELECT state FROM recovery_log ORDER BY id")] states = [r["state"] for r in db.q("SELECT state FROM recovery_log ORDER BY id")]
assert states == [SOFT, IDLE] assert states == [SOFT, IDLE]
assert db.q("SELECT user FROM audit")[0]["user"] == "auto-recovery" assert db.q("SELECT user FROM audit")[0]["user"] == "auto-recovery"
def test_auto_recovery_ignores_rails_that_are_off(tmp_path): def test_auto_recovery_ignores_rails_that_are_off(tmp_path):
hub, sim, db = rig(tmp_path) hub, sim, db = rig(tmp_path)
clock = [0] clock = [0]
rec = RecoveryManager(hub, lambda: clock[0]) rec = RecoveryManager(hub, lambda: clock[0])
sim.set_phone(1, OFF) sim.set_phone(1, OFF)
sim.rails[0].en = False # 这一路本来就关着:电流 0 不是故障 sim.rails[0].en = False # 这一路本来就关着:电流 0 不是故障
async def go(): async def go():
await hub.start() await hub.start()
sim.start() sim.start()
await settle() await settle()
db.x("UPDATE phones SET auto_recover=1") db.x("UPDATE phones SET auto_recover=1")
rec.reload() rec.reload()
for s in range(400): for s in range(400):
clock[0] += 1000 clock[0] += 1000
sim.tick(clock[0]) sim.tick(clock[0])
await settle() await settle()
run(go()) run(go())
assert not [r for r in sim.received if r.get("cmd") in ("key", "power_cycle")] assert not [r for r in sim.received if r.get("cmd") in ("key", "power_cycle")]
def test_flash_mode_command_and_event_numbers():
# 固件 0.1.5:命令 14 / 事件 21(cmd.h / evlog.h 冻结编号)
assert protocol.CMD_NAMES[14] == "flash_mode" and protocol.EVENT_TYPES[21][0] == "CHG_MODE"
assert protocol.EVENT_TYPES[19][0] == "BOOT_CHECK" and protocol.EVENT_TYPES[20][0] == "BLADE_MUTE"
assert protocol.build_command({"cmd": "flash_mode", "rail": 2, "on": True}) == {"cmd": "flash_mode", "rail": 2, "on": True}
assert protocol.build_command({"cmd": "flash_mode", "rail": 2, "on": True, "min": 45})["min"] == 45
assert "min" not in protocol.build_command({"cmd": "flash_mode", "rail": 2, "on": False, "min": 5})
for bad in ({"cmd": "flash_mode", "rail": 2, "on": True, "min": 0},
{"cmd": "flash_mode", "rail": 2, "on": True, "min": 121},
{"cmd": "flash_mode", "rail": 2, "on": True, "min": True},
{"cmd": "flash_mode", "rail": 6, "on": True},
{"cmd": "flash_mode", "rail": 2}):
with pytest.raises(protocol.CmdError):
protocol.build_command(bad)
def test_flash_mode_extend_command():
assert protocol.build_command({"cmd": "flash_mode", "rail": 1, "add": 30}) == {"cmd": "flash_mode", "rail": 1, "add": 30}
for bad in ({"cmd": "flash_mode", "rail": 1, "add": 0},
{"cmd": "flash_mode", "rail": 1, "add": 121},
{"cmd": "flash_mode", "rail": 1, "add": 30, "on": True},
{"cmd": "flash_mode", "rail": 1, "add": 30, "min": 10}):
with pytest.raises(protocol.CmdError):
protocol.build_command(bad)
+21 -1
查看文件
@@ -17,7 +17,9 @@
<span :class="{ on: r.en }">直供{{ r.en ? '开' : '关' }}</span> <span :class="{ on: r.en }">直供{{ r.en ? '开' : '关' }}</span>
<span :class="{ on: r.vb }">USB{{ r.vb ? '通' : '断' }}</span> <span :class="{ on: r.vb }">USB{{ r.vb ? '通' : '断' }}</span>
<span :class="{ on: r.pg, bad: r.en && r.pg === false }">PG</span> <span :class="{ on: r.pg, bad: r.en && r.pg === false }">PG</span>
<span :class="{ bad: r.flt }" v-if="r.flt">FLT</span> <span v-if="r.flt && r.chg > 0" title="刷机模式下手机充电器把电池端顶高,eFuse 进入反向阻断,FLT 随之拉低:正常指示,不是故障">反向阻断</span>
<span :class="{ bad: r.flt }" v-else-if="r.flt">FLT</span>
<span class="chg" v-if="r.chg > 0" :title="'刷机模式:USB 通着时允许手机充电、不断 USB(固件 0.1.5)'">刷机 {{ Math.ceil(r.chg / 60) }} 分</span>
</div> </div>
<div class="ar" @click.stop> <div class="ar" @click.stop>
@@ -39,6 +41,8 @@
<el-dropdown-item command="recovery">进 Recovery(音量上 + 电源)</el-dropdown-item> <el-dropdown-item command="recovery">进 Recovery(音量上 + 电源)</el-dropdown-item>
<el-dropdown-item command="edl">进 EDL / 9008</el-dropdown-item> <el-dropdown-item command="edl">进 EDL / 9008</el-dropdown-item>
<el-dropdown-item command="vbus" divided>{{ r?.vb ? '断开 USB' : '接通 USB' }}</el-dropdown-item> <el-dropdown-item command="vbus" divided>{{ r?.vb ? '断开 USB' : '接通 USB' }}</el-dropdown-item>
<el-dropdown-item command="flash">{{ r?.chg > 0 ? '退出刷机模式' : '刷机模式(1 小时)…' }}</el-dropdown-item>
<el-dropdown-item v-if="r?.chg > 0" command="flash_add">刷机模式加钟 30 分钟</el-dropdown-item>
<el-dropdown-item command="power">{{ r?.en ? '断电(保持关闭)' : '上电' }}</el-dropdown-item> <el-dropdown-item command="power">{{ r?.en ? '断电(保持关闭)' : '上电' }}</el-dropdown-item>
<el-dropdown-item command="volt">调电压…</el-dropdown-item> <el-dropdown-item command="volt">调电压…</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
@@ -104,10 +108,25 @@ function more(c) {
case 'edl': return run('进 EDL', { cmd: 'edl' }, true) case 'edl': return run('进 EDL', { cmd: 'edl' }, true)
case 'vbus': return run(on.vb ? '断开 USB' : '接通 USB', { cmd: 'vbus', on: !on.vb }, on.vb) case 'vbus': return run(on.vb ? '断开 USB' : '接通 USB', { cmd: 'vbus', on: !on.vb }, on.vb)
case 'power': return run(on.en ? '断电' : '上电', { cmd: 'power', on: !on.en }, on.en) case 'power': return run(on.en ? '断电' : '上电', { cmd: 'power', on: !on.en }, on.en)
case 'flash': return flashMode(!(on.chg > 0))
case 'flash_add': return run('刷机模式加钟 30 分钟', { cmd: 'flash_mode', add: 30 }) // 板子截断到剩余 120 分钟
case 'volt': mv.value = on.set || 3900; voltOpen.value = true case 'volt': mv.value = on.set || 3900; voltOpen.value = true
} }
} }
// 固件 0.1.5 刷机模式:窗口内 USB 通着也允许手机充电(节点被充电器顶到 ~4.4 V 不断 USB),
// 超过 4.45 V / 反灌 / 欠压照样保护;最多同时 2 路(板子回 state);1 小时到期自动恢复。
async function flashMode(on) {
if (!on) return run('退出刷机模式', { cmd: 'flash_mode', on: false })
try {
await ElMessageBox.confirm(
`${who()} 进入刷机模式 1 小时?期间 USB 接通时允许手机充电、不会因为电压被顶高而断 USB,` +
'电池端会停在约 4.4 V。最多同时 2 路;到期自动恢复。开完再「接通 USB」。',
'刷机模式', { type: 'warning', confirmButtonText: '进入' })
} catch { return }
return run('刷机模式', { cmd: 'flash_mode', on: true, min: 60 })
}
const voltOpen = ref(false) const voltOpen = ref(false)
const mv = ref(3900) const mv = ref(3900)
async function setVolt() { async function setVolt() {
@@ -147,6 +166,7 @@ async function saveAuto(v) {
.flags span { font-size: 12px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--line); color: var(--muted); } .flags span { font-size: 12px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--line); color: var(--muted); }
.flags span.on { color: var(--ink); border-color: var(--ink-2); } .flags span.on { color: var(--ink); border-color: var(--ink-2); }
.flags span.bad { color: var(--st-bad); border-color: var(--st-bad); } .flags span.bad { color: var(--st-bad); border-color: var(--st-bad); }
.flags span.chg { color: var(--st-warn); border-color: currentColor; }
.ar { display: flex; align-items: center; gap: 6px; } .ar { display: flex; align-items: center; gap: 6px; }
.acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; } .acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
.acts .el-button + .el-button { margin-left: 0; } .acts .el-button + .el-button { margin-left: 0; }
+1 -1
查看文件
@@ -90,7 +90,7 @@ import { mA, V, W, C, ago, fmtTime, railsMw, railHealth, LEVEL_COLOR, RECOVERY_Z
// 与 backend/prrack/protocol.py 的 EVENT_TYPES / EVENT_SEVERE 同一张表(数字来自固件 evlog.h) // 与 backend/prrack/protocol.py 的 EVENT_TYPES / EVENT_SEVERE 同一张表(数字来自固件 evlog.h)
const EV = ['', '板子上电', '事件丢失', '过压关路', '欠压关路', '反灌断 VBUS', '顶高断 VBUS', 'eFuse 故障', const EV = ['', '板子上电', '事件丢失', '过压关路', '欠压关路', '反灌断 VBUS', '顶高断 VBUS', 'eFuse 故障',
'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率', 'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率',
'上电等不到 PG', '固件升级', '开机重按', '刀片不应答关路'] '上电等不到 PG', '固件升级', '开机重按', '刀片不应答关路', '刷机模式']
const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14, 17, 20]) const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14, 17, 20])
// 开机重按(19)只有 arg bit7「最终没开起来」才算严重 // 开机重按(19)只有 arg bit7「最终没开起来」才算严重
const isSevere = ev => SEVERE.has(ev[3]) || (ev[3] === 19 && (ev[4] & 0x80) !== 0) const isSevere = ev => SEVERE.has(ev[3]) || (ev[3] === 19 && (ev[4] & 0x80) !== 0)
+3
查看文件
@@ -43,6 +43,9 @@ export function railHealth(board, r) {
if (!r.pr) return { level: 'empty', text: '空槽' } if (!r.pr) return { level: 'empty', text: '空槽' }
if (r.st && r.st !== 'idle') return { level: 'busy', text: ACT_ZH[r.st] || r.st } if (r.st && r.st !== 'idle') return { level: 'busy', text: ACT_ZH[r.st] || r.st }
if (!r.en) return { level: 'off', text: '已断电' } if (!r.en) return { level: 'off', text: '已断电' }
// 固件 ≥0.1.5 刷机模式:手机充电器把节点顶高时 eFuse(TPS25940A)进入反向阻断,FLT 随之拉低 ——
// 2026-09-28 台架实测与 RCB 同进同退,是正常指示,不是故障。模式外的 FLT 仍按故障标红。
if (r.chg > 0) return { level: 'busy', text: r.flt ? '刷机模式 · 充电中' : '刷机模式' }
if (r.flt) return { level: 'bad', text: 'eFuse 故障' } if (r.flt) return { level: 'bad', text: 'eFuse 故障' }
if (r.pg === false) return { level: 'bad', text: 'PG 丢失' } if (r.pg === false) return { level: 'bad', text: 'PG 丢失' }
if (r.mv == null || r.ua == null) return { level: 'warn', text: '读不到' } if (r.mv == null || r.ua == null) return { level: 'warn', text: '读不到' }