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]>
这个提交包含在:
@@ -188,6 +188,13 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
|
||||
if d["arg"] == 3:
|
||||
d["name"] += ":" + protocol.OTA_ERR_NAMES.get(d["mv"], str(d["mv"]))
|
||||
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:
|
||||
failed = bool(d["arg"] & protocol.BOOT_CHECK_FAILED)
|
||||
d["name"] += "(按了 %d 次,%s)" % (d["arg"] & 0x0F, "没开起来" if failed else "开起来了")
|
||||
|
||||
+167
-143
@@ -1,143 +1,167 @@
|
||||
"""板侧协议在服务器这一侧的镜像(需求文档 §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 限功率"),
|
||||
17: ("PG_TIMEOUT", "上电等不到 PG"), # A15:arg bit0 = FLT# 在报,bit1 = 按电压判
|
||||
18: ("OTA", "固件升级"), # C2:arg 1 开始 / 2 就绪 / 3 失败(mV 列=原因) / 4 自证健康 / 5 回滚 / 6 为换固件重启
|
||||
19: ("BOOT_CHECK", "开机重按"),
|
||||
20: ("BLADE_MUTE", "刀片不应答关路"), # 0.1.4:eFuse 开着、本路 INA226 + PCA9555 连续 200 ms 都不应答 # 0.1.2:arg bit0~3 = 按了几次,bit7 = 最终没开起来(电源键弹片接触不良)
|
||||
}
|
||||
|
||||
# LOG_EV_OTA 的 arg(firmware/main/ota.h 的 ota_ev_t)与失败原因(ota_err_t,失败那条的 mV 列)。⚠️ 冻结。
|
||||
OTA_EV_NAMES = {1: "开始下载", 2: "已就绪", 3: "失败", 4: "新固件自证健康", 5: "回滚", 6: "为换固件重启",
|
||||
7: "计划内重启:接管、没断电"}
|
||||
OTA_ERR_NAMES = {1: "下载失败", 2: "大小不对", 3: "哈希不对", 4: "写 flash 失败", 5: "镜像校验不过",
|
||||
6: "切启动分区失败", 7: "没有备用槽 / 新固件未自证"}
|
||||
|
||||
# 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。
|
||||
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
|
||||
|
||||
# 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",
|
||||
13: "ota"}
|
||||
|
||||
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)
|
||||
"""板侧协议在服务器这一侧的镜像(需求文档 §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 限功率"),
|
||||
17: ("PG_TIMEOUT", "上电等不到 PG"), # A15:arg bit0 = FLT# 在报,bit1 = 按电压判
|
||||
18: ("OTA", "固件升级"), # C2:arg 1 开始 / 2 就绪 / 3 失败(mV 列=原因) / 4 自证健康 / 5 回滚 / 6 为换固件重启
|
||||
19: ("BOOT_CHECK", "开机重按"), # 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_CHG_MODE 的 arg(firmware/main/chgmode.h)。⚠️ 冻结。
|
||||
CHG_EV_OFF, CHG_EV_EXPIRED, CHG_EV_EXTEND = 0, 255, 0x80
|
||||
CHG_EV_DROPPED = 254 # 0.1.13:这一路断了电(刀片拔走 / 安全判据关路),自动退出。⚠️ 带 0x80 位,要先于「加钟」判
|
||||
FLASH_MODE_MAX_MIN = 120
|
||||
|
||||
# LOG_EV_OTA 的 arg(firmware/main/ota.h 的 ota_ev_t)与失败原因(ota_err_t,失败那条的 mV 列)。⚠️ 冻结。
|
||||
OTA_EV_NAMES = {1: "开始下载", 2: "已就绪", 3: "失败", 4: "新固件自证健康", 5: "回滚", 6: "为换固件重启",
|
||||
7: "计划内重启:接管、没断电"}
|
||||
OTA_ERR_NAMES = {1: "下载失败", 2: "大小不对", 3: "哈希不对", 4: "写 flash 失败", 5: "镜像校验不过",
|
||||
6: "切启动分区失败", 7: "没有备用槽 / 新固件未自证"}
|
||||
|
||||
# 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。
|
||||
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
|
||||
|
||||
# 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",
|
||||
13: "ota", 14: "flash_mode"}
|
||||
|
||||
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 == "flash_mode":
|
||||
# 0.1.5 刷机模式:VBUS 通着时允许手机充电、不断 USB。板子最多同时 2 路(多了回 state)。
|
||||
# 0.1.6 加钟:{"add": 分钟},与 on / min 互斥;只给开着的窗口加,剩余最多 120 分钟(板子截断)。
|
||||
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:
|
||||
raise CmdError("加钟必须是 1~%d 分钟" % FLASH_MODE_MAX_MIN)
|
||||
if req.get("on") is not None or req.get("min") is not None:
|
||||
raise CmdError("加钟不能和 on / min 一起用")
|
||||
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:
|
||||
m = req.get("min")
|
||||
if not isinstance(m, int) or isinstance(m, bool) or not 1 <= m <= FLASH_MODE_MAX_MIN:
|
||||
raise CmdError("刷机模式时长必须是 1~%d 分钟" % FLASH_MODE_MAX_MIN)
|
||||
out["min"] = m
|
||||
return out
|
||||
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)
|
||||
|
||||
@@ -30,6 +30,7 @@ class SimRail:
|
||||
self.busy_until = 0
|
||||
self.act = "idle"
|
||||
self.pending: Optional[str] = None # 序列结束时要做的事
|
||||
self.chg_until = 0 # 刷机模式截止(模拟时钟 ms),0 = 没开
|
||||
|
||||
|
||||
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,
|
||||
"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,
|
||||
"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},
|
||||
"rails": rails, "temp": [35000, 36500],
|
||||
"fan": {"duty": max(300, self.fan_floor), "floor": self.fan_floor, "en": True,
|
||||
@@ -231,7 +234,31 @@ class SimBoard:
|
||||
r.en = req["on"]
|
||||
if not r.en:
|
||||
r.vb = False
|
||||
if r.chg_until > self.now:
|
||||
r.chg_until = 0
|
||||
self._ev(rail, 21, 0)
|
||||
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":
|
||||
r.set_mv = req["mv"]
|
||||
return {"ok": True, "set": r.set_mv}
|
||||
|
||||
+220
-187
@@ -1,187 +1,220 @@
|
||||
"""HTTP / WebSocket 接口:用 TestClient 起完整的应用,Hub 经进程内假 broker 连模拟板子。"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from prrack.api import create_app
|
||||
from prrack.bus import MemBroker, MemBus
|
||||
from prrack.db import Db, now_ms
|
||||
from prrack.hub import Hub
|
||||
from prrack.recovery import RecoveryManager
|
||||
from prrack.simboard import SimBoard
|
||||
|
||||
B = "b81f3fc9c8bc"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
broker = MemBroker()
|
||||
db = Db(str(tmp_path / "a.db"))
|
||||
hub = Hub(db, MemBus(broker), attempt_timeout=0.2)
|
||||
rec = RecoveryManager(hub, now_ms)
|
||||
sim = SimBoard(MemBus(broker), B, present=(True, True, True, False, True))
|
||||
dist = tmp_path / "dist"
|
||||
(dist / "assets").mkdir(parents=True)
|
||||
(dist / "index.html").write_text("<html>rack</html>", encoding="utf-8")
|
||||
async def up():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
sim.tick(now_ms())
|
||||
|
||||
app = create_app(hub, rec, str(dist), startup=[up], shutdown=[hub.stop])
|
||||
with TestClient(app) as c:
|
||||
deadline = time.time() + 2
|
||||
while B not in hub.latest and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
yield c, hub, sim, db
|
||||
|
||||
|
||||
def test_boards_and_snapshot(env):
|
||||
c, hub, sim, db = env
|
||||
boards = c.get("/api/boards").json()
|
||||
assert [b["id"] for b in boards] == [B]
|
||||
assert boards[0]["online"] is True and len(boards[0]["phones"]) == 5
|
||||
assert c.get("/api/boards/nope").status_code == 404
|
||||
|
||||
|
||||
def test_command_records_the_portal_user(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": False},
|
||||
headers={"X-Auth-User": "zhouyang"})
|
||||
assert r.status_code == 200 and r.json()["ok"] is True
|
||||
assert sim.rails[0].en is False
|
||||
a = c.get("/api/audit").json()[0]
|
||||
assert a["user"] == "zhouyang" and a["request"]["cmd"] == "power" and a["ok"] == 1
|
||||
# 没有门户头(服务器本机直接 curl)记作 local
|
||||
c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": True})
|
||||
assert c.get("/api/audit").json()[0]["user"] == "local"
|
||||
|
||||
|
||||
def test_bad_commands_never_reach_the_board(env):
|
||||
c, hub, sim, db = env
|
||||
n = len(sim.received)
|
||||
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"}):
|
||||
r = c.post("/api/boards/%s/cmd" % B, json=body)
|
||||
assert r.status_code == 400, body
|
||||
assert len(sim.received) == n
|
||||
|
||||
|
||||
def test_board_rejection_is_passed_through(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power_cycle", "rail": 4})
|
||||
assert r.status_code == 200 and r.json()["err"] == "empty"
|
||||
|
||||
|
||||
def test_timeout_is_504(env):
|
||||
c, hub, sim, db = env
|
||||
sim.drop_next_replies = 99
|
||||
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 2, "on": True})
|
||||
assert r.status_code == 504
|
||||
|
||||
|
||||
def test_phone_settings_toggle_auto_recovery(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.patch("/api/boards/%s/rails/3" % B, json={"label": "A-03", "auto_recover": True})
|
||||
assert r.status_code == 200
|
||||
ph = [p for p in r.json()["phones"] if p["rail"] == 3][0]
|
||||
assert ph["label"] == "A-03" and ph["auto_recover"] == 1
|
||||
rec = c.get("/api/recovery").json()
|
||||
assert rec["params"]["window_s"] == 300
|
||||
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 # 负数不收
|
||||
|
||||
|
||||
def test_history_after_telemetry(env):
|
||||
c, hub, sim, db = env
|
||||
for _ in range(3):
|
||||
sim.tick(now_ms())
|
||||
time.sleep(0.1)
|
||||
hub.flush()
|
||||
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["points"][0][2] is not None
|
||||
|
||||
|
||||
def test_websocket_hello_and_live_tele(env):
|
||||
c, hub, sim, db = env
|
||||
with c.websocket_connect("/api/ws", headers={"X-Auth-User": "zhouyang"}) as ws:
|
||||
hello = ws.receive_json()
|
||||
assert hello["t"] == "hello" and hello["user"] == "zhouyang" and hello["boards"][0]["id"] == B
|
||||
sim.tick(now_ms())
|
||||
msg = ws.receive_json()
|
||||
while msg["t"] != "tele":
|
||||
msg = ws.receive_json()
|
||||
assert msg["board"] == B and len(msg["status"]["rails"]) == 5
|
||||
|
||||
|
||||
def test_spa_fallback_is_not_cached(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.get("/some/deep/route")
|
||||
assert r.status_code == 200 and "rack" in r.text
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
|
||||
|
||||
def test_a_real_websocket_library_is_installed():
|
||||
"""TestClient 自带 WS 实现,上面那条 WS 用例在没装 websockets 的机器上也是绿的;
|
||||
而真 uvicorn 缺了它会把 /api/ws 当普通 GET(落到 SPA 兜底,回 200 HTML)。
|
||||
deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。"""
|
||||
from prrack.main import ws_library_ok
|
||||
assert ws_library_ok()
|
||||
|
||||
|
||||
def test_board_history_raw_and_minute(env):
|
||||
c, hub, sim, db = env
|
||||
base = now_ms() // 60000 * 60000 - 3 * 60000
|
||||
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)
|
||||
h = c.get("/api/boards/%s/history" % B, params={"start": base, "end": base + 120000}).json()
|
||||
assert h["resolution"] == "raw" and len(h["points"]) >= 100
|
||||
p = h["points"][0]
|
||||
assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000
|
||||
# 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾
|
||||
assert db.downsample(base + 180000) > 0
|
||||
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,))
|
||||
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 rows_min[0]["t1_min"] == 40000 - 59 and rows_min[0]["fan_avg"] == 300
|
||||
assert m["resolution"] == "raw"
|
||||
old = db.board_history(B, base - 10 * 86400000, base + 120000)
|
||||
# 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大
|
||||
assert old["resolution"] == "minute" and old["points"][0][2] == 35000
|
||||
assert max(p[3] for p in old["points"]) == 35119
|
||||
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):
|
||||
# 固件 0.1.2 LOG_EV_BOOT_CHECK:arg 低 4 位 = 按了几次,bit7 = 最终没开起来
|
||||
c, hub, sim, db = env
|
||||
now = now_ms()
|
||||
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, 9002, 2000, 1, 19, 0x83, 3931, 1500, now + 1)])
|
||||
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 "按了 2 次,开起来了" in ev[9001]["name"]
|
||||
assert ev[9002]["severe"] is True and "按了 3 次,没开起来" in ev[9002]["name"]
|
||||
|
||||
|
||||
def test_board_history_sums_rail_power(env):
|
||||
# 五路主板合计功率:同一时刻各路 mv×ua 求和再按桶平均;没有主板遥测的时刻是 None
|
||||
c, hub, sim, db = env
|
||||
base = now_ms() // 60000 * 60000 - 3 * 60000
|
||||
db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)",
|
||||
[(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 没有主板数据
|
||||
db.many("INSERT INTO tele VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
[(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))])
|
||||
h = db.board_history(B, base, base + 120000)
|
||||
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} == {None}
|
||||
assert db.downsample(base + 180000) > 0
|
||||
old = db.board_history(B, base - 10 * 86400000, base + 120000)
|
||||
assert old["resolution"] == "minute" and old["points"][0][10] == 5900
|
||||
"""HTTP / WebSocket 接口:用 TestClient 起完整的应用,Hub 经进程内假 broker 连模拟板子。"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from prrack.api import create_app
|
||||
from prrack.bus import MemBroker, MemBus
|
||||
from prrack.db import Db, now_ms
|
||||
from prrack.hub import Hub
|
||||
from prrack.recovery import RecoveryManager
|
||||
from prrack.simboard import SimBoard
|
||||
|
||||
B = "b81f3fc9c8bc"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
broker = MemBroker()
|
||||
db = Db(str(tmp_path / "a.db"))
|
||||
hub = Hub(db, MemBus(broker), attempt_timeout=0.2)
|
||||
rec = RecoveryManager(hub, now_ms)
|
||||
sim = SimBoard(MemBus(broker), B, present=(True, True, True, False, True))
|
||||
dist = tmp_path / "dist"
|
||||
(dist / "assets").mkdir(parents=True)
|
||||
(dist / "index.html").write_text("<html>rack</html>", encoding="utf-8")
|
||||
async def up():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
sim.tick(now_ms())
|
||||
|
||||
app = create_app(hub, rec, str(dist), startup=[up], shutdown=[hub.stop])
|
||||
with TestClient(app) as c:
|
||||
deadline = time.time() + 2
|
||||
while B not in hub.latest and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
yield c, hub, sim, db
|
||||
|
||||
|
||||
def test_boards_and_snapshot(env):
|
||||
c, hub, sim, db = env
|
||||
boards = c.get("/api/boards").json()
|
||||
assert [b["id"] for b in boards] == [B]
|
||||
assert boards[0]["online"] is True and len(boards[0]["phones"]) == 5
|
||||
assert c.get("/api/boards/nope").status_code == 404
|
||||
|
||||
|
||||
def test_command_records_the_portal_user(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": False},
|
||||
headers={"X-Auth-User": "zhouyang"})
|
||||
assert r.status_code == 200 and r.json()["ok"] is True
|
||||
assert sim.rails[0].en is False
|
||||
a = c.get("/api/audit").json()[0]
|
||||
assert a["user"] == "zhouyang" and a["request"]["cmd"] == "power" and a["ok"] == 1
|
||||
# 没有门户头(服务器本机直接 curl)记作 local
|
||||
c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": True})
|
||||
assert c.get("/api/audit").json()[0]["user"] == "local"
|
||||
|
||||
|
||||
def test_bad_commands_never_reach_the_board(env):
|
||||
c, hub, sim, db = env
|
||||
n = len(sim.received)
|
||||
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"}):
|
||||
r = c.post("/api/boards/%s/cmd" % B, json=body)
|
||||
assert r.status_code == 400, body
|
||||
assert len(sim.received) == n
|
||||
|
||||
|
||||
def test_board_rejection_is_passed_through(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power_cycle", "rail": 4})
|
||||
assert r.status_code == 200 and r.json()["err"] == "empty"
|
||||
|
||||
|
||||
def test_timeout_is_504(env):
|
||||
c, hub, sim, db = env
|
||||
sim.drop_next_replies = 99
|
||||
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 2, "on": True})
|
||||
assert r.status_code == 504
|
||||
|
||||
|
||||
def test_phone_settings_toggle_auto_recovery(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.patch("/api/boards/%s/rails/3" % B, json={"label": "A-03", "auto_recover": True})
|
||||
assert r.status_code == 200
|
||||
ph = [p for p in r.json()["phones"] if p["rail"] == 3][0]
|
||||
assert ph["label"] == "A-03" and ph["auto_recover"] == 1
|
||||
rec = c.get("/api/recovery").json()
|
||||
assert rec["params"]["window_s"] == 300
|
||||
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 # 负数不收
|
||||
|
||||
|
||||
def test_history_after_telemetry(env):
|
||||
c, hub, sim, db = env
|
||||
for _ in range(3):
|
||||
sim.tick(now_ms())
|
||||
time.sleep(0.1)
|
||||
hub.flush()
|
||||
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["points"][0][2] is not None
|
||||
|
||||
|
||||
def test_websocket_hello_and_live_tele(env):
|
||||
c, hub, sim, db = env
|
||||
with c.websocket_connect("/api/ws", headers={"X-Auth-User": "zhouyang"}) as ws:
|
||||
hello = ws.receive_json()
|
||||
assert hello["t"] == "hello" and hello["user"] == "zhouyang" and hello["boards"][0]["id"] == B
|
||||
sim.tick(now_ms())
|
||||
msg = ws.receive_json()
|
||||
while msg["t"] != "tele":
|
||||
msg = ws.receive_json()
|
||||
assert msg["board"] == B and len(msg["status"]["rails"]) == 5
|
||||
|
||||
|
||||
def test_spa_fallback_is_not_cached(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.get("/some/deep/route")
|
||||
assert r.status_code == 200 and "rack" in r.text
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
|
||||
|
||||
def test_a_real_websocket_library_is_installed():
|
||||
"""TestClient 自带 WS 实现,上面那条 WS 用例在没装 websockets 的机器上也是绿的;
|
||||
而真 uvicorn 缺了它会把 /api/ws 当普通 GET(落到 SPA 兜底,回 200 HTML)。
|
||||
deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。"""
|
||||
from prrack.main import ws_library_ok
|
||||
assert ws_library_ok()
|
||||
|
||||
|
||||
def test_board_history_raw_and_minute(env):
|
||||
c, hub, sim, db = env
|
||||
base = now_ms() // 60000 * 60000 - 3 * 60000
|
||||
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)
|
||||
h = c.get("/api/boards/%s/history" % B, params={"start": base, "end": base + 120000}).json()
|
||||
assert h["resolution"] == "raw" and len(h["points"]) >= 100
|
||||
p = h["points"][0]
|
||||
assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000
|
||||
# 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾
|
||||
assert db.downsample(base + 180000) > 0
|
||||
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,))
|
||||
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 rows_min[0]["t1_min"] == 40000 - 59 and rows_min[0]["fan_avg"] == 300
|
||||
assert m["resolution"] == "raw"
|
||||
old = db.board_history(B, base - 10 * 86400000, base + 120000)
|
||||
# 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大
|
||||
assert old["resolution"] == "minute" and old["points"][0][2] == 35000
|
||||
assert max(p[3] for p in old["points"]) == 35119
|
||||
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):
|
||||
# 固件 0.1.2 LOG_EV_BOOT_CHECK:arg 低 4 位 = 按了几次,bit7 = 最终没开起来
|
||||
c, hub, sim, db = env
|
||||
now = now_ms()
|
||||
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, 9002, 2000, 1, 19, 0x83, 3931, 1500, now + 1)])
|
||||
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 "按了 2 次,开起来了" in ev[9001]["name"]
|
||||
assert ev[9002]["severe"] is True and "按了 3 次,没开起来" in ev[9002]["name"]
|
||||
|
||||
|
||||
def test_board_history_sums_rail_power(env):
|
||||
# 五路主板合计功率:同一时刻各路 mv×ua 求和再按桶平均;没有主板遥测的时刻是 None
|
||||
c, hub, sim, db = env
|
||||
base = now_ms() // 60000 * 60000 - 3 * 60000
|
||||
db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)",
|
||||
[(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 没有主板数据
|
||||
db.many("INSERT INTO tele VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
[(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))])
|
||||
h = db.board_history(B, base, base + 120000)
|
||||
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} == {None}
|
||||
assert db.downsample(base + 180000) > 0
|
||||
old = db.board_history(B, base - 10 * 86400000, base + 120000)
|
||||
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 对接模拟板子)+ 自动恢复状态机。
|
||||
|
||||
和固件测试同一个口径:断言打在「板子那头真的收到 / 执行了什么」上(SimBoard.received、
|
||||
它的 rails 状态),不看 Hub 自己回了什么 —— 重传换了 seq 的现象恰恰是 Hub 这边一切正常、
|
||||
手机被按了两遍。"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from prrack import protocol
|
||||
from prrack.bus import MemBroker, MemBus, topic_matches
|
||||
from prrack.db import Db
|
||||
from prrack.hub import CmdTimeout, Hub
|
||||
from prrack.recovery import GAVE_UP, HARD, IDLE, SOFT, Params, PhoneRecovery, RecoveryManager
|
||||
from prrack.simboard import HUNG, NORMAL, OFF, SimBoard
|
||||
|
||||
B = "b81f3fc9c8bc"
|
||||
|
||||
|
||||
def run(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally: # 收掉 Hub 的后台任务(flusher / maint),免得退出时刷一屏 pending 警告
|
||||
tasks = asyncio.all_tasks(loop)
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
|
||||
loop.close()
|
||||
|
||||
|
||||
async def settle():
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
def rig(tmp_path, present=(True,) * 5, timeout=0.05):
|
||||
broker = MemBroker()
|
||||
db = Db(str(tmp_path / "t.db"))
|
||||
hub = Hub(db, MemBus(broker), attempt_timeout=timeout)
|
||||
sim = SimBoard(MemBus(broker), B, present=present)
|
||||
return hub, sim, db
|
||||
|
||||
|
||||
# ---- 协议镜像 -----------------------------------------------------------------
|
||||
|
||||
def test_build_command_whitelist_and_ranges():
|
||||
assert protocol.build_command({"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}) == \
|
||||
{"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}
|
||||
bad = [
|
||||
{"cmd": "wifi", "ssid": "x"}, # 只收 USB 的命令不给界面
|
||||
{"cmd": "events"}, # 服务器自己同步
|
||||
{"cmd": "set_voltage", "rail": 1, "mv": 4011}, # SR-13 之上
|
||||
{"cmd": "set_voltage", "rail": 1, "mv": "3900"},
|
||||
{"cmd": "key", "rail": 1, "keys": "pwr+home", "ms": 100},
|
||||
{"cmd": "key", "rail": 1, "keys": "pwr", "ms": 15001},
|
||||
{"cmd": "power", "rail": 6, "on": True},
|
||||
{"cmd": "power", "rail": True, "on": True}, # bool 不是 int
|
||||
{"cmd": "vbus", "rail": 1, "on": 1},
|
||||
{"cmd": "fan", "duty": 1001},
|
||||
]
|
||||
for b in bad:
|
||||
with pytest.raises(protocol.CmdError):
|
||||
protocol.build_command(b)
|
||||
|
||||
|
||||
def test_seq_is_strictly_increasing_and_in_range():
|
||||
t = [1700000000.0]
|
||||
g = protocol.SeqGen(clock=lambda: t[0])
|
||||
a = [g.next() for _ in range(5)]
|
||||
assert a == sorted(set(a)) and all(1 <= x <= 0xFFFFFFFF for x in a)
|
||||
t[0] -= 100 # 时钟往回跳也不许重复
|
||||
assert g.next() > a[-1] or g.next() == 1
|
||||
|
||||
|
||||
def test_event_table_matches_firmware_numbers():
|
||||
# firmware/main/evlog.h 冻结的数字(抽几个钉住)
|
||||
assert protocol.EVENT_TYPES[3][0] == "OVER_VOLT"
|
||||
assert protocol.EVENT_TYPES[12][0] == "REBOOT"
|
||||
assert protocol.EVENT_TYPES[13][0] == "CMD_REJECT"
|
||||
assert protocol.EVENT_TYPES[16][0] == "PD_LIMIT"
|
||||
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[19][0] == "BOOT_CHECK" and protocol.BOOT_CHECK_FAILED == 0x80 # 0.1.2
|
||||
|
||||
|
||||
def test_topic_matching():
|
||||
assert topic_matches("phonerail/+/rsp", "phonerail/%s/rsp" % B)
|
||||
assert not topic_matches("phonerail/+/rsp", "phonerail/%s/tele" % B)
|
||||
assert topic_matches("phonerail/#", "phonerail/x/y")
|
||||
assert protocol.parse_topic("phonerail/%s/tele" % B) == (B, "tele")
|
||||
assert protocol.parse_topic("evse/123/state") is None
|
||||
|
||||
|
||||
# ---- Hub ---------------------------------------------------------------------
|
||||
|
||||
def test_tele_is_ingested_and_board_discovered(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
sim.tick(1000)
|
||||
await settle()
|
||||
hub.flush()
|
||||
|
||||
run(go())
|
||||
snap = hub.snapshot(B)
|
||||
assert snap["online"] and snap["fw"] == "0.1.0-sim"
|
||||
assert snap["status"]["rails"][0]["en"] is True
|
||||
assert db.q("SELECT COUNT(*) c FROM tele")[0]["c"] == 5
|
||||
assert len(snap["phones"]) == 5 and snap["phones"][0]["auto_recover"] == 0
|
||||
|
||||
|
||||
def test_command_round_trip_and_audit(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
return await hub.call(B, {"cmd": "power", "rail": 2, "on": False}, "alice")
|
||||
|
||||
rsp = run(go())
|
||||
assert rsp["ok"] is True
|
||||
assert sim.rails[1].en is False
|
||||
row = db.q("SELECT * FROM audit")[0]
|
||||
assert row["user"] == "alice" and row["rail"] == 2 and row["ok"] == 1
|
||||
|
||||
|
||||
def test_retransmit_reuses_the_same_seq_and_executes_once(tmp_path):
|
||||
"""⭐ 回应丢了一次:Hub 用**同一个 seq** 重发,板子判重,只执行一次。"""
|
||||
hub, sim, db = rig(tmp_path)
|
||||
sim.drop_next_replies = 1
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
return await hub.call(B, {"cmd": "power_cycle", "rail": 1}, "bob")
|
||||
|
||||
rsp = run(go())
|
||||
assert rsp["ok"] is True and rsp.get("dup") is True
|
||||
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 [e[3] for e in sim.events].count(12) == 1 # 重启事件只记了一次
|
||||
|
||||
|
||||
def test_timeout_is_audited_and_raised(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
sim.drop_next_replies = 99
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
with pytest.raises(CmdTimeout):
|
||||
await hub.call(B, {"cmd": "power", "rail": 1, "on": True}, "carol")
|
||||
|
||||
run(go())
|
||||
row = db.q("SELECT * FROM audit")[0]
|
||||
assert row["ok"] == 0 and json.loads(row["response"])["err"] == "timeout"
|
||||
assert len({r["seq"] for r in sim.received}) == 1 # 三次都是同一个 seq
|
||||
|
||||
|
||||
def test_events_are_synced_incrementally(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
for i in range(19):
|
||||
sim._ev(1, 8, i) # 加上 BOOT 共 20 条:要分 3 页
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
sim.tick(1000)
|
||||
for _ in range(20):
|
||||
await settle()
|
||||
sim._ev(2, 3, 0, 4200, 1) # 又来一条过压
|
||||
sim.tick(2000)
|
||||
for _ in range(20):
|
||||
await settle()
|
||||
|
||||
run(go())
|
||||
seqs = [r["seq"] for r in db.q("SELECT seq FROM events ORDER BY seq")]
|
||||
assert seqs == list(range(21))
|
||||
assert db.q("SELECT ev_cursor FROM boards")[0]["ev_cursor"] == 21
|
||||
|
||||
|
||||
def test_empty_slot_reply_passes_through(tmp_path):
|
||||
hub, sim, db = rig(tmp_path, present=(True, True, True, False, True))
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
return await hub.call(B, {"cmd": "power", "rail": 4, "on": True}, "dave")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def feed(ph, t0, secs, ua_fn):
|
||||
out = []
|
||||
for s in range(secs):
|
||||
st, act = ph.feed(t0 + s * 1000, ua_fn(s), 3)
|
||||
if st or act:
|
||||
out.append((t0 + s * 1000, st, act))
|
||||
return out
|
||||
|
||||
|
||||
def test_flat_current_escalates_soft_then_hard_then_gives_up():
|
||||
ph = PhoneRecovery(P)
|
||||
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}
|
||||
t = r[-1][0]
|
||||
# settle 期间的样本一概不看(哪怕是平线)
|
||||
assert feed(ph, t + 1000, 29, 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}
|
||||
t = r[-1][0]
|
||||
r = feed(ph, t + 30000, 62, lambda s: 412000)
|
||||
assert r[-1][1] == GAVE_UP and r[-1][2] is None
|
||||
assert feed(ph, t + 200000, 100, lambda s: 412000) == [] # 放弃之后再也不动
|
||||
|
||||
|
||||
def test_recovers_after_soft_when_current_moves_again():
|
||||
ph = PhoneRecovery(P)
|
||||
t = feed(ph, 0, 61, lambda s: 412000)[-1][0]
|
||||
r = feed(ph, t + 30000, 70, lambda s: 300000 + (s % 7) * 40000)
|
||||
assert r[-1][1] == IDLE and ph.reason == "recovered"
|
||||
|
||||
|
||||
def test_low_current_is_detected_and_normal_is_not():
|
||||
ph = PhoneRecovery(P)
|
||||
assert feed(ph, 0, 200, lambda s: 300000 + (s % 5) * 50000) == []
|
||||
ph2 = PhoneRecovery(P)
|
||||
assert feed(ph2, 0, 61, lambda s: 800 + s % 3)[-1][2][0] == "low"
|
||||
|
||||
|
||||
def test_rate_limit_gives_up():
|
||||
ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2))
|
||||
t = 0
|
||||
for _ in range(2): # 两次软重启都「恢复」了,但每次很快又卡
|
||||
assert feed(ph, t, 65, lambda s: 412000)[-1][1] == SOFT
|
||||
t += 65000 # 下一段接着上一段喂完的时刻往后走(时间不倒退)
|
||||
# 前 30 s 还在 settle 里不算数,之后要攒满一个 60 s 窗口才判「恢复」
|
||||
assert feed(ph, t, 100, lambda s: 300000 + (s % 7) * 40000)[-1][1] == IDLE
|
||||
t += 100000
|
||||
r = feed(ph, t, 65, lambda s: 412000)
|
||||
assert r[-1][1] == GAVE_UP and ph.reason == "rate_limit"
|
||||
|
||||
|
||||
# ---- 自动恢复:接到 Hub + 模拟板上 -------------------------------------------------
|
||||
|
||||
def test_auto_recovery_end_to_end_with_sim(tmp_path):
|
||||
"""⭐ 模拟板上第 3 台卡死:开了自动恢复 → 5 分钟后下长按电源 → 板子执行 → 恢复。
|
||||
没开自动恢复的第 2 台(也卡死)一个命令都不收到。"""
|
||||
hub, sim, db = rig(tmp_path)
|
||||
clock = [1_000_000]
|
||||
rec = RecoveryManager(hub, lambda: clock[0])
|
||||
sim.set_phone(3, HUNG)
|
||||
sim.set_phone(2, HUNG)
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
await settle() # 等 Hub 处理完上线消息、建好这块板的 phones 行
|
||||
db.x("UPDATE phones SET auto_recover=1 WHERE rail=3")
|
||||
rec.reload()
|
||||
for s in range(300 + 130 + 320):
|
||||
clock[0] += 1000
|
||||
sim.tick(clock[0])
|
||||
await settle()
|
||||
|
||||
run(go())
|
||||
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 not [r for r in sim.received if r.get("rail") == 2]
|
||||
assert sim.rails[2].phone == NORMAL
|
||||
states = [r["state"] for r in db.q("SELECT state FROM recovery_log ORDER BY id")]
|
||||
assert states == [SOFT, IDLE]
|
||||
assert db.q("SELECT user FROM audit")[0]["user"] == "auto-recovery"
|
||||
|
||||
|
||||
def test_auto_recovery_ignores_rails_that_are_off(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
clock = [0]
|
||||
rec = RecoveryManager(hub, lambda: clock[0])
|
||||
sim.set_phone(1, OFF)
|
||||
sim.rails[0].en = False # 这一路本来就关着:电流 0 不是故障
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
await settle()
|
||||
db.x("UPDATE phones SET auto_recover=1")
|
||||
rec.reload()
|
||||
for s in range(400):
|
||||
clock[0] += 1000
|
||||
sim.tick(clock[0])
|
||||
await settle()
|
||||
|
||||
run(go())
|
||||
assert not [r for r in sim.received if r.get("cmd") in ("key", "power_cycle")]
|
||||
"""协议镜像 + Hub(经进程内假 broker 对接模拟板子)+ 自动恢复状态机。
|
||||
|
||||
和固件测试同一个口径:断言打在「板子那头真的收到 / 执行了什么」上(SimBoard.received、
|
||||
它的 rails 状态),不看 Hub 自己回了什么 —— 重传换了 seq 的现象恰恰是 Hub 这边一切正常、
|
||||
手机被按了两遍。"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from prrack import protocol
|
||||
from prrack.bus import MemBroker, MemBus, topic_matches
|
||||
from prrack.db import Db
|
||||
from prrack.hub import CmdTimeout, Hub
|
||||
from prrack.recovery import GAVE_UP, HARD, IDLE, SOFT, Params, PhoneRecovery, RecoveryManager
|
||||
from prrack.simboard import HUNG, NORMAL, OFF, SimBoard
|
||||
|
||||
B = "b81f3fc9c8bc"
|
||||
|
||||
|
||||
def run(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally: # 收掉 Hub 的后台任务(flusher / maint),免得退出时刷一屏 pending 警告
|
||||
tasks = asyncio.all_tasks(loop)
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
|
||||
loop.close()
|
||||
|
||||
|
||||
async def settle():
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
def rig(tmp_path, present=(True,) * 5, timeout=0.05):
|
||||
broker = MemBroker()
|
||||
db = Db(str(tmp_path / "t.db"))
|
||||
hub = Hub(db, MemBus(broker), attempt_timeout=timeout)
|
||||
sim = SimBoard(MemBus(broker), B, present=present)
|
||||
return hub, sim, db
|
||||
|
||||
|
||||
# ---- 协议镜像 -----------------------------------------------------------------
|
||||
|
||||
def test_build_command_whitelist_and_ranges():
|
||||
assert protocol.build_command({"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}) == \
|
||||
{"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}
|
||||
bad = [
|
||||
{"cmd": "wifi", "ssid": "x"}, # 只收 USB 的命令不给界面
|
||||
{"cmd": "events"}, # 服务器自己同步
|
||||
{"cmd": "set_voltage", "rail": 1, "mv": 4011}, # SR-13 之上
|
||||
{"cmd": "set_voltage", "rail": 1, "mv": "3900"},
|
||||
{"cmd": "key", "rail": 1, "keys": "pwr+home", "ms": 100},
|
||||
{"cmd": "key", "rail": 1, "keys": "pwr", "ms": 15001},
|
||||
{"cmd": "power", "rail": 6, "on": True},
|
||||
{"cmd": "power", "rail": True, "on": True}, # bool 不是 int
|
||||
{"cmd": "vbus", "rail": 1, "on": 1},
|
||||
{"cmd": "fan", "duty": 1001},
|
||||
]
|
||||
for b in bad:
|
||||
with pytest.raises(protocol.CmdError):
|
||||
protocol.build_command(b)
|
||||
|
||||
|
||||
def test_seq_is_strictly_increasing_and_in_range():
|
||||
t = [1700000000.0]
|
||||
g = protocol.SeqGen(clock=lambda: t[0])
|
||||
a = [g.next() for _ in range(5)]
|
||||
assert a == sorted(set(a)) and all(1 <= x <= 0xFFFFFFFF for x in a)
|
||||
t[0] -= 100 # 时钟往回跳也不许重复
|
||||
assert g.next() > a[-1] or g.next() == 1
|
||||
|
||||
|
||||
def test_event_table_matches_firmware_numbers():
|
||||
# firmware/main/evlog.h 冻结的数字(抽几个钉住)
|
||||
assert protocol.EVENT_TYPES[3][0] == "OVER_VOLT"
|
||||
assert protocol.EVENT_TYPES[12][0] == "REBOOT"
|
||||
assert protocol.EVENT_TYPES[13][0] == "CMD_REJECT"
|
||||
assert protocol.EVENT_TYPES[16][0] == "PD_LIMIT"
|
||||
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[19][0] == "BOOT_CHECK" and protocol.BOOT_CHECK_FAILED == 0x80 # 0.1.2
|
||||
|
||||
|
||||
def test_topic_matching():
|
||||
assert topic_matches("phonerail/+/rsp", "phonerail/%s/rsp" % B)
|
||||
assert not topic_matches("phonerail/+/rsp", "phonerail/%s/tele" % B)
|
||||
assert topic_matches("phonerail/#", "phonerail/x/y")
|
||||
assert protocol.parse_topic("phonerail/%s/tele" % B) == (B, "tele")
|
||||
assert protocol.parse_topic("evse/123/state") is None
|
||||
|
||||
|
||||
# ---- Hub ---------------------------------------------------------------------
|
||||
|
||||
def test_tele_is_ingested_and_board_discovered(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
sim.tick(1000)
|
||||
await settle()
|
||||
hub.flush()
|
||||
|
||||
run(go())
|
||||
snap = hub.snapshot(B)
|
||||
assert snap["online"] and snap["fw"] == "0.1.0-sim"
|
||||
assert snap["status"]["rails"][0]["en"] is True
|
||||
assert db.q("SELECT COUNT(*) c FROM tele")[0]["c"] == 5
|
||||
assert len(snap["phones"]) == 5 and snap["phones"][0]["auto_recover"] == 0
|
||||
|
||||
|
||||
def test_command_round_trip_and_audit(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
return await hub.call(B, {"cmd": "power", "rail": 2, "on": False}, "alice")
|
||||
|
||||
rsp = run(go())
|
||||
assert rsp["ok"] is True
|
||||
assert sim.rails[1].en is False
|
||||
row = db.q("SELECT * FROM audit")[0]
|
||||
assert row["user"] == "alice" and row["rail"] == 2 and row["ok"] == 1
|
||||
|
||||
|
||||
def test_retransmit_reuses_the_same_seq_and_executes_once(tmp_path):
|
||||
"""⭐ 回应丢了一次:Hub 用**同一个 seq** 重发,板子判重,只执行一次。"""
|
||||
hub, sim, db = rig(tmp_path)
|
||||
sim.drop_next_replies = 1
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
return await hub.call(B, {"cmd": "power_cycle", "rail": 1}, "bob")
|
||||
|
||||
rsp = run(go())
|
||||
assert rsp["ok"] is True and rsp.get("dup") is True
|
||||
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 [e[3] for e in sim.events].count(12) == 1 # 重启事件只记了一次
|
||||
|
||||
|
||||
def test_timeout_is_audited_and_raised(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
sim.drop_next_replies = 99
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
with pytest.raises(CmdTimeout):
|
||||
await hub.call(B, {"cmd": "power", "rail": 1, "on": True}, "carol")
|
||||
|
||||
run(go())
|
||||
row = db.q("SELECT * FROM audit")[0]
|
||||
assert row["ok"] == 0 and json.loads(row["response"])["err"] == "timeout"
|
||||
assert len({r["seq"] for r in sim.received}) == 1 # 三次都是同一个 seq
|
||||
|
||||
|
||||
def test_events_are_synced_incrementally(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
for i in range(19):
|
||||
sim._ev(1, 8, i) # 加上 BOOT 共 20 条:要分 3 页
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
sim.tick(1000)
|
||||
for _ in range(20):
|
||||
await settle()
|
||||
sim._ev(2, 3, 0, 4200, 1) # 又来一条过压
|
||||
sim.tick(2000)
|
||||
for _ in range(20):
|
||||
await settle()
|
||||
|
||||
run(go())
|
||||
seqs = [r["seq"] for r in db.q("SELECT seq FROM events ORDER BY seq")]
|
||||
assert seqs == list(range(21))
|
||||
assert db.q("SELECT ev_cursor FROM boards")[0]["ev_cursor"] == 21
|
||||
|
||||
|
||||
def test_empty_slot_reply_passes_through(tmp_path):
|
||||
hub, sim, db = rig(tmp_path, present=(True, True, True, False, True))
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
return await hub.call(B, {"cmd": "power", "rail": 4, "on": True}, "dave")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def feed(ph, t0, secs, ua_fn):
|
||||
out = []
|
||||
for s in range(secs):
|
||||
st, act = ph.feed(t0 + s * 1000, ua_fn(s), 3)
|
||||
if st or act:
|
||||
out.append((t0 + s * 1000, st, act))
|
||||
return out
|
||||
|
||||
|
||||
def test_flat_current_escalates_soft_then_hard_then_gives_up():
|
||||
ph = PhoneRecovery(P)
|
||||
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}
|
||||
t = r[-1][0]
|
||||
# settle 期间的样本一概不看(哪怕是平线)
|
||||
assert feed(ph, t + 1000, 29, 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}
|
||||
t = r[-1][0]
|
||||
r = feed(ph, t + 30000, 62, lambda s: 412000)
|
||||
assert r[-1][1] == GAVE_UP and r[-1][2] is None
|
||||
assert feed(ph, t + 200000, 100, lambda s: 412000) == [] # 放弃之后再也不动
|
||||
|
||||
|
||||
def test_recovers_after_soft_when_current_moves_again():
|
||||
ph = PhoneRecovery(P)
|
||||
t = feed(ph, 0, 61, lambda s: 412000)[-1][0]
|
||||
r = feed(ph, t + 30000, 70, lambda s: 300000 + (s % 7) * 40000)
|
||||
assert r[-1][1] == IDLE and ph.reason == "recovered"
|
||||
|
||||
|
||||
def test_low_current_is_detected_and_normal_is_not():
|
||||
ph = PhoneRecovery(P)
|
||||
assert feed(ph, 0, 200, lambda s: 300000 + (s % 5) * 50000) == []
|
||||
ph2 = PhoneRecovery(P)
|
||||
assert feed(ph2, 0, 61, lambda s: 800 + s % 3)[-1][2][0] == "low"
|
||||
|
||||
|
||||
def test_rate_limit_gives_up():
|
||||
ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2))
|
||||
t = 0
|
||||
for _ in range(2): # 两次软重启都「恢复」了,但每次很快又卡
|
||||
assert feed(ph, t, 65, lambda s: 412000)[-1][1] == SOFT
|
||||
t += 65000 # 下一段接着上一段喂完的时刻往后走(时间不倒退)
|
||||
# 前 30 s 还在 settle 里不算数,之后要攒满一个 60 s 窗口才判「恢复」
|
||||
assert feed(ph, t, 100, lambda s: 300000 + (s % 7) * 40000)[-1][1] == IDLE
|
||||
t += 100000
|
||||
r = feed(ph, t, 65, lambda s: 412000)
|
||||
assert r[-1][1] == GAVE_UP and ph.reason == "rate_limit"
|
||||
|
||||
|
||||
# ---- 自动恢复:接到 Hub + 模拟板上 -------------------------------------------------
|
||||
|
||||
def test_auto_recovery_end_to_end_with_sim(tmp_path):
|
||||
"""⭐ 模拟板上第 3 台卡死:开了自动恢复 → 5 分钟后下长按电源 → 板子执行 → 恢复。
|
||||
没开自动恢复的第 2 台(也卡死)一个命令都不收到。"""
|
||||
hub, sim, db = rig(tmp_path)
|
||||
clock = [1_000_000]
|
||||
rec = RecoveryManager(hub, lambda: clock[0])
|
||||
sim.set_phone(3, HUNG)
|
||||
sim.set_phone(2, HUNG)
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
await settle() # 等 Hub 处理完上线消息、建好这块板的 phones 行
|
||||
db.x("UPDATE phones SET auto_recover=1 WHERE rail=3")
|
||||
rec.reload()
|
||||
for s in range(300 + 130 + 320):
|
||||
clock[0] += 1000
|
||||
sim.tick(clock[0])
|
||||
await settle()
|
||||
|
||||
run(go())
|
||||
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 not [r for r in sim.received if r.get("rail") == 2]
|
||||
assert sim.rails[2].phone == NORMAL
|
||||
states = [r["state"] for r in db.q("SELECT state FROM recovery_log ORDER BY id")]
|
||||
assert states == [SOFT, IDLE]
|
||||
assert db.q("SELECT user FROM audit")[0]["user"] == "auto-recovery"
|
||||
|
||||
|
||||
def test_auto_recovery_ignores_rails_that_are_off(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
clock = [0]
|
||||
rec = RecoveryManager(hub, lambda: clock[0])
|
||||
sim.set_phone(1, OFF)
|
||||
sim.rails[0].en = False # 这一路本来就关着:电流 0 不是故障
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
await settle()
|
||||
db.x("UPDATE phones SET auto_recover=1")
|
||||
rec.reload()
|
||||
for s in range(400):
|
||||
clock[0] += 1000
|
||||
sim.tick(clock[0])
|
||||
await settle()
|
||||
|
||||
run(go())
|
||||
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)
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
<span :class="{ on: r.en }">直供{{ r.en ? '开' : '关' }}</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="{ 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 class="ar" @click.stop>
|
||||
@@ -39,6 +41,8 @@
|
||||
<el-dropdown-item command="recovery">进 Recovery(音量上 + 电源)</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="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="volt">调电压…</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
@@ -104,10 +108,25 @@ function more(c) {
|
||||
case 'edl': return run('进 EDL', { cmd: 'edl' }, true)
|
||||
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 '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
|
||||
}
|
||||
}
|
||||
|
||||
// 固件 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 mv = ref(3900)
|
||||
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.on { color: var(--ink); border-color: var(--ink-2); }
|
||||
.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; }
|
||||
.acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
|
||||
.acts .el-button + .el-button { margin-left: 0; }
|
||||
|
||||
@@ -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)
|
||||
const EV = ['', '板子上电', '事件丢失', '过压关路', '欠压关路', '反灌断 VBUS', '顶高断 VBUS', 'eFuse 故障',
|
||||
'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率',
|
||||
'上电等不到 PG', '固件升级', '开机重按', '刀片不应答关路']
|
||||
'上电等不到 PG', '固件升级', '开机重按', '刀片不应答关路', '刷机模式']
|
||||
const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14, 17, 20])
|
||||
// 开机重按(19)只有 arg bit7「最终没开起来」才算严重
|
||||
const isSevere = ev => SEVERE.has(ev[3]) || (ev[3] === 19 && (ev[4] & 0x80) !== 0)
|
||||
|
||||
@@ -43,6 +43,9 @@ export function railHealth(board, r) {
|
||||
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.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.pg === false) return { level: 'bad', text: 'PG 丢失' }
|
||||
if (r.mv == null || r.ua == null) return { level: 'warn', text: '读不到' }
|
||||
|
||||
在新工单中引用
屏蔽一个用户