From 830b4cdff8d82d65c55318582b921f2826b5a05a Mon Sep 17 00:00:00 2001 From: zhou yang Date: Mon, 28 Sep 2026 08:39:47 +0800 Subject: [PATCH] =?UTF-8?q?rack=EF=BC=9A=E4=B8=BB=E6=9D=BF=20/=20=E5=BA=95?= =?UTF-8?q?=E6=9D=BF=E5=8A=9F=E7=8E=87=E4=B8=8E=E5=8A=9F=E7=8E=87=E6=9B=B2?= =?UTF-8?q?=E7=BA=BF=EF=BC=9B=E5=BA=95=E6=9D=BF=E6=9B=B2=E7=BA=BF=E5=8A=A0?= =?UTF-8?q?=E7=94=B5=E5=8E=8B=E7=94=B5=E6=B5=81=E3=80=81=E5=BC=80=E6=9C=BA?= =?UTF-8?q?=E9=87=8D=E6=8C=89=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每路主板功率(V×I):总览槽位、单板卡片、单路曲线新增「功率」一张 - 底板:单板页新增功率仪表(底板输入 / 五路主板合计 / 损耗),底板曲线加 功率图(输入 vs 主板合计);board_history 每点多回一列 rails_mw - 底板曲线改为纵向 N 张(温度 / 风扇 / 输入电压 / 输入电流 / 功率),断档断线 - LOG_EV_BOOT_CHECK(19)事件命名,只有「没开起来」标红 Co-Authored-By: Claude Opus 5.5 --- rack/backend/prrack/api.py | 6 +- rack/backend/prrack/db.py | 13 +- rack/backend/prrack/protocol.py | 282 ++++---- rack/backend/tests/test_api.py | 342 +++++----- rack/backend/tests/test_core.py | 607 +++++++++--------- rack/frontend/src/chart.js | 174 +++-- rack/frontend/src/components/BoardChart.vue | 245 +++---- rack/frontend/src/components/HistoryChart.vue | 62 +- rack/frontend/src/components/RailCard.vue | 4 +- rack/frontend/src/components/RangePicker.vue | 36 +- rack/frontend/src/pages/Board.vue | 340 +++++----- rack/frontend/src/pages/Overview.vue | 17 +- rack/frontend/src/theme.css | 273 ++++---- rack/frontend/src/util.js | 9 + 14 files changed, 1288 insertions(+), 1122 deletions(-) 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=? AND ts 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 @@ - - - - - + + + diff --git a/rack/frontend/src/components/HistoryChart.vue b/rack/frontend/src/components/HistoryChart.vue index b8003fd..ce081af 100644 --- a/rack/frontend/src/components/HistoryChart.vue +++ b/rack/frontend/src/components/HistoryChart.vue @@ -1,45 +1,58 @@ - - diff --git a/rack/frontend/src/components/RailCard.vue b/rack/frontend/src/components/RailCard.vue index 5786639..23eeeeb 100644 --- a/rack/frontend/src/components/RailCard.vue +++ b/rack/frontend/src/components/RailCard.vue @@ -10,7 +10,7 @@
-
{{ mA(r.ua) }}mA
+
{{ mA(r.ua) }}mA
功率 {{ W(r.mv, r.ua) }} W
{{ V(r.mv) }}V
设定 {{ V(r.set) }}
@@ -62,7 +62,7 @@ import { computed, ref, watch } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { ArrowDown } from '@element-plus/icons-vue' import { api, rspText } from '../api' -import { mA, V, railHealth, LEVEL_COLOR, RECOVERY_ZH } from '../util' +import { mA, V, W, railHealth, LEVEL_COLOR, RECOVERY_ZH } from '../util' const props = defineProps({ board: Object, rail: Number, selected: Boolean, recState: String }) defineEmits(['select']) diff --git a/rack/frontend/src/components/RangePicker.vue b/rack/frontend/src/components/RangePicker.vue index cb7ef33..9fe70f5 100644 --- a/rack/frontend/src/components/RangePicker.vue +++ b/rack/frontend/src/components/RangePicker.vue @@ -1,15 +1,21 @@ - - - + + + + + diff --git a/rack/frontend/src/pages/Board.vue b/rack/frontend/src/pages/Board.vue index 516f035..57a8e0a 100644 --- a/rack/frontend/src/pages/Board.vue +++ b/rack/frontend/src/pages/Board.vue @@ -1,156 +1,184 @@ - - - - - + + + + + diff --git a/rack/frontend/src/pages/Overview.vue b/rack/frontend/src/pages/Overview.vue index 2fe8189..0048c4e 100644 --- a/rack/frontend/src/pages/Overview.vue +++ b/rack/frontend/src/pages/Overview.vue @@ -35,6 +35,7 @@
{{ phone(b, i).label || ' ' }}
{{ health(b, i).text }}
@@ -44,6 +45,8 @@