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]>
这个提交包含在:
+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)
|
||||
|
||||
在新工单中引用
屏蔽一个用户