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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-28 08:39:47 +08:00
共同撰写人 Claude Opus 5.5
父节点 1f6862ac38
当前提交 830b4cdff8
共修改 14 个文件,包含 1288 行新增和 1122 行删除
+5 -1
查看文件
@@ -110,7 +110,7 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
@app.get("/api/boards/{board}/history")
def board_history(board: str, start: int, end: Optional[int] = None):
"""整板曲线(温度 / 风扇 / 输入),见 Db.board_history。"""
"""整板曲线(温度 / 风扇 / 输入 / 五路主板合计功率),见 Db.board_history。"""
_board_or_404(hub, board)
return hub.db.board_history(board, start, end or now_ms())
@@ -188,6 +188,10 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
if d["arg"] == 3:
d["name"] += ":" + protocol.OTA_ERR_NAMES.get(d["mv"], str(d["mv"]))
d["severe"] = d["arg"] in (3, 5)
elif d["type"] == 19:
failed = bool(d["arg"] & protocol.BOOT_CHECK_FAILED)
d["name"] += "(按了 %d 次,%s)" % (d["arg"] & 0x0F, "没开起来" if failed else "开起来了")
d["severe"] = failed
out.append(d)
return out
+10 -3
查看文件
@@ -215,7 +215,8 @@ class Db:
def board_history(self, board: str, t0: int, t1: int, max_points: int = 1500) -> dict:
"""[t0, t1) 的整板曲线:两路 NTC 温度(m°C,带每桶 min/max)、风扇占空比(‰)、输入电压电流。
取数规则同 `history()`:近期短跨度回原始 1 s,否则回分钟聚合。
points:[t, t0, t0_min, t0_max, t1, t1_min, t1_max, fan, in_mv, in_ua];读不到的是 None。"""
points:[t, t0, t0_min, t0_max, t1, t1_min, t1_max, fan, in_mv, in_ua, rails_mw];读不到的是 None。
rails_mw 是五路手机主板合计功率(mW),和输入功率 in_mv×in_ua 一比就是板上损耗。"""
span = max(1, t1 - t0)
raw_ok = t0 >= now_ms() - RAW_KEEP_S * 1000 and span <= 6 * 3600 * 1000
if raw_ok:
@@ -234,6 +235,12 @@ class Db:
"CAST(AVG(in_mv_avg) AS INTEGER) mv, CAST(AVG(in_ua_avg) AS INTEGER) ua FROM board_tele_min "
"WHERE board_id=? AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, t0, t1))
res = "minute"
# 五路主板合计功率(mW):每个时刻五路 mv×ua 求和,再按桶取均值(除以桶里的时刻数,不是行数)。
# 分钟聚合只有各路均值,拿 mv_avg×ua_avg 近似 —— 电压几乎不动,误差可以忽略。
src = ("mv*ua", "tele") if res == "raw" else ("mv_avg*ua_avg", "tele_min")
rails_mw = {r["t"]: r["p"] for r in self.q(
"SELECT ts/?*? AS t, CAST(SUM(%s)/1e6/COUNT(DISTINCT ts) AS INTEGER) p FROM %s "
"WHERE board_id=? AND ts>=? AND ts<? GROUP BY t" % src, (bucket, bucket, board, t0, t1))}
return {"resolution": res, "bucket_ms": bucket,
"points": [[r["t"], r["a0"], r["n0"], r["x0"], r["a1"], r["n1"], r["x1"], r["f"], r["mv"], r["ua"]]
for r in rows]}
"points": [[r["t"], r["a0"], r["n0"], r["x0"], r["a1"], r["n1"], r["x1"], r["f"], r["mv"], r["ua"],
rails_mw.get(r["t"])] for r in rows]}
+142 -140
查看文件
@@ -1,140 +1,142 @@
"""板侧协议在服务器这一侧的镜像(需求文档 §10.8「A12 / A14 定稿」)。
改这里之前先看固件:
· 命令与错误码 —— firmware/main/cmd.h(数字冻结,只许追加)
· 事件类型 —— firmware/main/evlog.h 的 log_ev_t(数字就是 flash 格式,冻结)
· topic —— firmware/main/mlink.h
两边对不上时**不会报错**:事件会被显示成别的类型、命令会被板子回 unknown_cmd。
"""
import re
import time
from typing import Dict, Optional, Tuple
TOPIC_ROOT = "phonerail"
TOPIC_RE = re.compile(r"^phonerail/([0-9a-f]{12})/(cmd|rsp|tele|state)$")
RAILS = 5
# log_ev_t(firmware/main/evlog.h)。⚠️ 数字冻结。
EVENT_TYPES: Dict[int, Tuple[str, str]] = {
0: ("NONE", "(空槽位)"),
1: ("BOOT", "板子上电"),
2: ("LOST", "事件丢失"),
3: ("OVER_VOLT", "过压关路"),
4: ("UNDER_VOLT", "欠压关路"),
5: ("REVERSE", "反灌断 VBUS"),
6: ("PUSHED_UP", "顶高断 VBUS"),
7: ("EFUSE_FAULT", "eFuse 故障"),
8: ("PG_CHANGE", "PG 变化"),
9: ("I2C_LOST", "I²C 失联"),
10: ("THERMAL_DERATE", "过温降载"),
11: ("FAN_STALL", "风扇停转"),
12: ("REBOOT", "重启序列"),
13: ("CMD_REJECT", "命令被拒"),
14: ("MUX_LOST", "mux 失联全断"),
15: ("SLOT_EMPTY", "刀片不在位"),
16: ("PD_LIMIT", "PD 限功率"),
17: ("PG_TIMEOUT", "上电等不到 PG"), # A15:arg bit0 = FLT# 在报,bit1 = 按电压判
18: ("OTA", "固件升级"), # C2:arg 1 开始 / 2 就绪 / 3 失败(mV 列=原因) / 4 自证健康 / 5 回滚 / 6 为换固件重启
}
# LOG_EV_OTA 的 arg(firmware/main/ota.h 的 ota_ev_t)与失败原因(ota_err_t,失败那条的 mV 列)。⚠️ 冻结。
OTA_EV_NAMES = {1: "开始下载", 2: "已就绪", 3: "失败", 4: "新固件自证健康", 5: "回滚", 6: "为换固件重启",
7: "计划内重启:接管、没断电"}
OTA_ERR_NAMES = {1: "下载失败", 2: "大小不对", 3: "哈希不对", 4: "写 flash 失败", 5: "镜像校验不过",
6: "切启动分区失败", 7: "没有备用槽 / 新固件未自证"}
# 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。
EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14, 17} # OTA 的失败 / 回滚在 api.events 里按 arg 单独标红
# cmd_id_t(firmware/main/cmd.h),CMD_REJECT 事件的 arg 就是它。
CMD_NAMES = {1: "status", 2: "set_voltage", 3: "power_cycle", 4: "key", 5: "edl", 6: "vbus",
7: "fan", 8: "events", 9: "power", 10: "wifi", 11: "mqtt", 12: "net",
13: "ota"}
KEYS = ("pwr", "vup", "vdn")
KEY_MAX_MS = 15000
MV_MIN, MV_MAX = 3524, 4010
class CmdError(ValueError):
"""请求在发出去之前就被服务器拒了(参数不对)。"""
def _rail(v) -> int:
if not isinstance(v, int) or isinstance(v, bool) or not 1 <= v <= RAILS:
raise CmdError("rail 必须是 1~%d" % RAILS)
return v
def _bool(v, name: str) -> bool:
if not isinstance(v, bool):
raise CmdError("%s 必须是 true/false" % name)
return v
def build_command(req: dict) -> dict:
"""网页 / 自动恢复发来的请求 → 发给板子的命令(不含 seq)。
**白名单**:只放行管理界面该有的命令。`wifi` / `mqtt` 板子本来就只收 USB,
这里再拦一道,免得界面上出现一个永远失败的按钮;`events` 由服务器自己同步。
服务器这一侧先校验一遍,是为了让操作者立刻看到中文的错误,而不是等板子回 `range`;
**板子的校验仍然是唯一的权威**(SR-13 在固件里)。
"""
cmd = req.get("cmd")
if cmd == "status" or cmd == "net":
return {"cmd": cmd}
if cmd == "set_voltage":
mv = req.get("mv")
if not isinstance(mv, int) or isinstance(mv, bool) or not MV_MIN <= mv <= MV_MAX:
raise CmdError("电压必须是 %d~%d mV" % (MV_MIN, MV_MAX))
return {"cmd": cmd, "rail": _rail(req.get("rail")), "mv": mv}
if cmd in ("power_cycle", "edl"):
return {"cmd": cmd, "rail": _rail(req.get("rail"))}
if cmd == "key":
keys = req.get("keys")
if not isinstance(keys, str) or not keys or any(k not in KEYS for k in keys.split("+")):
raise CmdError("keys 只能是 pwr / vup / vdn 用 + 连起来")
ms = req.get("ms")
if not isinstance(ms, int) or isinstance(ms, bool) or not 1 <= ms <= KEY_MAX_MS:
raise CmdError("按键时长必须是 1~%d ms" % KEY_MAX_MS)
return {"cmd": cmd, "rail": _rail(req.get("rail")), "keys": keys, "ms": ms}
if cmd in ("vbus", "power"):
return {"cmd": cmd, "rail": _rail(req.get("rail")), "on": _bool(req.get("on"), "on")}
if cmd == "fan":
duty = req.get("duty")
if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000:
raise CmdError("风扇下限必须是 0~1000‰(0 = 自动)")
return {"cmd": cmd, "duty": duty}
raise CmdError("不支持的命令:%r" % (cmd,))
def is_mutating(cmd: str) -> bool:
return cmd not in ("status", "events", "net")
class SeqGen:
"""seq 生成器:以毫秒时间戳为底、进程内严格递增,落在 1~2^32−1。
板子的判重窗口按「值相等」判(cmd.h),不要求单调,只要求**不撞**:
服务重启之后从当前时间重新起步,不会撞上重启前刚用过的那几十个号。
"""
def __init__(self, clock=time.time):
self._clock = clock
self._last = 0
def next(self) -> int:
now = int(self._clock() * 1000)
v = max(self._last + 1, now)
self._last = v
return (v - 1) % 0xFFFFFFFF + 1
def parse_topic(topic: str) -> Optional[Tuple[str, str]]:
m = TOPIC_RE.match(topic)
return (m.group(1), m.group(2)) if m else None
def topic(board: str, kind: str) -> str:
return "%s/%s/%s" % (TOPIC_ROOT, board, kind)
"""板侧协议在服务器这一侧的镜像(需求文档 §10.8「A12 / A14 定稿」)。
改这里之前先看固件:
· 命令与错误码 —— firmware/main/cmd.h(数字冻结,只许追加)
· 事件类型 —— firmware/main/evlog.h 的 log_ev_t(数字就是 flash 格式,冻结)
· topic —— firmware/main/mlink.h
两边对不上时**不会报错**:事件会被显示成别的类型、命令会被板子回 unknown_cmd。
"""
import re
import time
from typing import Dict, Optional, Tuple
TOPIC_ROOT = "phonerail"
TOPIC_RE = re.compile(r"^phonerail/([0-9a-f]{12})/(cmd|rsp|tele|state)$")
RAILS = 5
# log_ev_t(firmware/main/evlog.h)。⚠️ 数字冻结。
EVENT_TYPES: Dict[int, Tuple[str, str]] = {
0: ("NONE", "(空槽位)"),
1: ("BOOT", "板子上电"),
2: ("LOST", "事件丢失"),
3: ("OVER_VOLT", "过压关路"),
4: ("UNDER_VOLT", "欠压关路"),
5: ("REVERSE", "反灌断 VBUS"),
6: ("PUSHED_UP", "顶高断 VBUS"),
7: ("EFUSE_FAULT", "eFuse 故障"),
8: ("PG_CHANGE", "PG 变化"),
9: ("I2C_LOST", "I²C 失联"),
10: ("THERMAL_DERATE", "过温降载"),
11: ("FAN_STALL", "风扇停转"),
12: ("REBOOT", "重启序列"),
13: ("CMD_REJECT", "命令被拒"),
14: ("MUX_LOST", "mux 失联全断"),
15: ("SLOT_EMPTY", "刀片不在位"),
16: ("PD_LIMIT", "PD 限功率"),
17: ("PG_TIMEOUT", "上电等不到 PG"), # A15:arg bit0 = FLT# 在报,bit1 = 按电压判
18: ("OTA", "固件升级"), # C2:arg 1 开始 / 2 就绪 / 3 失败(mV 列=原因) / 4 自证健康 / 5 回滚 / 6 为换固件重启
19: ("BOOT_CHECK", "开机重按"), # 0.1.2:arg bit0~3 = 按了几次,bit7 = 最终没开起来(电源键弹片接触不良)
}
# LOG_EV_OTA 的 arg(firmware/main/ota.h 的 ota_ev_t)与失败原因(ota_err_t,失败那条的 mV 列)。⚠️ 冻结。
OTA_EV_NAMES = {1: "开始下载", 2: "已就绪", 3: "失败", 4: "新固件自证健康", 5: "回滚", 6: "为换固件重启",
7: "计划内重启:接管、没断电"}
OTA_ERR_NAMES = {1: "下载失败", 2: "大小不对", 3: "哈希不对", 4: "写 flash 失败", 5: "镜像校验不过",
6: "切启动分区失败", 7: "没有备用槽 / 新固件未自证"}
# 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。
EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14, 17} # OTA 的失败 / 回滚、开机重按的「没开起来」在 api.events 里按 arg 单独标红
BOOT_CHECK_FAILED = 0x80 # LOG_EV_BOOT_CHECK 的 arg bit7
# cmd_id_t(firmware/main/cmd.h),CMD_REJECT 事件的 arg 就是它。
CMD_NAMES = {1: "status", 2: "set_voltage", 3: "power_cycle", 4: "key", 5: "edl", 6: "vbus",
7: "fan", 8: "events", 9: "power", 10: "wifi", 11: "mqtt", 12: "net",
13: "ota"}
KEYS = ("pwr", "vup", "vdn")
KEY_MAX_MS = 15000
MV_MIN, MV_MAX = 3524, 4010
class CmdError(ValueError):
"""请求在发出去之前就被服务器拒了(参数不对)。"""
def _rail(v) -> int:
if not isinstance(v, int) or isinstance(v, bool) or not 1 <= v <= RAILS:
raise CmdError("rail 必须是 1~%d" % RAILS)
return v
def _bool(v, name: str) -> bool:
if not isinstance(v, bool):
raise CmdError("%s 必须是 true/false" % name)
return v
def build_command(req: dict) -> dict:
"""网页 / 自动恢复发来的请求 → 发给板子的命令(不含 seq)。
**白名单**:只放行管理界面该有的命令。`wifi` / `mqtt` 板子本来就只收 USB,
这里再拦一道,免得界面上出现一个永远失败的按钮;`events` 由服务器自己同步。
服务器这一侧先校验一遍,是为了让操作者立刻看到中文的错误,而不是等板子回 `range`;
**板子的校验仍然是唯一的权威**(SR-13 在固件里)。
"""
cmd = req.get("cmd")
if cmd == "status" or cmd == "net":
return {"cmd": cmd}
if cmd == "set_voltage":
mv = req.get("mv")
if not isinstance(mv, int) or isinstance(mv, bool) or not MV_MIN <= mv <= MV_MAX:
raise CmdError("电压必须是 %d~%d mV" % (MV_MIN, MV_MAX))
return {"cmd": cmd, "rail": _rail(req.get("rail")), "mv": mv}
if cmd in ("power_cycle", "edl"):
return {"cmd": cmd, "rail": _rail(req.get("rail"))}
if cmd == "key":
keys = req.get("keys")
if not isinstance(keys, str) or not keys or any(k not in KEYS for k in keys.split("+")):
raise CmdError("keys 只能是 pwr / vup / vdn 用 + 连起来")
ms = req.get("ms")
if not isinstance(ms, int) or isinstance(ms, bool) or not 1 <= ms <= KEY_MAX_MS:
raise CmdError("按键时长必须是 1~%d ms" % KEY_MAX_MS)
return {"cmd": cmd, "rail": _rail(req.get("rail")), "keys": keys, "ms": ms}
if cmd in ("vbus", "power"):
return {"cmd": cmd, "rail": _rail(req.get("rail")), "on": _bool(req.get("on"), "on")}
if cmd == "fan":
duty = req.get("duty")
if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000:
raise CmdError("风扇下限必须是 0~1000‰(0 = 自动)")
return {"cmd": cmd, "duty": duty}
raise CmdError("不支持的命令:%r" % (cmd,))
def is_mutating(cmd: str) -> bool:
return cmd not in ("status", "events", "net")
class SeqGen:
"""seq 生成器:以毫秒时间戳为底、进程内严格递增,落在 1~2^32−1。
板子的判重窗口按「值相等」判(cmd.h),不要求单调,只要求**不撞**:
服务重启之后从当前时间重新起步,不会撞上重启前刚用过的那几十个号。
"""
def __init__(self, clock=time.time):
self._clock = clock
self._last = 0
def next(self) -> int:
now = int(self._clock() * 1000)
v = max(self._last + 1, now)
self._last = v
return (v - 1) % 0xFFFFFFFF + 1
def parse_topic(topic: str) -> Optional[Tuple[str, str]]:
m = TOPIC_RE.match(topic)
return (m.group(1), m.group(2)) if m else None
def topic(board: str, kind: str) -> str:
return "%s/%s/%s" % (TOPIC_ROOT, board, kind)
+187 -155
查看文件
@@ -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("<html>rack</html>", encoding="utf-8")
async def up():
await hub.start()
sim.start()
sim.tick(now_ms())
app = create_app(hub, rec, str(dist), startup=[up], shutdown=[hub.stop])
with TestClient(app) as c:
deadline = time.time() + 2
while B not in hub.latest and time.time() < deadline:
time.sleep(0.01)
yield c, hub, sim, db
def test_boards_and_snapshot(env):
c, hub, sim, db = env
boards = c.get("/api/boards").json()
assert [b["id"] for b in boards] == [B]
assert boards[0]["online"] is True and len(boards[0]["phones"]) == 5
assert c.get("/api/boards/nope").status_code == 404
def test_command_records_the_portal_user(env):
c, hub, sim, db = env
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": False},
headers={"X-Auth-User": "zhouyang"})
assert r.status_code == 200 and r.json()["ok"] is True
assert sim.rails[0].en is False
a = c.get("/api/audit").json()[0]
assert a["user"] == "zhouyang" and a["request"]["cmd"] == "power" and a["ok"] == 1
# 没有门户头(服务器本机直接 curl)记作 local
c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": True})
assert c.get("/api/audit").json()[0]["user"] == "local"
def test_bad_commands_never_reach_the_board(env):
c, hub, sim, db = env
n = len(sim.received)
for body in ({"cmd": "wifi", "ssid": "x"}, {"cmd": "set_voltage", "rail": 1, "mv": 4300},
{"cmd": "key", "rail": 1, "keys": "pwr", "ms": 60000}, {"cmd": "rm -rf"}):
r = c.post("/api/boards/%s/cmd" % B, json=body)
assert r.status_code == 400, body
assert len(sim.received) == n
def test_board_rejection_is_passed_through(env):
c, hub, sim, db = env
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power_cycle", "rail": 4})
assert r.status_code == 200 and r.json()["err"] == "empty"
def test_timeout_is_504(env):
c, hub, sim, db = env
sim.drop_next_replies = 99
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 2, "on": True})
assert r.status_code == 504
def test_phone_settings_toggle_auto_recovery(env):
c, hub, sim, db = env
r = c.patch("/api/boards/%s/rails/3" % B, json={"label": "A-03", "auto_recover": True})
assert r.status_code == 200
ph = [p for p in r.json()["phones"] if p["rail"] == 3][0]
assert ph["label"] == "A-03" and ph["auto_recover"] == 1
rec = c.get("/api/recovery").json()
assert rec["params"]["window_s"] == 300
r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1})
assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 5000 # 负数不收
def test_history_after_telemetry(env):
c, hub, sim, db = env
for _ in range(3):
sim.tick(now_ms())
time.sleep(0.1)
hub.flush()
h = c.get("/api/boards/%s/rails/1/history" % B, params={"start": now_ms() - 60000}).json()
assert h["resolution"] == "raw" and len(h["points"]) >= 1
assert h["points"][0][2] is not None
def test_websocket_hello_and_live_tele(env):
c, hub, sim, db = env
with c.websocket_connect("/api/ws", headers={"X-Auth-User": "zhouyang"}) as ws:
hello = ws.receive_json()
assert hello["t"] == "hello" and hello["user"] == "zhouyang" and hello["boards"][0]["id"] == B
sim.tick(now_ms())
msg = ws.receive_json()
while msg["t"] != "tele":
msg = ws.receive_json()
assert msg["board"] == B and len(msg["status"]["rails"]) == 5
def test_spa_fallback_is_not_cached(env):
c, hub, sim, db = env
r = c.get("/some/deep/route")
assert r.status_code == 200 and "rack" in r.text
assert r.headers["cache-control"] == "no-cache"
def test_a_real_websocket_library_is_installed():
"""TestClient 自带 WS 实现,上面那条 WS 用例在没装 websockets 的机器上也是绿的;
而真 uvicorn 缺了它会把 /api/ws 当普通 GET(落到 SPA 兜底,回 200 HTML)。
deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。"""
from prrack.main import ws_library_ok
assert ws_library_ok()
def test_board_history_raw_and_minute(env):
c, hub, sim, db = env
base = now_ms() // 60000 * 60000 - 3 * 60000
rows = [(base + i * 1000, B, 12000, 1500000, 35000 + i, 40000 - i, 300) for i in range(120)]
db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)", rows)
h = c.get("/api/boards/%s/history" % B, params={"start": base, "end": base + 120000}).json()
assert h["resolution"] == "raw" and len(h["points"]) >= 100
p = h["points"][0]
assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000
# 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾
assert db.downsample(base + 180000) > 0
m = db.board_history(B, base, base + 120000 + 1) # 跨度小也能强制走聚合:t0 早于原始保留期时
rows_min = db.q("SELECT * FROM board_tele_min WHERE board_id=? ORDER BY ts", (B,))
assert [r["t0_min"] for r in rows_min][:2] == [35000, 35060]
assert [r["t0_max"] for r in rows_min][:2] == [35059, 35119]
assert rows_min[0]["t1_min"] == 40000 - 59 and rows_min[0]["fan_avg"] == 300
assert m["resolution"] == "raw"
old = db.board_history(B, base - 10 * 86400000, base + 120000)
# 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大
assert old["resolution"] == "minute" and old["points"][0][2] == 35000
assert max(p[3] for p in old["points"]) == 35119
assert c.get("/api/boards/nope/history", params={"start": 0}).status_code == 404
"""HTTP / WebSocket 接口:用 TestClient 起完整的应用,Hub 经进程内假 broker 连模拟板子。"""
import time
import pytest
from fastapi.testclient import TestClient
from prrack.api import create_app
from prrack.bus import MemBroker, MemBus
from prrack.db import Db, now_ms
from prrack.hub import Hub
from prrack.recovery import RecoveryManager
from prrack.simboard import SimBoard
B = "b81f3fc9c8bc"
@pytest.fixture
def env(tmp_path):
broker = MemBroker()
db = Db(str(tmp_path / "a.db"))
hub = Hub(db, MemBus(broker), attempt_timeout=0.2)
rec = RecoveryManager(hub, now_ms)
sim = SimBoard(MemBus(broker), B, present=(True, True, True, False, True))
dist = tmp_path / "dist"
(dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text("<html>rack</html>", encoding="utf-8")
async def up():
await hub.start()
sim.start()
sim.tick(now_ms())
app = create_app(hub, rec, str(dist), startup=[up], shutdown=[hub.stop])
with TestClient(app) as c:
deadline = time.time() + 2
while B not in hub.latest and time.time() < deadline:
time.sleep(0.01)
yield c, hub, sim, db
def test_boards_and_snapshot(env):
c, hub, sim, db = env
boards = c.get("/api/boards").json()
assert [b["id"] for b in boards] == [B]
assert boards[0]["online"] is True and len(boards[0]["phones"]) == 5
assert c.get("/api/boards/nope").status_code == 404
def test_command_records_the_portal_user(env):
c, hub, sim, db = env
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": False},
headers={"X-Auth-User": "zhouyang"})
assert r.status_code == 200 and r.json()["ok"] is True
assert sim.rails[0].en is False
a = c.get("/api/audit").json()[0]
assert a["user"] == "zhouyang" and a["request"]["cmd"] == "power" and a["ok"] == 1
# 没有门户头(服务器本机直接 curl)记作 local
c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": True})
assert c.get("/api/audit").json()[0]["user"] == "local"
def test_bad_commands_never_reach_the_board(env):
c, hub, sim, db = env
n = len(sim.received)
for body in ({"cmd": "wifi", "ssid": "x"}, {"cmd": "set_voltage", "rail": 1, "mv": 4300},
{"cmd": "key", "rail": 1, "keys": "pwr", "ms": 60000}, {"cmd": "rm -rf"}):
r = c.post("/api/boards/%s/cmd" % B, json=body)
assert r.status_code == 400, body
assert len(sim.received) == n
def test_board_rejection_is_passed_through(env):
c, hub, sim, db = env
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power_cycle", "rail": 4})
assert r.status_code == 200 and r.json()["err"] == "empty"
def test_timeout_is_504(env):
c, hub, sim, db = env
sim.drop_next_replies = 99
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 2, "on": True})
assert r.status_code == 504
def test_phone_settings_toggle_auto_recovery(env):
c, hub, sim, db = env
r = c.patch("/api/boards/%s/rails/3" % B, json={"label": "A-03", "auto_recover": True})
assert r.status_code == 200
ph = [p for p in r.json()["phones"] if p["rail"] == 3][0]
assert ph["label"] == "A-03" and ph["auto_recover"] == 1
rec = c.get("/api/recovery").json()
assert rec["params"]["window_s"] == 300
r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1})
assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 5000 # 负数不收
def test_history_after_telemetry(env):
c, hub, sim, db = env
for _ in range(3):
sim.tick(now_ms())
time.sleep(0.1)
hub.flush()
h = c.get("/api/boards/%s/rails/1/history" % B, params={"start": now_ms() - 60000}).json()
assert h["resolution"] == "raw" and len(h["points"]) >= 1
assert h["points"][0][2] is not None
def test_websocket_hello_and_live_tele(env):
c, hub, sim, db = env
with c.websocket_connect("/api/ws", headers={"X-Auth-User": "zhouyang"}) as ws:
hello = ws.receive_json()
assert hello["t"] == "hello" and hello["user"] == "zhouyang" and hello["boards"][0]["id"] == B
sim.tick(now_ms())
msg = ws.receive_json()
while msg["t"] != "tele":
msg = ws.receive_json()
assert msg["board"] == B and len(msg["status"]["rails"]) == 5
def test_spa_fallback_is_not_cached(env):
c, hub, sim, db = env
r = c.get("/some/deep/route")
assert r.status_code == 200 and "rack" in r.text
assert r.headers["cache-control"] == "no-cache"
def test_a_real_websocket_library_is_installed():
"""TestClient 自带 WS 实现,上面那条 WS 用例在没装 websockets 的机器上也是绿的;
而真 uvicorn 缺了它会把 /api/ws 当普通 GET(落到 SPA 兜底,回 200 HTML)。
deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。"""
from prrack.main import ws_library_ok
assert ws_library_ok()
def test_board_history_raw_and_minute(env):
c, hub, sim, db = env
base = now_ms() // 60000 * 60000 - 3 * 60000
rows = [(base + i * 1000, B, 12000, 1500000, 35000 + i, 40000 - i, 300) for i in range(120)]
db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)", rows)
h = c.get("/api/boards/%s/history" % B, params={"start": base, "end": base + 120000}).json()
assert h["resolution"] == "raw" and len(h["points"]) >= 100
p = h["points"][0]
assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000
# 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾
assert db.downsample(base + 180000) > 0
m = db.board_history(B, base, base + 120000 + 1) # 跨度小也能强制走聚合:t0 早于原始保留期时
rows_min = db.q("SELECT * FROM board_tele_min WHERE board_id=? ORDER BY ts", (B,))
assert [r["t0_min"] for r in rows_min][:2] == [35000, 35060]
assert [r["t0_max"] for r in rows_min][:2] == [35059, 35119]
assert rows_min[0]["t1_min"] == 40000 - 59 and rows_min[0]["fan_avg"] == 300
assert m["resolution"] == "raw"
old = db.board_history(B, base - 10 * 86400000, base + 120000)
# 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大
assert old["resolution"] == "minute" and old["points"][0][2] == 35000
assert max(p[3] for p in old["points"]) == 35119
assert c.get("/api/boards/nope/history", params={"start": 0}).status_code == 404
def test_boot_check_event_is_named_and_only_the_failure_is_severe(env):
# 固件 0.1.2 LOG_EV_BOOT_CHECK:arg 低 4 位 = 按了几次,bit7 = 最终没开起来
c, hub, sim, db = env
now = now_ms()
db.many("INSERT INTO events (board_id, seq, t_ms, rail, type, arg, mv, ua, received) VALUES (?,?,?,?,?,?,?,?,?)",
[(B, 9001, 1000, 1, 19, 2, 3931, 210000, now),
(B, 9002, 2000, 1, 19, 0x83, 3931, 1500, now + 1)])
ev = {e["seq"]: e for e in c.get("/api/events", params={"board": B}).json()}
assert ev[9001]["code"] == "BOOT_CHECK" and ev[9001]["severe"] is False
assert "按了 2 次,开起来了" in ev[9001]["name"]
assert ev[9002]["severe"] is True and "按了 3 次,没开起来" in ev[9002]["name"]
def test_board_history_sums_rail_power(env):
# 五路主板合计功率:同一时刻各路 mv×ua 求和再按桶平均;没有主板遥测的时刻是 None
c, hub, sim, db = env
base = now_ms() // 60000 * 60000 - 3 * 60000
db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)",
[(base + i * 1000, B, 12000, 1000000, 35000, 35000, 0) for i in range(120)])
# 前 60 s 有两路:4.0 V × 500 mA + 3.9 V × 1 A = 5.9 W;后 60 s 没有主板数据
db.many("INSERT INTO tele VALUES (?,?,?,?,?,?,?,?,?,?)",
[(base + i * 1000, B, r, mv, ua, 1, 1, 1, 1, 0)
for i in range(60) for r, mv, ua in ((1, 4000, 500000), (2, 3900, 1000000))])
h = db.board_history(B, base, base + 120000)
assert h["resolution"] == "raw"
assert {p[10] for p in h["points"] if p[0] < base + 60000} == {5900}
assert {p[10] for p in h["points"] if p[0] >= base + 60000} == {None}
assert db.downsample(base + 180000) > 0
old = db.board_history(B, base - 10 * 86400000, base + 120000)
assert old["resolution"] == "minute" and old["points"][0][10] == 5900
+304 -303
查看文件
@@ -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")]
+106 -68
查看文件
@@ -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
}
+129 -116
查看文件
@@ -1,116 +1,129 @@
<template>
<section class="card bc">
<div class="hh">
<h2 class="ttl">底板温度</h2>
<RangePicker v-model="range" />
</div>
<div class="legend" aria-hidden="true">
<span><i :style="{ background: c.s1 }" />NTC1 Hub 稳压旁(U31)</span>
<span><i :style="{ background: c.s2 }" />NTC2 输入降压旁(U37)</span>
</div>
<div ref="el" class="chart" role="img" aria-label="底板两路温度与风扇占空比曲线" />
<p class="muted note">{{ note }}</p>
</section>
</template>
<script setup>
// 底板两路 NTC 温度(上)+ 风扇占空比(下)。温度两条线用 dataviz 参考调色板第 1/2 槽,
// 亮暗两种面板底色都跑过 validate_palette.js(CVD ΔE ≥ 24,对比 ≥ 3:1)。
// 图例用自绘的一行(带位置说明),曲线末端不再直接标名:两条线常常贴得很近,标签会打架。
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import RangePicker from './RangePicker.vue'
import { api } from '../api'
import { onMessage } from '../store'
import { dark } from '../theme'
import { echarts, palette, band, stacked, chartFonts } from '../chart'
const props = defineProps({ board: String })
const range = ref(3600e3)
const el = ref(null)
const note = ref('')
const c = ref(palette())
let chart = null
let pts = [] // [t, t0, t0min, t0max, t1, t1min, t1max, fan, in_mv, in_ua]
let live = false
const degC = v => (v == null ? null : v / 1000)
function render() {
if (!chart) return // 还在等字体
const p = palette()
c.value = p
const hasBand = pts.some(x => x[2] != null && x[2] !== x[3])
const bandOf = (lo, hi, color) => [
{ type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], degC(x[lo])]), stack: `b${lo}`,
symbol: 'none', lineStyle: { opacity: 0 }, silent: true, tooltip: { show: false } },
{ type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], x[hi] == null || x[lo] == null ? null : (x[hi] - x[lo]) / 1000]),
stack: `b${lo}`, symbol: 'none', lineStyle: { opacity: 0 }, areaStyle: { color: band(color, 0.12) }, silent: true, tooltip: { show: false } },
]
const fmtC = v => (v == null ? '—' : `${v.toFixed(1)} °C`)
chart.setOption({
...stacked(p, {
topName: '温度 °C', bottomName: '风扇 %',
topFmt: v => v.toFixed(0), bottomFmt: v => v.toFixed(0), bottomMin: 0, bottomMax: 100, bottomInterval: 50,
// 温度按数据撑满,但至少 10 °C 的跨度:别把 0.2 °C 的抖动放大成山峰
topMin: v => Math.floor(Math.min(v.min, (v.min + v.max) / 2 - 5)), topMax: v => Math.ceil(Math.max(v.max, (v.min + v.max) / 2 + 5)),
}),
series: [
...(hasBand ? [...bandOf(2, 3, p.s1), ...bandOf(5, 6, p.s2)] : []),
{ name: 'NTC1', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], degC(x[1])]), symbol: 'none',
lineStyle: { width: 2 }, color: p.s1, tooltip: { valueFormatter: fmtC } },
{ name: 'NTC2', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], degC(x[4])]), symbol: 'none',
lineStyle: { width: 2 }, color: p.s2, tooltip: { valueFormatter: fmtC } },
{ name: '风扇', type: 'line', xAxisIndex: 1, yAxisIndex: 1, step: 'end', data: pts.map(x => [x[0], x[7] == null ? null : x[7] / 10]),
symbol: 'none', lineStyle: { width: 2 }, color: p.ink2, tooltip: { valueFormatter: v => (v == null ? '—' : `${v.toFixed(0)} %`) } },
],
}, true)
}
async function load() {
const end = Date.now()
try {
const h = await api.boardHistory(props.board, end - range.value, end)
pts = h.points
live = h.resolution === 'raw' && range.value <= 3600e3
note.value = pts.length === 0 ? '这段时间没有温度数据(板子离线,或者刚上线)'
: h.resolution === 'raw' ? `每秒一个点${live ? ',实时追加' : ''}`
: '每分钟一个点;浅色带是这一分钟里的最低到最高温度'
render()
} catch (e) {
note.value = '曲线没取到:' + e.message
}
}
const off = onMessage(m => {
if (!live || m.t !== 'tele' || m.board !== props.board) return
const s = m.status
const t = s.temp || []
pts.push([m.ts, t[0], t[0], t[0], t[1], t[1], t[1], s.fan?.duty, s.in?.mv, s.in?.ua])
const cut = Date.now() - range.value
while (pts.length && pts[0][0] < cut) pts.shift()
render()
})
const onResize = () => chart && chart.resize()
let gone = false
onMounted(async () => {
window.addEventListener('resize', onResize)
await chartFonts('温度 °C', '风扇 %')
if (gone) return
chart = echarts.init(el.value)
load()
})
onBeforeUnmount(() => { gone = true; off(); window.removeEventListener('resize', onResize); chart && chart.dispose() })
watch(() => [props.board, range.value], load)
watch(dark, () => chart && render())
</script>
<style scoped>
.hh { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
.hh .ttl { margin: 0; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 13px; color: var(--ink-2); margin-top: 8px; }
.legend i { display: inline-block; width: 14px; height: 3px; border-radius: 2px; margin-right: 6px; vertical-align: middle; }
.chart { height: 320px; }
.note { margin: 4px 0 0; }
@media (max-width: 520px) { .chart { height: 260px; } }
</style>
<template>
<section class="card chart-card">
<div class="hh">
<h2 class="ttl">底板曲线</h2>
<RangePicker v-model="range" />
</div>
<div class="legend" aria-hidden="true">
<span><i :style="{ background: c.s1 }" />NTC1 Hub 稳压旁(U31)</span>
<span><i :style="{ background: c.s2 }" />NTC2 输入降压旁(U37)</span>
</div>
<div class="legend" aria-hidden="true">
<span><i :style="{ background: c.s1 }" />功率:底板输入</span>
<span><i :style="{ background: c.s2 }" />五路主板合计(两线之差 ≈ 板上损耗)</span>
</div>
<div ref="el" class="chart" :style="{ height: h + 'px' }" role="img"
aria-label="底板温度、风扇占空比、输入电压、输入电流、功率五张曲线" />
<p class="muted note">{{ note }}</p>
</section>
</template>
<script setup>
// 底板五张图纵向排:两路 NTC 温度 / 风扇占空比 / 12 V 输入电压 / 输入电流 / 功率(底板输入 vs 五路主板合计)。
// 各自独立的 Y 轴(不同量纲绝不共轴),共用时间轴、缩放和十字线联动。
// 温度两条线用 dataviz 参考调色板第 1/2 槽,亮暗两种面板底色都跑过 validate_palette.js(CVD ΔE ≥ 24,对比 ≥ 3:1);
// 电压电流各只有一条线,用第 1 槽;风扇沿用次墨色;功率两条线同样按第 1/2 槽(输入 / 主板),另起一行图例。
// 图例用自绘的一行(带位置说明),曲线末端不再直接标名:两条温度线常常贴得很近,标签会打架。
import { onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
import RangePicker from './RangePicker.vue'
import { api } from '../api'
import { onMessage } from '../store'
import { railsMw } from '../util'
import { dark } from '../theme'
import { echarts, palette, band, panels, atLeast, chartFonts, breakGaps } from '../chart'
const props = defineProps({ board: String })
const range = ref(3600e3)
const el = ref(null)
const note = ref('')
const c = ref(palette())
const h = ref(560)
let chart = null
let raw = [] // [t, t0, t0min, t0max, t1, t1min, t1max, fan, in_mv, in_ua, rails_mw]
let live = false
let step = 1000 // 每个点代表的毫秒数,判断断档用
const degC = v => (v == null ? null : v / 1000)
const NAMES = ['温度 °C', '风扇 %', '输入电压 V', '输入电流 A', '功率 W']
function render() {
if (!chart) return // 还在等字体
const p = palette()
c.value = p
const pts = breakGaps(raw, step)
const hasBand = pts.some(x => x[2] != null && x[2] !== x[3])
const bandOf = (lo, hi, color) => [
{ type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], degC(x[lo])]), stack: `b${lo}`,
symbol: 'none', lineStyle: { opacity: 0 }, silent: true, tooltip: { show: false } },
{ type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], x[hi] == null || x[lo] == null ? null : (x[hi] - x[lo]) / 1000]),
stack: `b${lo}`, symbol: 'none', lineStyle: { opacity: 0 }, areaStyle: { color: band(color, 0.12) }, silent: true, tooltip: { show: false } },
]
const unit = (d, u) => v => (v == null ? '—' : `${v.toFixed(d)} ${u}`)
const line = (i, name, color, data, fmt, extra = {}) => ({
name, type: 'line', xAxisIndex: i, yAxisIndex: i, data, symbol: 'none',
lineStyle: { width: 2 }, color, tooltip: { valueFormatter: fmt }, ...extra,
})
const L = panels(p, [
// 温度至少 10 °C 的跨度;输入电压至少 0.5 V、电流至少 0.5 A:别把 ADC 的抖动放大成山峰
{ name: NAMES[0], height: 130, fmt: v => v.toFixed(0), ...atLeast(10, 5), interval: 5 },
{ name: NAMES[1], height: 64, fmt: v => v.toFixed(0), min: 0, max: 100, interval: 50 },
{ name: NAMES[2], height: 90, fmt: v => v.toFixed(1), ...atLeast(0.5, 0.1) },
{ name: NAMES[3], height: 90, fmt: v => v.toFixed(1), ...atLeast(0.5, 0.1) },
// 功率从 0 起:两条线之间的缝就是损耗,要看得出比例
{ name: NAMES[4], height: 120, fmt: v => v.toFixed(0), min: 0 },
])
if (h.value !== L.height) { h.value = L.height; nextTick(() => chart && chart.resize()) }
chart.setOption({
...L.option,
series: [
...(hasBand ? [...bandOf(2, 3, p.s1), ...bandOf(5, 6, p.s2)] : []),
line(0, 'NTC1', p.s1, pts.map(x => [x[0], degC(x[1])]), unit(1, '°C')),
line(0, 'NTC2', p.s2, pts.map(x => [x[0], degC(x[4])]), unit(1, '°C')),
line(1, '风扇', p.ink2, pts.map(x => [x[0], x[7] == null ? null : x[7] / 10]), unit(0, '%'), { step: 'end' }),
line(2, '输入电压', p.s1, pts.map(x => [x[0], x[8] == null ? null : x[8] / 1000]), unit(2, 'V')),
line(3, '输入电流', p.s1, pts.map(x => [x[0], x[9] == null ? null : x[9] / 1e6]), unit(2, 'A')),
line(4, '底板输入', p.s1, pts.map(x => [x[0], x[8] == null || x[9] == null ? null : x[8] * x[9] / 1e9]), unit(2, 'W')),
line(4, '主板合计', p.s2, pts.map(x => [x[0], x[10] == null ? null : x[10] / 1000]), unit(2, 'W')),
],
}, true)
}
async function load() {
const end = Date.now()
try {
const r = await api.boardHistory(props.board, end - range.value, end)
raw = r.points
step = r.bucket_ms || 1000
live = r.resolution === 'raw' && range.value <= 3600e3
note.value = raw.length === 0 ? '这段时间没有底板数据(板子离线,或者刚上线)'
: r.resolution === 'raw' ? `每秒一个点${live ? ',实时追加' : ''}`
: '每分钟一个点;浅色带是这一分钟里的最低到最高温度(电压电流只存了分钟均值)'
render()
} catch (e) {
note.value = '曲线没取到:' + e.message
}
}
const off = onMessage(m => {
if (!live || m.t !== 'tele' || m.board !== props.board) return
const s = m.status
const t = s.temp || []
raw.push([m.ts, t[0], t[0], t[0], t[1], t[1], t[1], s.fan?.duty, s.in?.mv, s.in?.ua, railsMw(s.rails)])
const cut = Date.now() - range.value
while (raw.length && raw[0][0] < cut) raw.shift()
render()
})
const onResize = () => chart && chart.resize()
let gone = false
onMounted(async () => {
window.addEventListener('resize', onResize)
await chartFonts(...NAMES)
if (gone) return
chart = echarts.init(el.value)
load()
})
onBeforeUnmount(() => { gone = true; off(); window.removeEventListener('resize', onResize); chart && chart.dispose() })
watch(() => [props.board, range.value], load)
watch(dark, () => chart && render())
</script>
+35 -27
查看文件
@@ -1,45 +1,58 @@
<template>
<section class="card hc">
<section class="card chart-card">
<div class="hh">
<h2 class="ttl">第 {{ rail }} 路</h2>
<RangePicker v-model="range" />
</div>
<div ref="el" class="chart" role="img" :aria-label="`第 ${rail} 路电流与电压曲线`" />
<div class="legend" aria-hidden="true">
<span><i :style="{ background: c.s1 }" />电流 · 功率 · 电压</span>
<span class="hint">点上面的卡片切换是哪一路</span>
</div>
<div ref="el" class="chart" :style="{ height: h + 'px' }" role="img" :aria-label="`第 ${rail} 路电流、功率与电压曲线`" />
<p class="muted note">{{ note }}</p>
</section>
</template>
<script setup>
// 一路的电流(上)与电压(下)。**不用双 Y 轴**:3.9 V 上的几十毫伏起伏和几百 mA 的电流
// 放在一张图的两个刻度上,谁也看不清(dataviz 头号反模式)。上下两块共用时间轴,缩放联动。
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
// 一路的电流(上)、主板功率(中,= 电压 × 电流)与电压(下)。**不用双 Y 轴**:3.9 V 上的几十毫伏起伏和几百 mA 的电流
// 放在一张图的两个刻度上,谁也看不清(dataviz 头号反模式)。三块共用时间轴,缩放联动。
// 功率由每个点的均值电压 × 均值电流算出来(分钟聚合时是近似,电压几乎不动,误差可忽略)。
import { onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
import RangePicker from './RangePicker.vue'
import { api } from '../api'
import { onMessage } from '../store'
import { dark } from '../theme'
import { echarts, palette, band, stacked, chartFonts } from '../chart'
import { echarts, palette, band, panels, chartFonts, breakGaps } from '../chart'
const props = defineProps({ board: String, rail: Number })
const range = ref(600e3)
const el = ref(null)
const note = ref('')
const c = ref(palette())
const h = ref(560)
let chart = null
let pts = [] // [t, mv, ua, ua_min, ua_max]
let raw = [] // [t, mv, ua, ua_min, ua_max]
let live = false
let step = 1000 // 每个点代表的毫秒数,判断断档用
function render() {
if (!chart) return // 还在等字体
const p = palette()
c.value = p
const pts = breakGaps(raw, step)
const hasBand = pts.some(x => x[3] != null && x[3] !== x[4])
const mA = v => (v == null ? null : v / 1000)
// 总高度和旁边「底板曲线」那张凑齐(两张卡并排时底边对齐;那边图例多一行,按截图实测调的)
const L = panels(p, [
{ name: '电流 mA', height: 256, fmt: v => v.toFixed(0) },
{ name: '功率 W', height: 212, fmt: v => v.toFixed(1), min: 0 },
// 电压固定 3.5~4.1 V(硬件可调范围 3.524~4.010 V 全在里面),每 0.3 V 一格:
// 不固定的话 echarts 会把 3.9 V 画在 1~6 V 正中间;自动取整的刻度在这么矮的图上会挤成一团
{ name: '电压 V', height: 140, fmt: v => v.toFixed(2), min: 3.5, max: 4.1, interval: 0.3 },
])
if (h.value !== L.height) { h.value = L.height; nextTick(() => chart && chart.resize()) }
chart.setOption({
...stacked(p, {
topName: '电流 mA', bottomName: '电压 V',
topFmt: v => v.toFixed(0), bottomFmt: v => v.toFixed(2),
// 电压固定 3.5~4.1 V(硬件可调范围 3.524~4.010 V 全在里面),每 0.3 V 一格:
// 不固定的话 echarts 会把 3.9 V 画在 1~6 V 正中间;自动取整的刻度在这么矮的图上会挤成一团
bottomMin: 3.5, bottomMax: 4.1, bottomInterval: 0.3,
}),
...L.option,
series: [
...(hasBand ? [
{ type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], mA(x[3])]), stack: 'b', symbol: 'none',
@@ -49,7 +62,9 @@ function render() {
] : []),
{ name: '电流', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], mA(x[2])]), symbol: 'none',
lineStyle: { width: 2 }, color: p.s1, tooltip: { valueFormatter: v => (v == null ? '—' : `${v.toFixed(0)} mA`) } },
{ name: '电压', type: 'line', xAxisIndex: 1, yAxisIndex: 1, data: pts.map(x => [x[0], x[1] == null ? null : x[1] / 1000]),
{ name: '功率', type: 'line', xAxisIndex: 1, yAxisIndex: 1, data: pts.map(x => [x[0], x[1] == null || x[2] == null ? null : x[1] * x[2] / 1e9]),
symbol: 'none', lineStyle: { width: 2 }, color: p.s1, tooltip: { valueFormatter: v => (v == null ? '—' : `${v.toFixed(2)} W`) } },
{ name: '电压', type: 'line', xAxisIndex: 2, yAxisIndex: 2, data: pts.map(x => [x[0], x[1] == null ? null : x[1] / 1000]),
symbol: 'none', lineStyle: { width: 2 }, color: p.s1, tooltip: { valueFormatter: v => (v == null ? '—' : `${v.toFixed(3)} V`) } },
],
}, true)
@@ -59,7 +74,8 @@ async function load() {
const end = Date.now()
try {
const h = await api.history(props.board, props.rail, end - range.value, end)
pts = h.points
raw = h.points
step = h.bucket_ms || 1000
live = h.resolution === 'raw' && range.value <= 3600e3
note.value = h.resolution === 'raw'
? `每秒一个点${live ? ',实时追加' : ''}`
@@ -74,9 +90,9 @@ const off = onMessage(m => {
if (!live || m.t !== 'tele' || m.board !== props.board) return
const r = m.status.rails?.[props.rail - 1]
if (!r) return
pts.push([m.ts, r.mv, r.ua, r.ua, r.ua])
raw.push([m.ts, r.mv, r.ua, r.ua, r.ua])
const cut = Date.now() - range.value
while (pts.length && pts[0][0] < cut) pts.shift()
while (raw.length && raw[0][0] < cut) raw.shift()
render()
})
@@ -84,7 +100,7 @@ const onResize = () => chart && chart.resize()
let gone = false
onMounted(async () => {
window.addEventListener('resize', onResize)
await chartFonts('电流 mA', '电压 V')
await chartFonts('电流 mA', '功率 W', '电压 V')
if (gone) return
chart = echarts.init(el.value)
load()
@@ -93,11 +109,3 @@ onBeforeUnmount(() => { gone = true; off(); window.removeEventListener('resize',
watch(() => [props.board, props.rail, range.value], load)
watch(dark, () => chart && render())
</script>
<style scoped>
.hh { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
.hh .ttl { margin: 0; }
.chart { height: 340px; margin-top: 6px; }
.note { margin: 4px 0 0; }
@media (max-width: 520px) { .chart { height: 280px; } }
</style>
+2 -2
查看文件
@@ -10,7 +10,7 @@
<el-input v-model="label" size="small" class="label" placeholder="手机编号" @change="saveLabel" @click.stop />
<div class="nums" v-if="r?.pr">
<div><b class="readout">{{ mA(r.ua) }}</b><small>mA</small></div>
<div><b class="readout">{{ mA(r.ua) }}</b><small>mA</small><div class="muted set">功率 <span class="readout">{{ W(r.mv, r.ua) }}</span> W</div></div>
<div><b class="readout">{{ V(r.mv) }}</b><small>V</small><div class="muted set">设定 {{ V(r.set) }}</div></div>
</div>
<div class="flags" v-if="r?.pr">
@@ -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'])
+21 -15
查看文件
@@ -1,15 +1,21 @@
<template>
<el-radio-group :model-value="modelValue" size="small" @update:model-value="v => $emit('update:modelValue', v)">
<el-radio-button v-for="o in RANGES" :key="o.v" :value="o.v">{{ o.t }}</el-radio-button>
</el-radio-group>
</template>
<script setup>
// 曲线的时间范围。与后端的取数规则对应:6 小时以内回每秒原始数据,更长回分钟聚合(db.py)。
defineProps({ modelValue: Number })
defineEmits(['update:modelValue'])
const RANGES = [
{ v: 600e3, t: '10 分钟' }, { v: 3600e3, t: '1 小时' }, { v: 6 * 3600e3, t: '6 小时' },
{ v: 86400e3, t: '1 天' }, { v: 7 * 86400e3, t: '7 天' },
]
</script>
<template>
<el-radio-group class="rp" :model-value="modelValue" size="small" @update:model-value="v => $emit('update:modelValue', v)">
<el-radio-button v-for="o in RANGES" :key="o.v" :value="o.v">{{ o.t }}</el-radio-button>
</el-radio-group>
</template>
<script setup>
// 曲线的时间范围。与后端的取数规则对应:6 小时以内回每秒原始数据,更长回分钟聚合(db.py)。
defineProps({ modelValue: Number })
defineEmits(['update:modelValue'])
const RANGES = [
{ v: 600e3, t: '10 分钟' }, { v: 3600e3, t: '1 小时' }, { v: 6 * 3600e3, t: '6 小时' },
{ v: 86400e3, t: '1 天' }, { v: 7 * 86400e3, t: '7 天' },
]
</script>
<style scoped>
/* 手机上五个按钮放不下:不换行,横向滑 */
.rp { flex-wrap: nowrap; max-width: 100%; overflow-x: auto; scrollbar-width: none; padding: 1px; }
.rp :deep(.el-radio-button__inner) { font-variant-numeric: tabular-nums; }
</style>
+184 -156
查看文件
@@ -1,156 +1,184 @@
<template>
<div v-if="!b" class="card"><el-empty description="没有这块板子,或者还没收到它的数据" /></div>
<div v-else class="bd">
<section class="card head">
<div class="names">
<span :class="['led', !b.online || b.stale ? 'lost' : 'ok']" />
<el-input v-model="name" class="ed name" placeholder="给这块板起个名字" @change="saveMeta" />
<el-input v-model="location" class="ed" placeholder="位置,比如 机架 A 第 3 层" @change="saveMeta" />
</div>
<dl class="meta">
<div><dt>状态</dt><dd :class="{ bad: !b.online || b.stale }">{{ !b.online || b.stale ? `通信中断,最后数据 ${ago(b.last_tele)}` : '在线' }}</dd></div>
<div><dt>ID</dt><dd class="readout">{{ b.id }}</dd></div>
<div><dt>固件</dt><dd>{{ b.status?.fw || b.fw }} <OtaState :status="b.status" quiet /> <router-link to="/firmware">推固件</router-link></dd></div>
<div><dt>IP</dt><dd class="readout">{{ b.ip || '—' }}</dd></div>
</dl>
<div class="gauges" v-if="b.status">
<div class="g"><span class="gk">输入</span>
<span class="gv readout">{{ V(b.status.in?.mv) }}<small>V</small></span>
<span class="gv readout">{{ mA(b.status.in?.ua) }}<small>mA</small></span></div>
<div class="g"><span class="gk">温度</span>
<span v-for="(t, i) in b.status.temp || []" :key="i" class="gv readout">{{ t == null ? '—' : C(t) }}<small>°C</small></span></div>
<div class="g"><span class="gk">风扇</span>
<span class="gv readout">{{ (b.status.fan?.duty ?? 0) / 10 }}<small>%</small></span>
<span v-for="(r, i) in b.status.fan?.rpm || []" :key="i" class="gv readout" :class="{ bad: b.status.fan?.stall?.[i] }">{{ r ?? '—' }}<small>rpm</small></span>
<el-select v-model="fanFloor" size="small" class="floor" aria-label="风扇占空比下限" @change="setFan">
<el-option :value="0" label="自动" />
<el-option v-for="d in [500, 700, 850, 1000]" :key="d" :value="d" :label="`不低于 ${d / 10}%`" />
</el-select></div>
<div class="g"><span class="gk">巡检</span>
<span class="gv readout">{{ b.status.safety?.cycles }}<small>圈</small></span>
<span class="gv readout" :class="{ bad: b.status.safety?.skipped }">{{ b.status.safety?.skipped }}<small>跳过</small></span>
<span class="gv readout" :class="{ bad: b.status.safety?.i2c_err }">{{ b.status.safety?.i2c_err }}<small>I²C 错</small></span></div>
</div>
</section>
<div class="rails">
<RailCard v-for="i in 5" :key="i" :board="b" :rail="i" :selected="sel === i"
:rec-state="recStates[i]" @select="sel = i" />
</div>
<div class="charts">
<HistoryChart :board="b.id" :rail="sel" />
<BoardChart :board="b.id" />
</div>
<div class="two">
<section class="card">
<h2 class="ttl">最近事件</h2>
<el-table :data="events" size="small" max-height="360" empty-text="这块板还没有事件">
<el-table-column label="收到" width="130"><template #default="{ row }"><span class="readout">{{ fmtTime(row.received) }}</span></template></el-table-column>
<el-table-column label="路" width="50"><template #default="{ row }">{{ row.rail || '整板' }}</template></el-table-column>
<el-table-column label="事件"><template #default="{ row }"><span :class="{ bad: row.severe }">{{ row.name }}</span></template></el-table-column>
<el-table-column label="当时" width="150"><template #default="{ row }"><span class="readout">{{ V(row.mv) }} V {{ mA(row.ua) }} mA</span></template></el-table-column>
</el-table>
</section>
<section class="card">
<h2 class="ttl">操作记录</h2>
<el-table :data="audit" size="small" max-height="360" empty-text="还没有人操作过这块板">
<el-table-column label="时间" width="130"><template #default="{ row }"><span class="readout">{{ fmtTime(row.ts) }}</span></template></el-table-column>
<el-table-column label="谁" prop="user" width="110" />
<el-table-column label="操作"><template #default="{ row }">{{ row.rail ? `#${row.rail} ` : '' }}{{ row.request.cmd }}{{ row.request.keys ? ' ' + row.request.keys : '' }}</template></el-table-column>
<el-table-column label="结果" width="120"><template #default="{ row }"><span :class="{ bad: !row.ok }">{{ rspText(row.response) }}</span></template></el-table-column>
</el-table>
</section>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import RailCard from '../components/RailCard.vue'
import HistoryChart from '../components/HistoryChart.vue'
import BoardChart from '../components/BoardChart.vue'
import OtaState from '../components/OtaState.vue'
import { api, rspText } from '../api'
import { state, onMessage } from '../store'
import { mA, V, C, ago, fmtTime } from '../util'
const props = defineProps({ id: String })
const b = computed(() => state.boards[props.id])
const sel = ref(1)
const name = ref('')
const location = ref('')
const fanFloor = ref(0)
const events = ref([])
const audit = ref([])
const recStates = ref({})
watch(b, v => {
if (!v) return
if (document.activeElement?.tagName !== 'INPUT') { name.value = v.name; location.value = v.location }
fanFloor.value = v.status?.fan?.floor ?? 0
}, { immediate: true })
async function saveMeta() {
try { await api.patchBoard(props.id, { name: name.value, location: location.value }) } catch (e) { ElMessage.error(e.message) }
}
async function setFan(d) {
try {
const rsp = await api.cmd(props.id, { cmd: 'fan', duty: d })
ElMessage({ type: rsp.ok ? 'success' : 'error', message: `风扇:${rspText(rsp)}` })
} catch (e) { ElMessage.error(e.message) }
}
async function refresh() {
try {
const [e, a, r] = await Promise.all([api.events({ board: props.id, limit: 50 }), api.audit({ board: props.id, limit: 50 }), api.recovery()])
events.value = e
audit.value = a
recStates.value = Object.fromEntries(r.states.filter(s => s.board === props.id).map(s => [s.rail, s.state]))
} catch (e) { /* 列表刷新失败不打扰:下一条推送会再试 */ }
}
let timer = null
const off = onMessage(m => {
if (['events', 'audit', 'recovery'].includes(m.t) && (m.board === props.id || m.row?.board_id === props.id)) {
clearTimeout(timer)
timer = setTimeout(refresh, 300)
}
})
onMounted(refresh)
onBeforeUnmount(off)
watch(() => props.id, refresh)
</script>
<style scoped>
.bd { display: flex; flex-direction: column; gap: 16px; }
.head { display: flex; flex-direction: column; gap: 12px; }
.names { display: grid; grid-template-columns: auto minmax(0, 1.2fr) minmax(0, 1fr); gap: 10px; align-items: center; }
/* 就地编辑:平时像标题,点进去才像输入框 */
.ed :deep(.el-input__wrapper) { box-shadow: none; background: transparent; padding-left: 4px; }
.ed :deep(.el-input__wrapper:hover), .ed :deep(.el-input__wrapper.is-focus) { box-shadow: 0 0 0 1px var(--line) inset; background: var(--bay); }
.name :deep(.el-input__inner) { font-size: 18px; font-weight: 700; color: var(--ink); }
.meta { display: flex; flex-wrap: wrap; gap: 6px 28px; margin: 0; font-size: 13px; }
.meta div { display: flex; gap: 8px; align-items: center; }
.meta dt { color: var(--muted); }
.meta dd { margin: 0; color: var(--ink-2); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.gauges { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 8px;
border-top: 1px solid var(--line-soft); padding-top: 12px; }
.g { display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
.gk { color: var(--muted); font-size: 13px; width: 32px; flex-shrink: 0; }
.gv { font-size: 20px; color: var(--ink); }
.gv small { font-size: 11px; color: var(--muted); margin-left: 3px; font-family: var(--ui); }
.floor { width: 130px; align-self: center; }
.rails { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.charts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
.two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
@media (max-width: 1200px) { .rails { grid-template-columns: repeat(3, minmax(0, 1fr)); } .charts { grid-template-columns: 1fr; } }
@media (max-width: 800px) { .rails { grid-template-columns: 1fr 1fr; } .two { grid-template-columns: 1fr; } }
@media (max-width: 520px) {
.rails { grid-template-columns: 1fr; }
.names { grid-template-columns: auto 1fr; }
.names .ed:not(.name) { grid-column: 1 / -1; }
}
</style>
<template>
<div v-if="!b" class="card"><el-empty description="没有这块板子,或者还没收到它的数据" /></div>
<div v-else class="bd">
<section class="card head">
<div class="idrow">
<span :class="['led', 'big', !b.online || b.stale ? 'lost' : 'ok']" />
<div class="ident">
<el-input v-model="name" class="ed name" placeholder="给这块板起个名字" @change="saveMeta" />
<el-input v-model="location" class="ed loc" size="small" placeholder="位置,比如 机架 A 第 3 层" @change="saveMeta" />
</div>
<dl class="meta">
<div><dt>状态</dt><dd :class="{ bad: !b.online || b.stale }">{{ !b.online || b.stale ? `通信中断,最后数据 ${ago(b.last_tele)}` : '在线' }}</dd></div>
<div><dt>ID</dt><dd class="readout">{{ b.id }}</dd></div>
<div><dt>固件</dt><dd><span class="readout">{{ b.status?.fw || b.fw }}</span> <OtaState :status="b.status" quiet /> <router-link to="/firmware">推固件</router-link></dd></div>
<div><dt>IP</dt><dd class="readout">{{ b.ip || '—' }}</dd></div>
</dl>
</div>
<div class="gauges" v-if="b.status">
<div class="g"><div class="gh"><span class="gk">输入</span></div>
<div class="gvs"><span class="gv readout">{{ V(b.status.in?.mv) }}<small>V</small></span>
<span class="gv readout">{{ mA(b.status.in?.ua) }}<small>mA</small></span></div></div>
<div class="g"><div class="gh"><span class="gk">功率</span><span class="gs">底板输入 · 主板合计 · 损耗</span></div>
<div class="gvs"><span class="gv readout">{{ W(b.status.in?.mv, b.status.in?.ua) }}<small>W</small></span>
<span class="gv sm readout">{{ fmtW(pw.rails) }}<small>W 主板</small></span>
<span class="gv sm readout">{{ fmtW(pw.loss) }}<small>W 损耗</small></span></div></div>
<div class="g"><div class="gh"><span class="gk">温度</span><span class="gs">NTC1 · NTC2</span></div>
<div class="gvs"><span v-for="(t, i) in b.status.temp || []" :key="i" class="gv readout">{{ t == null ? '—' : C(t) }}<small>°C</small></span></div></div>
<div class="g"><div class="gh"><span class="gk">风扇</span>
<el-select v-model="fanFloor" size="small" class="floor" aria-label="风扇占空比下限" @change="setFan">
<el-option :value="0" label="自动" />
<el-option v-for="d in [500, 700, 850, 1000]" :key="d" :value="d" :label="`不低于 ${d / 10}%`" />
</el-select></div>
<div class="gvs"><span class="gv readout">{{ (b.status.fan?.duty ?? 0) / 10 }}<small>%</small></span>
<span v-for="(r, i) in b.status.fan?.rpm || []" :key="i" class="gv sm readout" :class="{ bad: b.status.fan?.stall?.[i] }">{{ r ?? '—' }}<small>rpm</small></span></div></div>
<div class="g"><div class="gh"><span class="gk">巡检</span></div>
<div class="gvs"><span class="gv readout">{{ b.status.safety?.cycles }}<small>圈</small></span>
<span class="gv sm readout" :class="{ bad: b.status.safety?.skipped }">{{ b.status.safety?.skipped }}<small>跳过</small></span>
<span class="gv sm readout" :class="{ bad: b.status.safety?.i2c_err }">{{ b.status.safety?.i2c_err }}<small>I²C 错</small></span></div></div>
</div>
</section>
<div class="rails">
<RailCard v-for="i in 5" :key="i" :board="b" :rail="i" :selected="sel === i"
:rec-state="recStates[i]" @select="sel = i" />
</div>
<div class="charts">
<HistoryChart :board="b.id" :rail="sel" />
<BoardChart :board="b.id" />
</div>
<div class="two">
<section class="card">
<h2 class="ttl">最近事件</h2>
<el-table :data="events" size="small" max-height="360" empty-text="这块板还没有事件">
<el-table-column label="收到" width="130"><template #default="{ row }"><span class="readout">{{ fmtTime(row.received) }}</span></template></el-table-column>
<el-table-column label="路" width="50"><template #default="{ row }">{{ row.rail || '整板' }}</template></el-table-column>
<el-table-column label="事件"><template #default="{ row }"><span :class="{ bad: row.severe }">{{ row.name }}</span></template></el-table-column>
<el-table-column label="当时" width="150"><template #default="{ row }"><span class="readout">{{ V(row.mv) }} V {{ mA(row.ua) }} mA</span></template></el-table-column>
</el-table>
</section>
<section class="card">
<h2 class="ttl">操作记录</h2>
<el-table :data="audit" size="small" max-height="360" empty-text="还没有人操作过这块板">
<el-table-column label="时间" width="130"><template #default="{ row }"><span class="readout">{{ fmtTime(row.ts) }}</span></template></el-table-column>
<el-table-column label="谁" prop="user" width="110" />
<el-table-column label="操作"><template #default="{ row }">{{ row.rail ? `#${row.rail} ` : '' }}{{ row.request.cmd }}{{ row.request.keys ? ' ' + row.request.keys : '' }}</template></el-table-column>
<el-table-column label="结果" width="120"><template #default="{ row }"><span :class="{ bad: !row.ok }">{{ rspText(row.response) }}</span></template></el-table-column>
</el-table>
</section>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import RailCard from '../components/RailCard.vue'
import HistoryChart from '../components/HistoryChart.vue'
import BoardChart from '../components/BoardChart.vue'
import OtaState from '../components/OtaState.vue'
import { api, rspText } from '../api'
import { state, onMessage } from '../store'
import { mA, V, W, C, ago, fmtTime, mW, railsMw } from '../util'
const props = defineProps({ id: String })
const b = computed(() => state.boards[props.id])
// 功率(mW):五路主板合计,和底板输入之差算作板上损耗(DC-DC 效率 + 走线 + 自身耗电)
const pw = computed(() => {
const s = b.value?.status
const input = mW(s?.in?.mv, s?.in?.ua)
const rails = railsMw(s?.rails)
return { rails, loss: input == null || rails == null ? null : input - rails }
})
const fmtW = v => (v == null ? '—' : (v / 1000).toFixed(2))
const sel = ref(1)
const name = ref('')
const location = ref('')
const fanFloor = ref(0)
const events = ref([])
const audit = ref([])
const recStates = ref({})
watch(b, v => {
if (!v) return
if (document.activeElement?.tagName !== 'INPUT') { name.value = v.name; location.value = v.location }
fanFloor.value = v.status?.fan?.floor ?? 0
}, { immediate: true })
async function saveMeta() {
try { await api.patchBoard(props.id, { name: name.value, location: location.value }) } catch (e) { ElMessage.error(e.message) }
}
async function setFan(d) {
try {
const rsp = await api.cmd(props.id, { cmd: 'fan', duty: d })
ElMessage({ type: rsp.ok ? 'success' : 'error', message: `风扇:${rspText(rsp)}` })
} catch (e) { ElMessage.error(e.message) }
}
async function refresh() {
try {
const [e, a, r] = await Promise.all([api.events({ board: props.id, limit: 50 }), api.audit({ board: props.id, limit: 50 }), api.recovery()])
events.value = e
audit.value = a
recStates.value = Object.fromEntries(r.states.filter(s => s.board === props.id).map(s => [s.rail, s.state]))
} catch (e) { /* 列表刷新失败不打扰:下一条推送会再试 */ }
}
let timer = null
const off = onMessage(m => {
if (['events', 'audit', 'recovery'].includes(m.t) && (m.board === props.id || m.row?.board_id === props.id)) {
clearTimeout(timer)
timer = setTimeout(refresh, 300)
}
})
onMounted(refresh)
onBeforeUnmount(off)
watch(() => props.id, refresh)
</script>
<style scoped>
.bd { display: flex; flex-direction: column; gap: 16px; }
.head { display: flex; flex-direction: column; gap: 14px; }
.idrow { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.led.big { width: 10px; height: 10px; margin-top: 13px; }
.ident { flex: 1 1 280px; min-width: 0; display: flex; flex-direction: column; }
/* 就地编辑:平时像标题,点进去才像输入框 */
.ed :deep(.el-input__wrapper) { box-shadow: none; background: transparent; padding-left: 4px; }
.ed :deep(.el-input__wrapper:hover), .ed :deep(.el-input__wrapper.is-focus) { box-shadow: 0 0 0 1px var(--line) inset; background: var(--bay); }
.name :deep(.el-input__inner) { font-size: 20px; font-weight: 700; color: var(--ink); height: 34px; }
.loc :deep(.el-input__inner) { color: var(--ink-2); font-size: 13px; }
.meta { display: grid; grid-template-columns: auto auto; gap: 4px 24px; margin: 4px 0 0; font-size: 13px; }
.meta div { display: flex; gap: 10px; align-items: center; min-width: 0; }
.meta dt { color: var(--muted); width: 28px; flex-shrink: 0; }
.meta dd { margin: 0; color: var(--ink-2); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* 五个仪表格:凹进面板的小槽,标签在上、读数在下 */
.gauges { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; }
.g { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay); padding: 10px 14px 12px;
display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.gh { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; }
.gk { color: var(--muted); font-size: 13px; }
.gs { color: var(--muted); font-size: 11px; }
.gvs { display: flex; gap: 6px 18px; align-items: baseline; flex-wrap: wrap; }
.gv { font-size: 24px; color: var(--ink); line-height: 1.1; white-space: nowrap; }
.gv.sm { font-size: 17px; color: var(--ink-2); }
.gv.bad { color: var(--st-bad); }
.gv small { font-size: 11px; color: var(--muted); margin-left: 3px; font-family: var(--ui); }
.floor { width: 120px; }
.rails { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.charts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
.two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
@media (max-width: 1500px) { .gauges { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 1200px) { .rails { grid-template-columns: repeat(3, minmax(0, 1fr)); } .charts { grid-template-columns: 1fr; }
.gauges { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 800px) { .rails { grid-template-columns: 1fr 1fr; } .two { grid-template-columns: 1fr; } }
@media (max-width: 520px) {
.rails { grid-template-columns: 1fr; }
.gauges { gap: 8px; }
.g { padding: 8px 10px 10px; }
.gv { font-size: 20px; }
.gv.sm { font-size: 15px; }
.gvs { column-gap: 12px; }
}
</style>
+13 -4
查看文件
@@ -35,6 +35,7 @@
<div class="bl" :title="phone(b, i).label">{{ phone(b, i).label || ' ' }}</div>
<template v-if="rail(b, i)?.pr">
<div class="bv readout">{{ mA(rail(b, i)?.ua) }}<small>mA</small></div>
<div class="bw readout">{{ W(rail(b, i)?.mv, rail(b, i)?.ua) }}<small>W</small></div>
<Spark :board="b.id" :rail="i" :color="loud(b, i) ? LEVEL_COLOR[health(b, i).level] : 'var(--ink-2)'" />
</template>
<div class="bs" :style="{ color: loud(b, i) ? LEVEL_COLOR[health(b, i).level] : 'var(--muted)' }">{{ health(b, i).text }}</div>
@@ -44,6 +45,8 @@
<footer class="pf" v-if="b.status">
<div><span class="k">输入</span><span class="readout">{{ V(b.status.in?.mv) }}</span> V
<span class="readout">{{ A(b.status.in?.ua) }}</span> A</div>
<div><span class="k">底板功率</span><span class="readout">{{ W(b.status.in?.mv, b.status.in?.ua) }}</span> W
<span class="k sub">主板</span><span class="readout">{{ railsW(b) }}</span> W</div>
<div><span class="k">温度</span>
<span v-for="(t, k) in b.status.temp || []" :key="k" class="readout t">{{ t == null ? '—' : C(t) }}</span> °C</div>
<div><span class="k">风扇</span><span class="readout">{{ (b.status.fan?.duty ?? 0) / 10 }}</span> %
@@ -62,7 +65,7 @@
<time class="muted readout">{{ fmtTime(f.ts) }}</time>
<span class="who">{{ boardName(f.board) }}</span>
<template v-if="f.kind === 'event'">
<span :class="{ bad: SEVERE.has(f.ev[3]) }">{{ f.ev[2] ? `#${f.ev[2]} ` : '' }}{{ evName(f.ev[3]) }}</span>
<span :class="{ bad: isSevere(f.ev) }">{{ f.ev[2] ? `#${f.ev[2]} ` : '' }}{{ evName(f.ev[3]) }}</span>
</template>
<template v-else-if="f.kind === 'audit'">
<span>{{ f.row.user }} 对 #{{ f.row.rail ?? '' }} 执行 {{ f.row.request.cmd }}:{{ rspText(f.row.response) }}</span>
@@ -82,16 +85,19 @@ import { Refresh } from '@element-plus/icons-vue'
import Spark from '../components/Spark.vue'
import { state } from '../store'
import { rspText } from '../api'
import { mA, V, C, ago, fmtTime, railHealth, LEVEL_COLOR, RECOVERY_ZH, REASON_ZH } from '../util'
import { mA, V, W, C, ago, fmtTime, railsMw, railHealth, LEVEL_COLOR, RECOVERY_ZH, REASON_ZH } from '../util'
// 与 backend/prrack/protocol.py 的 EVENT_TYPES / EVENT_SEVERE 同一张表(数字来自固件 evlog.h)
const EV = ['', '板子上电', '事件丢失', '过压关路', '欠压关路', '反灌断 VBUS', '顶高断 VBUS', 'eFuse 故障',
'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率',
'上电等不到 PG', '固件升级']
'上电等不到 PG', '固件升级', '开机重按']
const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14, 17])
// 开机重按(19)只有 arg bit7「最终没开起来」才算严重
const isSevere = ev => SEVERE.has(ev[3]) || (ev[3] === 19 && (ev[4] & 0x80) !== 0)
const evName = t => EV[t] || `事件 ${t}`
const A = ua => (ua == null ? '—' : (ua / 1e6).toFixed(2))
const railsW = b => { const s = railsMw(b.status?.rails); return s == null ? '—' : (s / 1000).toFixed(2) }
const boards = computed(() => Object.values(state.boards).sort((a, b) =>
(a.location + a.name + a.id).localeCompare(b.location + b.name + b.id)))
const rail = (b, i) => b.status?.rails?.[i - 1]
@@ -152,12 +158,15 @@ const tally = computed(() => {
.bl { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bv { font-size: 24px; line-height: 1.1; color: var(--ink); margin-top: 2px; }
.bv small { font-size: 11px; color: var(--muted); margin-left: 2px; font-family: var(--ui); }
.bw { font-size: 13px; color: var(--ink-2); line-height: 1.2; }
.bw small { font-size: 10px; color: var(--muted); margin-left: 2px; font-family: var(--ui); }
.bs { font-size: 12px; font-weight: 600; margin-top: auto; }
.bay.off .bv, .bay.lost .bv { color: var(--muted); }
.bay.off .bv, .bay.lost .bv, .bay.off .bw, .bay.lost .bw { color: var(--muted); }
.pf { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 13px; color: var(--ink-2);
border-top: 1px solid var(--line-soft); padding-top: 10px; }
.pf .k { color: var(--muted); margin-right: 6px; }
.pf .k.sub { margin-left: 6px; }
.pf .readout { font-size: 15px; color: var(--ink); }
.pf .t + .t::before { content: '/'; margin: 0 4px; color: var(--muted); }
+141 -132
查看文件
@@ -1,132 +1,141 @@
/* PhoneRail 机架 —— 仪器前面板风格。
一处用力:每块板是一块「面板」,五个槽是凹进去的「刀片槽」,槽里是状态灯 + 读数 + 电流小曲线。
其余一律安静:冷灰阳极铝底、细线分隔、读数用 DIN 系等宽数字。
颜色只有两类职责:
· 状态(运行 / 忙 / 异常 / 故障 / 断电 / 空槽 / 失联)—— 只用来表示状态,旁边一定有文字;
· 交互(选中、链接、主按钮)—— 阳极蓝。
图表的系列色在 chart.js 里(dataviz 参考调色板第 1/2 槽,按本页底色校验过)。 */
:root {
color-scheme: light;
--plane: #eceff2; /* 页面底 */
--panel: #fbfcfd; /* 面板 */
--bay: #f2f4f7; /* 凹槽(比面板深一档) */
--bay-edge: #dde2e8;
--ink: #16202b;
--ink-2: #566270;
--muted: #8a94a0;
--line: #d9dee4;
--line-soft: #e8ecf0;
--accent: #1d5fd1;
--accent-soft: #e3ecfb;
--bar: #fbfcfd;
--st-ok: #0c8a0c; /* 运行(比 dataviz 的 good 深一档:它要当文字用) */
--st-busy: #1d5fd1;
--st-warn: #b07a00;
--st-bad: #c62f2f;
--st-off: #5d6875;
--st-empty: #b9c1ca;
--st-lost: #98a1ab;
--led-ok: #16b416; /* 灯本身:亮一点,但只当圆点用,不当文字 */
--led-warn: #fab219;
--led-bad: #e04343;
/* 两套字体都随前端打包(main.js 引入),系统字体只在字体文件还没下完时顶一下 */
--readout: "Barlow", "Noto Sans SC Variable", sans-serif;
--ui: "Noto Sans SC Variable", sans-serif;
--r-panel: 10px;
--r-bay: 6px;
/* Element Plus:主色换成阳极蓝,其余跟着面板走 */
--el-color-primary: #1d5fd1;
--el-color-primary-light-3: #6190e0;
--el-color-primary-light-5: #8eafe8;
--el-color-primary-light-7: #bbcff1;
--el-color-primary-light-8: #d2dff6;
--el-color-primary-light-9: #e8effb;
--el-color-primary-dark-2: #184ca7;
--el-font-family: var(--ui);
--el-border-radius-base: 6px;
}
html.dark {
color-scheme: dark;
--plane: #0f1216;
--panel: #171b21;
--bay: #12161b;
--bay-edge: #262c34;
--ink: #e8ecf1;
--ink-2: #a7b0bb;
--muted: #76808b;
--line: #2a3038;
--line-soft: #20252c;
--accent: #5b95f2;
--accent-soft: #1b2a44;
--bar: #13171c;
--st-ok: #3cc43c;
--st-busy: #5b95f2;
--st-warn: #f0b23a;
--st-bad: #ef6464;
--st-off: #8a95a1;
--st-empty: #3a414a;
--st-lost: #5d6671;
--led-ok: #2fd12f;
--led-warn: #fab219;
--led-bad: #ef5353;
--el-color-primary: #5b95f2;
--el-color-primary-light-3: #3f6fb8;
--el-color-primary-light-5: #2f548b;
--el-color-primary-light-7: #203a5f;
--el-color-primary-light-8: #1a2f4c;
--el-color-primary-light-9: #142338;
--el-color-primary-dark-2: #7eabf5;
--el-bg-color: #171b21;
--el-bg-color-overlay: #1c2128;
--el-fill-color-blank: #171b21;
--el-border-color: #2f363f;
--el-border-color-light: #2a3038;
--el-border-color-lighter: #242a31;
}
* { box-sizing: border-box; }
html, body { background: var(--plane); }
body { margin: 0; font-family: var(--ui); color: var(--ink); font-size: 14px; line-height: 1.5;
-webkit-font-smoothing: antialiased; }
a { color: var(--accent); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* 面板:页面上的一级容器 */
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-panel); padding: 16px; }
.muted { color: var(--muted); font-size: 13px; }
.readout { font-family: var(--readout); font-variant-numeric: tabular-nums; letter-spacing: .01em; }
.bad { color: var(--st-bad); }
.warn { color: var(--st-warn); }
/* 面板标题:粗一点的正文字,不加眉标、不全大写 */
.ttl { font-weight: 600; font-size: 15px; margin: 0 0 10px; color: var(--ink); }
/* 状态灯 */
.led { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0;
background: var(--st-empty); box-shadow: 0 0 0 2px var(--panel); }
.led.ok { background: var(--led-ok); }
.led.busy { background: var(--st-busy); }
.led.warn { background: var(--led-warn); }
.led.bad { background: var(--led-bad); }
.led.off { background: var(--st-off); }
.led.lost { background: var(--st-lost); }
@media (prefers-reduced-motion: no-preference) {
.led.busy { animation: led-blink 1s steps(2, jump-none) infinite; }
}
@keyframes led-blink { 50% { opacity: .35; } }
/* 表格:去掉 Element Plus 的斑马感,行线用细线 */
.el-table { --el-table-border-color: var(--line-soft); --el-table-header-bg-color: transparent;
--el-table-tr-bg-color: transparent; --el-table-bg-color: transparent; --el-table-row-hover-bg-color: var(--bay);
--el-table-text-color: var(--ink); --el-table-header-text-color: var(--muted); }
.el-table th.el-table__cell { font-weight: 500; }
code { font-size: 12px; background: var(--bay); border: 1px solid var(--line-soft); padding: 0 4px; border-radius: 4px;
word-break: break-all; }
/* PhoneRail 机架 —— 仪器前面板风格。
一处用力:每块板是一块「面板」,五个槽是凹进去的「刀片槽」,槽里是状态灯 + 读数 + 电流小曲线。
其余一律安静:冷灰阳极铝底、细线分隔、读数用 DIN 系等宽数字。
颜色只有两类职责:
· 状态(运行 / 忙 / 异常 / 故障 / 断电 / 空槽 / 失联)—— 只用来表示状态,旁边一定有文字;
· 交互(选中、链接、主按钮)—— 阳极蓝。
图表的系列色在 chart.js 里(dataviz 参考调色板第 1/2 槽,按本页底色校验过)。 */
:root {
color-scheme: light;
--plane: #eceff2; /* 页面底 */
--panel: #fbfcfd; /* 面板 */
--bay: #f2f4f7; /* 凹槽(比面板深一档) */
--bay-edge: #dde2e8;
--ink: #16202b;
--ink-2: #566270;
--muted: #8a94a0;
--line: #d9dee4;
--line-soft: #e8ecf0;
--accent: #1d5fd1;
--accent-soft: #e3ecfb;
--bar: #fbfcfd;
--st-ok: #0c8a0c; /* 运行(比 dataviz 的 good 深一档:它要当文字用) */
--st-busy: #1d5fd1;
--st-warn: #b07a00;
--st-bad: #c62f2f;
--st-off: #5d6875;
--st-empty: #b9c1ca;
--st-lost: #98a1ab;
--led-ok: #16b416; /* 灯本身:亮一点,但只当圆点用,不当文字 */
--led-warn: #fab219;
--led-bad: #e04343;
/* 两套字体都随前端打包(main.js 引入),系统字体只在字体文件还没下完时顶一下 */
--readout: "Barlow", "Noto Sans SC Variable", sans-serif;
--ui: "Noto Sans SC Variable", sans-serif;
--r-panel: 10px;
--r-bay: 6px;
/* Element Plus:主色换成阳极蓝,其余跟着面板走 */
--el-color-primary: #1d5fd1;
--el-color-primary-light-3: #6190e0;
--el-color-primary-light-5: #8eafe8;
--el-color-primary-light-7: #bbcff1;
--el-color-primary-light-8: #d2dff6;
--el-color-primary-light-9: #e8effb;
--el-color-primary-dark-2: #184ca7;
--el-font-family: var(--ui);
--el-border-radius-base: 6px;
}
html.dark {
color-scheme: dark;
--plane: #0f1216;
--panel: #171b21;
--bay: #12161b;
--bay-edge: #262c34;
--ink: #e8ecf1;
--ink-2: #a7b0bb;
--muted: #76808b;
--line: #2a3038;
--line-soft: #20252c;
--accent: #5b95f2;
--accent-soft: #1b2a44;
--bar: #13171c;
--st-ok: #3cc43c;
--st-busy: #5b95f2;
--st-warn: #f0b23a;
--st-bad: #ef6464;
--st-off: #8a95a1;
--st-empty: #3a414a;
--st-lost: #5d6671;
--led-ok: #2fd12f;
--led-warn: #fab219;
--led-bad: #ef5353;
--el-color-primary: #5b95f2;
--el-color-primary-light-3: #3f6fb8;
--el-color-primary-light-5: #2f548b;
--el-color-primary-light-7: #203a5f;
--el-color-primary-light-8: #1a2f4c;
--el-color-primary-light-9: #142338;
--el-color-primary-dark-2: #7eabf5;
--el-bg-color: #171b21;
--el-bg-color-overlay: #1c2128;
--el-fill-color-blank: #171b21;
--el-border-color: #2f363f;
--el-border-color-light: #2a3038;
--el-border-color-lighter: #242a31;
}
* { box-sizing: border-box; }
html, body { background: var(--plane); }
body { margin: 0; font-family: var(--ui); color: var(--ink); font-size: 14px; line-height: 1.5;
-webkit-font-smoothing: antialiased; }
a { color: var(--accent); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* 面板:页面上的一级容器 */
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-panel); padding: 16px; }
.muted { color: var(--muted); font-size: 13px; }
.readout { font-family: var(--readout); font-variant-numeric: tabular-nums; letter-spacing: .01em; }
.bad { color: var(--st-bad); }
.warn { color: var(--st-warn); }
/* 面板标题:粗一点的正文字,不加眉标、不全大写 */
.ttl { font-weight: 600; font-size: 15px; margin: 0 0 10px; color: var(--ink); }
/* 状态灯 */
.led { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0;
background: var(--st-empty); box-shadow: 0 0 0 2px var(--panel); }
.led.ok { background: var(--led-ok); }
.led.busy { background: var(--st-busy); }
.led.warn { background: var(--led-warn); }
.led.bad { background: var(--led-bad); }
.led.off { background: var(--st-off); }
.led.lost { background: var(--st-lost); }
@media (prefers-reduced-motion: no-preference) {
.led.busy { animation: led-blink 1s steps(2, jump-none) infinite; }
}
@keyframes led-blink { 50% { opacity: .35; } }
/* 表格:去掉 Element Plus 的斑马感,行线用细线 */
.el-table { --el-table-border-color: var(--line-soft); --el-table-header-bg-color: transparent;
--el-table-tr-bg-color: transparent; --el-table-bg-color: transparent; --el-table-row-hover-bg-color: var(--bay);
--el-table-text-color: var(--ink); --el-table-header-text-color: var(--muted); }
.el-table th.el-table__cell { font-weight: 500; }
code { font-size: 12px; background: var(--bay); border: 1px solid var(--line-soft); padding: 0 4px; border-radius: 4px;
word-break: break-all; }
/* 曲线卡:标题 + 时间范围一行,下面一行小图例,再下面是图,最底下一行灰字说明 */
.chart-card .hh { display: flex; justify-content: space-between; align-items: center; gap: 8px 12px; flex-wrap: wrap; }
.chart-card .hh .ttl { margin: 0; }
.chart-card .legend { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 12px; color: var(--ink-2);
margin-top: 10px; min-height: 20px; align-items: center; }
.chart-card .legend .hint { color: var(--muted); }
.chart-card .legend i { display: inline-block; width: 14px; height: 3px; border-radius: 2px; margin-right: 6px; vertical-align: middle; }
.chart-card .note { margin: 2px 0 0; font-size: 12px; }
+9
查看文件
@@ -3,6 +3,15 @@
export const mA = ua => (ua == null ? '—' : (ua / 1000).toFixed(ua >= 100000 || ua <= -100000 ? 0 : 1))
export const V = mv => (mv == null ? '—' : (mv / 1000).toFixed(3))
export const C = mc => (mc == null ? '—' : (mc / 1000).toFixed(1))
// 功率 = 电压 × 电流:mV × µA = nW,显示成 W(两位小数,手机主板一般零点几到十几瓦)
export const mW = (mv, ua) => (mv == null || ua == null ? null : mv * ua / 1e6)
export const W = (mv, ua) => (mv == null || ua == null ? '—' : (mv * ua / 1e9).toFixed(2))
// 五路主板合计功率(mW):只算读得到的路;一路都读不到回 null
export function railsMw(rails) {
let s = null
for (const r of rails || []) if (r?.mv != null && r?.ua != null) s = (s ?? 0) + r.mv * r.ua / 1e6
return s
}
export function ago(ts) {
if (!ts) return '从未'