diff --git a/rack/backend/prrack/api.py b/rack/backend/prrack/api.py
index 4886f44..863e29e 100644
--- a/rack/backend/prrack/api.py
+++ b/rack/backend/prrack/api.py
@@ -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
diff --git a/rack/backend/prrack/db.py b/rack/backend/prrack/db.py
index 7b2eba1..0397db0 100644
--- a/rack/backend/prrack/db.py
+++ b/rack/backend/prrack/db.py
@@ -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]}
diff --git a/rack/backend/prrack/protocol.py b/rack/backend/prrack/protocol.py
index b4b4cbe..d586462 100644
--- a/rack/backend/prrack/protocol.py
+++ b/rack/backend/prrack/protocol.py
@@ -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)
diff --git a/rack/backend/tests/test_api.py b/rack/backend/tests/test_api.py
index 031f1b6..e577e11 100644
--- a/rack/backend/tests/test_api.py
+++ b/rack/backend/tests/test_api.py
@@ -1,155 +1,187 @@
-"""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("rack", 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
+"""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("rack", 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
diff --git a/rack/backend/tests/test_core.py b/rack/backend/tests/test_core.py
index f069982..ade5b82 100644
--- a/rack/backend/tests/test_core.py
+++ b/rack/backend/tests/test_core.py
@@ -1,303 +1,304 @@
-"""协议镜像 + 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
-
-
-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")]
diff --git a/rack/frontend/src/chart.js b/rack/frontend/src/chart.js
index 462fb7c..482aafa 100644
--- a/rack/frontend/src/chart.js
+++ b/rack/frontend/src/chart.js
@@ -1,68 +1,106 @@
-// ECharts 的公共部分:按需引入 + 取色 + 两张上下叠放的图共用一条时间轴。
-//
-// 规矩(dataviz skill):
-// · 不用双 Y 轴 —— 电流和电压、温度和风扇,一律上下两个 grid,共用 x(dataZoom 联动);
-// · 系列色取参考调色板第 1/2 槽,按本页亮暗两种面板底色跑过 validate_palette.js(全部通过);
-// · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线;
-// · 线宽 2px,不画数据点;min~max 画成同色 15% 的带子。
-import * as echarts from 'echarts/core'
-import { LineChart } from 'echarts/charts'
-import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent } from 'echarts/components'
-import { CanvasRenderer } from 'echarts/renderers'
-import { cssVar, dark } from './theme'
-
-echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer])
-export { echarts }
-
-// 字体是打包进来异步下的,而 zrender 按「字体 → 文字」缓存宽度、从不清空:
-// 字形没到就量,会一直按回退字体的宽度排版(时间轴标签叠成一团,重画也救不回来)。
-// 所以每张图 init 之前先把它 canvas 上要用的字形下完:全部 ASCII + 轴名里的汉字。
-// 图例和 tooltip 是 DOM,字体到了自己会换,不用管。
-const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('')
-export function chartFonts(...texts) {
- if (!document.fonts) return Promise.resolve()
- const family = getComputedStyle(document.body).fontFamily
- return document.fonts.load(`12px ${family}`, ASCII + '°' + texts.join('')).catch(() => {})
-}
-
-const SERIES = { light: ['#2a78d6', '#eb6834'], dark: ['#3987e5', '#d95926'] }
-
-export function palette() {
- const s = SERIES[dark.value ? 'dark' : 'light']
- return {
- s1: s[0], s2: s[1],
- ink: cssVar('--ink'), ink2: cssVar('--ink-2'), muted: cssVar('--muted'),
- grid: cssVar('--line-soft'), axis: cssVar('--line'), panel: cssVar('--panel'), bay: cssVar('--bay'),
- }
-}
-
-export function band(hex, a = 0.15) {
- const n = parseInt(hex.slice(1), 16)
- return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`
-}
-
-// 上下两块:上面主图(高),下面副图(矮)。返回 grid / xAxis / dataZoom 的公共配置。
-export function stacked(p, { topName, bottomName, topFmt, bottomFmt, bottomMin, bottomMax, topMin, topMax, bottomInterval }) {
- const axisLabel = { color: p.muted, fontSize: 11 }
- const x = i => ({
- type: 'time', gridIndex: i, axisLine: { lineStyle: { color: p.axis } }, axisTick: { show: false },
- axisLabel: { ...axisLabel, show: i === 1, hideOverlap: true }, splitLine: { show: false },
- })
- const y = (i, name, fmt, min, max, interval) => ({
- type: 'value', gridIndex: i, name, nameTextStyle: { color: p.muted, fontSize: 11, align: 'left', padding: [0, 0, 0, -30] },
- scale: true, min, max, interval, splitNumber: i === 0 ? 4 : 2, axisLabel: { ...axisLabel, formatter: fmt },
- splitLine: { lineStyle: { color: p.grid } }, axisLine: { show: false }, axisTick: { show: false },
- })
- return {
- animation: false,
- textStyle: { fontFamily: getComputedStyle(document.body).fontFamily },
- grid: [{ left: 48, right: 16, top: 30, height: '48%' }, { left: 48, right: 16, top: '75%', bottom: 44 }],
- xAxis: [x(0), x(1)],
- yAxis: [y(0, topName, topFmt, topMin, topMax), y(1, bottomName, bottomFmt, bottomMin, bottomMax, bottomInterval)],
- axisPointer: { link: [{ xAxisIndex: 'all' }], lineStyle: { color: p.muted } },
- dataZoom: [{ type: 'inside', xAxisIndex: [0, 1] },
- { type: 'slider', xAxisIndex: [0, 1], height: 16, bottom: 8, borderColor: p.axis, fillerColor: band(p.s1, 0.12),
- dataBackground: { lineStyle: { color: p.axis }, areaStyle: { color: p.bay } }, textStyle: { color: p.muted } }],
- tooltip: { trigger: 'axis', backgroundColor: p.panel, borderColor: p.axis, textStyle: { color: p.ink, fontSize: 12 } },
- }
-}
+// ECharts 的公共部分:按需引入 + 取色 + 纵向叠放的几张小图共用一条时间轴。
+//
+// 规矩(dataviz skill):
+// · 不用双 Y 轴 —— 不同量纲一律各占一个 grid,纵向排、共用 x(dataZoom 联动);
+// · 系列色取参考调色板第 1/2 槽,按本页亮暗两种面板底色跑过 validate_palette.js(全部通过);
+// · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线;
+// · 线宽 2px,不画数据点;min~max 画成同色 15% 的带子。
+import * as echarts from 'echarts/core'
+import { LineChart } from 'echarts/charts'
+import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent } from 'echarts/components'
+import { CanvasRenderer } from 'echarts/renderers'
+import { cssVar, dark } from './theme'
+
+echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer])
+export { echarts }
+
+// 字体是打包进来异步下的,而 zrender 按「字体 → 文字」缓存宽度、从不清空:
+// 字形没到就量,会一直按回退字体的宽度排版(时间轴标签叠成一团,重画也救不回来)。
+// 所以每张图 init 之前先把它 canvas 上要用的字形下完:全部 ASCII + 轴名里的汉字。
+// 图例和 tooltip 是 DOM,字体到了自己会换,不用管。
+const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('')
+export function chartFonts(...texts) {
+ if (!document.fonts) return Promise.resolve()
+ const family = getComputedStyle(document.body).fontFamily
+ return document.fonts.load(`12px ${family}`, ASCII + '°' + texts.join('')).catch(() => {})
+}
+
+const SERIES = { light: ['#2a78d6', '#eb6834'], dark: ['#3987e5', '#d95926'] }
+
+export function palette() {
+ const s = SERIES[dark.value ? 'dark' : 'light']
+ return {
+ s1: s[0], s2: s[1],
+ ink: cssVar('--ink'), ink2: cssVar('--ink-2'), muted: cssVar('--muted'),
+ grid: cssVar('--line-soft'), axis: cssVar('--line'), panel: cssVar('--panel'), bay: cssVar('--bay'),
+ }
+}
+
+export function band(hex, a = 0.15) {
+ const n = parseInt(hex.slice(1), 16)
+ return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`
+}
+
+// 纵向排 N 张各自独立的小图(各有自己的 Y 轴和名字),共用一条时间轴、缩放联动。
+// panels:[{ name, fmt, min, max, interval, height }],height 是像素。返回 { option, height },
+// height 是整张画布该给的像素高度(容器要按它设高,不然 echarts 会挤或留白)。
+export function panels(p, defs, { gap = 44, top = 28, bottom = 64 } = {}) {
+ const axisLabel = { color: p.muted, fontSize: 11 }
+ const last = defs.length - 1
+ let y0 = top
+ const grid = defs.map(d => { const g = { left: 52, right: 16, top: y0, height: d.height }; y0 += d.height + gap; return g })
+ return {
+ height: y0 - gap + bottom,
+ option: {
+ animation: false,
+ textStyle: { fontFamily: getComputedStyle(document.body).fontFamily },
+ grid,
+ xAxis: defs.map((_, i) => ({
+ type: 'time', gridIndex: i, axisLine: { lineStyle: { color: p.axis } }, axisTick: { show: false },
+ axisLabel: { ...axisLabel, show: i === last, hideOverlap: true }, splitLine: { show: false },
+ })),
+ yAxis: defs.map((d, i) => ({
+ type: 'value', gridIndex: i, name: d.name,
+ nameTextStyle: { color: p.ink2, fontSize: 12, align: 'left', padding: [0, 0, 0, -34] },
+ scale: true, min: d.min, max: d.max, interval: d.interval, splitNumber: 2,
+ axisLabel: { ...axisLabel, formatter: d.fmt },
+ splitLine: { lineStyle: { color: p.grid } }, axisLine: { show: false }, axisTick: { show: false },
+ })),
+ axisPointer: { link: [{ xAxisIndex: 'all' }], lineStyle: { color: p.muted } },
+ // 缩放条:默认那根亮灰的拖动把手在暗色面板上太抢眼,全部压成面板线色,只让选中段带一点系列色
+ dataZoom: [{ type: 'inside', xAxisIndex: defs.map((_, i) => i) },
+ { type: 'slider', xAxisIndex: defs.map((_, i) => i), height: 18, bottom: 10, left: 52, right: 16,
+ borderColor: p.axis, backgroundColor: 'transparent', fillerColor: band(p.s1, 0.10), showDetail: false,
+ handleIcon: 'path://M4,0 h2 v18 h-2 z', handleSize: '100%', handleStyle: { color: p.muted, borderColor: p.muted },
+ moveHandleSize: 4, moveHandleStyle: { color: p.axis, opacity: 1 },
+ emphasis: { handleStyle: { color: p.ink2, borderColor: p.ink2 }, moveHandleStyle: { color: p.muted } },
+ dataBackground: { lineStyle: { color: p.axis, width: 1 }, areaStyle: { color: p.bay, opacity: 1 } },
+ selectedDataBackground: { lineStyle: { color: p.s1, width: 1, opacity: .5 }, areaStyle: { color: band(p.s1, 0.08) } },
+ textStyle: { color: p.muted } }],
+ tooltip: { trigger: 'axis', backgroundColor: p.panel, borderColor: p.axis, textStyle: { color: p.ink, fontSize: 12 } },
+ },
+ }
+}
+
+// 自动撑满数据,但保证至少 span 的跨度(别把噪声级的抖动放大成山峰),上下界吸附到 step 的整数倍:
+// 不吸附的话端点是 30.7 / 40.8 这种数,echarts 会在它旁边再标一个整刻度,两个标签叠在一起。
+export const atLeast = (span, step) => {
+ const k = 1 / step
+ const r = x => Math.round(x * 1e6) / 1e6
+ return {
+ min: v => r(Math.floor(Math.min(v.min, (v.min + v.max) / 2 - span / 2) * k) / k),
+ max: v => r(Math.ceil(Math.max(v.max, (v.min + v.max) / 2 + span / 2) * k) / k),
+ }
+}
+
+// 断档处插一行空值,让线在那里断开:否则板子离线半小时,echarts 会拿一条斜线把两头连起来,
+// 看着像是电流在这半小时里匀速变化。间隔超过 3 个桶就算断档。
+export function breakGaps(pts, step) {
+ const out = []
+ for (const x of pts) {
+ const prev = out[out.length - 1]
+ if (prev && x[0] - prev[0] > 3 * step) out.push([prev[0] + step, ...Array(x.length - 1).fill(null)])
+ out.push(x)
+ }
+ return out
+}
diff --git a/rack/frontend/src/components/BoardChart.vue b/rack/frontend/src/components/BoardChart.vue
index 4a2816a..596ad96 100644
--- a/rack/frontend/src/components/BoardChart.vue
+++ b/rack/frontend/src/components/BoardChart.vue
@@ -1,116 +1,129 @@
-
- {{ note }} {{ note }} {{ note }}底板温度
- 底板曲线
+ 第 {{ rail }} 路