rack:主板 / 底板功率与功率曲线;底板曲线加电压电流、开机重按事件

- 每路主板功率(V×I):总览槽位、单板卡片、单路曲线新增「功率」一张
- 底板:单板页新增功率仪表(底板输入 / 五路主板合计 / 损耗),底板曲线加
  功率图(输入 vs 主板合计);board_history 每点多回一列 rails_mw
- 底板曲线改为纵向 N 张(温度 / 风扇 / 输入电压 / 输入电流 / 功率),断档断线
- LOG_EV_BOOT_CHECK(19)事件命名,只有「没开起来」标红

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-28 08:39:47 +08:00
共同撰写人 Claude Opus 5.5
父节点 1f6862ac38
当前提交 830b4cdff8
共修改 14 个文件,包含 1288 行新增和 1122 行删除
+5 -1
查看文件
@@ -110,7 +110,7 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
@app.get("/api/boards/{board}/history")
def board_history(board: str, start: int, end: Optional[int] = None):
"""整板曲线(温度 / 风扇 / 输入),见 Db.board_history。"""
"""整板曲线(温度 / 风扇 / 输入 / 五路主板合计功率),见 Db.board_history。"""
_board_or_404(hub, board)
return hub.db.board_history(board, start, end or now_ms())
@@ -188,6 +188,10 @@ 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"] == 19:
failed = bool(d["arg"] & protocol.BOOT_CHECK_FAILED)
d["name"] += "(按了 %d 次,%s)" % (d["arg"] & 0x0F, "没开起来" if failed else "开起来了")
d["severe"] = failed
out.append(d)
return out
+10 -3
查看文件
@@ -215,7 +215,8 @@ class Db:
def board_history(self, board: str, t0: int, t1: int, max_points: int = 1500) -> dict:
"""[t0, t1) 的整板曲线:两路 NTC 温度(m°C,带每桶 min/max)、风扇占空比(‰)、输入电压电流。
取数规则同 `history()`:近期短跨度回原始 1 s,否则回分钟聚合。
points:[t, t0, t0_min, t0_max, t1, t1_min, t1_max, fan, in_mv, in_ua];读不到的是 None。"""
points:[t, t0, t0_min, t0_max, t1, t1_min, t1_max, fan, in_mv, in_ua, rails_mw];读不到的是 None。
rails_mw 是五路手机主板合计功率(mW),和输入功率 in_mv×in_ua 一比就是板上损耗。"""
span = max(1, t1 - t0)
raw_ok = t0 >= now_ms() - RAW_KEEP_S * 1000 and span <= 6 * 3600 * 1000
if raw_ok:
@@ -234,6 +235,12 @@ class Db:
"CAST(AVG(in_mv_avg) AS INTEGER) mv, CAST(AVG(in_ua_avg) AS INTEGER) ua FROM board_tele_min "
"WHERE board_id=? AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, t0, t1))
res = "minute"
# 五路主板合计功率(mW):每个时刻五路 mv×ua 求和,再按桶取均值(除以桶里的时刻数,不是行数)。
# 分钟聚合只有各路均值,拿 mv_avg×ua_avg 近似 —— 电压几乎不动,误差可以忽略。
src = ("mv*ua", "tele") if res == "raw" else ("mv_avg*ua_avg", "tele_min")
rails_mw = {r["t"]: r["p"] for r in self.q(
"SELECT ts/?*? AS t, CAST(SUM(%s)/1e6/COUNT(DISTINCT ts) AS INTEGER) p FROM %s "
"WHERE board_id=? AND ts>=? AND ts<? GROUP BY t" % src, (bucket, bucket, board, t0, t1))}
return {"resolution": res, "bucket_ms": bucket,
"points": [[r["t"], r["a0"], r["n0"], r["x0"], r["a1"], r["n1"], r["x1"], r["f"], r["mv"], r["ua"]]
for r in rows]}
"points": [[r["t"], r["a0"], r["n0"], r["x0"], r["a1"], r["n1"], r["x1"], r["f"], r["mv"], r["ua"],
rails_mw.get(r["t"])] for r in rows]}
+142 -140
查看文件
@@ -1,140 +1,142 @@
"""板侧协议在服务器这一侧的镜像(需求文档 §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 为换固件重启
}
# 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} # OTA 的失败 / 回滚在 api.events 里按 arg 单独标红
# 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 = 最终没开起来(电源键弹片接触不良)
}
# 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} # 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)