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") @app.get("/api/boards/{board}/history")
def board_history(board: str, start: int, end: Optional[int] = None): def board_history(board: str, start: int, end: Optional[int] = None):
"""整板曲线(温度 / 风扇 / 输入),见 Db.board_history。""" """整板曲线(温度 / 风扇 / 输入 / 五路主板合计功率),见 Db.board_history。"""
_board_or_404(hub, board) _board_or_404(hub, board)
return hub.db.board_history(board, start, end or now_ms()) 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: if d["arg"] == 3:
d["name"] += ":" + protocol.OTA_ERR_NAMES.get(d["mv"], str(d["mv"])) d["name"] += ":" + protocol.OTA_ERR_NAMES.get(d["mv"], str(d["mv"]))
d["severe"] = d["arg"] in (3, 5) 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) out.append(d)
return out 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: def board_history(self, board: str, t0: int, t1: int, max_points: int = 1500) -> dict:
"""[t0, t1) 的整板曲线:两路 NTC 温度(m°C,带每桶 min/max)、风扇占空比(‰)、输入电压电流。 """[t0, t1) 的整板曲线:两路 NTC 温度(m°C,带每桶 min/max)、风扇占空比(‰)、输入电压电流。
取数规则同 `history()`:近期短跨度回原始 1 s,否则回分钟聚合。 取数规则同 `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) span = max(1, t1 - t0)
raw_ok = t0 >= now_ms() - RAW_KEEP_S * 1000 and span <= 6 * 3600 * 1000 raw_ok = t0 >= now_ms() - RAW_KEEP_S * 1000 and span <= 6 * 3600 * 1000
if raw_ok: 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 " "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)) "WHERE board_id=? AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, t0, t1))
res = "minute" 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, 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"]] "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]} rails_mw.get(r["t"])] for r in rows]}
+142 -140
查看文件
@@ -1,140 +1,142 @@
"""板侧协议在服务器这一侧的镜像(需求文档 §10.8「A12 / A14 定稿」)。 """板侧协议在服务器这一侧的镜像(需求文档 §10.8「A12 / A14 定稿」)。
改这里之前先看固件: 改这里之前先看固件:
· 命令与错误码 —— firmware/main/cmd.h(数字冻结,只许追加) · 命令与错误码 —— firmware/main/cmd.h(数字冻结,只许追加)
· 事件类型 —— firmware/main/evlog.h 的 log_ev_t(数字就是 flash 格式,冻结) · 事件类型 —— firmware/main/evlog.h 的 log_ev_t(数字就是 flash 格式,冻结)
· topic —— firmware/main/mlink.h · topic —— firmware/main/mlink.h
两边对不上时**不会报错**:事件会被显示成别的类型、命令会被板子回 unknown_cmd。 两边对不上时**不会报错**:事件会被显示成别的类型、命令会被板子回 unknown_cmd。
""" """
import re import re
import time import time
from typing import Dict, Optional, Tuple from typing import Dict, Optional, Tuple
TOPIC_ROOT = "phonerail" TOPIC_ROOT = "phonerail"
TOPIC_RE = re.compile(r"^phonerail/([0-9a-f]{12})/(cmd|rsp|tele|state)$") TOPIC_RE = re.compile(r"^phonerail/([0-9a-f]{12})/(cmd|rsp|tele|state)$")
RAILS = 5 RAILS = 5
# log_ev_t(firmware/main/evlog.h)。⚠️ 数字冻结。 # log_ev_t(firmware/main/evlog.h)。⚠️ 数字冻结。
EVENT_TYPES: Dict[int, Tuple[str, str]] = { EVENT_TYPES: Dict[int, Tuple[str, str]] = {
0: ("NONE", "(空槽位)"), 0: ("NONE", "(空槽位)"),
1: ("BOOT", "板子上电"), 1: ("BOOT", "板子上电"),
2: ("LOST", "事件丢失"), 2: ("LOST", "事件丢失"),
3: ("OVER_VOLT", "过压关路"), 3: ("OVER_VOLT", "过压关路"),
4: ("UNDER_VOLT", "欠压关路"), 4: ("UNDER_VOLT", "欠压关路"),
5: ("REVERSE", "反灌断 VBUS"), 5: ("REVERSE", "反灌断 VBUS"),
6: ("PUSHED_UP", "顶高断 VBUS"), 6: ("PUSHED_UP", "顶高断 VBUS"),
7: ("EFUSE_FAULT", "eFuse 故障"), 7: ("EFUSE_FAULT", "eFuse 故障"),
8: ("PG_CHANGE", "PG 变化"), 8: ("PG_CHANGE", "PG 变化"),
9: ("I2C_LOST", "I²C 失联"), 9: ("I2C_LOST", "I²C 失联"),
10: ("THERMAL_DERATE", "过温降载"), 10: ("THERMAL_DERATE", "过温降载"),
11: ("FAN_STALL", "风扇停转"), 11: ("FAN_STALL", "风扇停转"),
12: ("REBOOT", "重启序列"), 12: ("REBOOT", "重启序列"),
13: ("CMD_REJECT", "命令被拒"), 13: ("CMD_REJECT", "命令被拒"),
14: ("MUX_LOST", "mux 失联全断"), 14: ("MUX_LOST", "mux 失联全断"),
15: ("SLOT_EMPTY", "刀片不在位"), 15: ("SLOT_EMPTY", "刀片不在位"),
16: ("PD_LIMIT", "PD 限功率"), 16: ("PD_LIMIT", "PD 限功率"),
17: ("PG_TIMEOUT", "上电等不到 PG"), # A15:arg bit0 = FLT# 在报,bit1 = 按电压判 17: ("PG_TIMEOUT", "上电等不到 PG"), # A15:arg bit0 = FLT# 在报,bit1 = 按电压判
18: ("OTA", "固件升级"), # C2:arg 1 开始 / 2 就绪 / 3 失败(mV 列=原因) / 4 自证健康 / 5 回滚 / 6 为换固件重启 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: "为换固件重启", # LOG_EV_OTA 的 arg(firmware/main/ota.h 的 ota_ev_t)与失败原因(ota_err_t,失败那条的 mV 列)。⚠️ 冻结。
7: "计划内重启:接管、没断电"} OTA_EV_NAMES = {1: "开始下载", 2: "已就绪", 3: "失败", 4: "新固件自证健康", 5: "回滚", 6: "为换固件重启",
OTA_ERR_NAMES = {1: "下载失败", 2: "大小不对", 3: "哈希不对", 4: "写 flash 失败", 5: "镜像校验不过", 7: "计划内重启:接管、没断电"}
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 单独标红 # 严重程度:界面上标红的那些(会断电 / 断 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 就是它。 BOOT_CHECK_FAILED = 0x80 # LOG_EV_BOOT_CHECK 的 arg bit7
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", # cmd_id_t(firmware/main/cmd.h),CMD_REJECT 事件的 arg 就是它。
13: "ota"} 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",
KEYS = ("pwr", "vup", "vdn") 13: "ota"}
KEY_MAX_MS = 15000
MV_MIN, MV_MAX = 3524, 4010 KEYS = ("pwr", "vup", "vdn")
KEY_MAX_MS = 15000
MV_MIN, MV_MAX = 3524, 4010
class CmdError(ValueError):
"""请求在发出去之前就被服务器拒了(参数不对)。"""
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) def _rail(v) -> int:
return v 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) def _bool(v, name: str) -> bool:
return v if not isinstance(v, bool):
raise CmdError("%s 必须是 true/false" % name)
return v
def build_command(req: dict) -> dict:
"""网页 / 自动恢复发来的请求 → 发给板子的命令(不含 seq)。
def build_command(req: dict) -> dict:
**白名单**:只放行管理界面该有的命令。`wifi` / `mqtt` 板子本来就只收 USB, """网页 / 自动恢复发来的请求 → 发给板子的命令(不含 seq)。
这里再拦一道,免得界面上出现一个永远失败的按钮;`events` 由服务器自己同步。
服务器这一侧先校验一遍,是为了让操作者立刻看到中文的错误,而不是等板子回 `range`; **白名单**:只放行管理界面该有的命令。`wifi` / `mqtt` 板子本来就只收 USB,
**板子的校验仍然是唯一的权威**(SR-13 在固件里)。 这里再拦一道,免得界面上出现一个永远失败的按钮;`events` 由服务器自己同步。
""" 服务器这一侧先校验一遍,是为了让操作者立刻看到中文的错误,而不是等板子回 `range`;
cmd = req.get("cmd") **板子的校验仍然是唯一的权威**(SR-13 在固件里)。
if cmd == "status" or cmd == "net": """
return {"cmd": cmd} cmd = req.get("cmd")
if cmd == "set_voltage": if cmd == "status" or cmd == "net":
mv = req.get("mv") return {"cmd": cmd}
if not isinstance(mv, int) or isinstance(mv, bool) or not MV_MIN <= mv <= MV_MAX: if cmd == "set_voltage":
raise CmdError("电压必须是 %d~%d mV" % (MV_MIN, MV_MAX)) mv = req.get("mv")
return {"cmd": cmd, "rail": _rail(req.get("rail")), "mv": mv} if not isinstance(mv, int) or isinstance(mv, bool) or not MV_MIN <= mv <= MV_MAX:
if cmd in ("power_cycle", "edl"): raise CmdError("电压必须是 %d~%d mV" % (MV_MIN, MV_MAX))
return {"cmd": cmd, "rail": _rail(req.get("rail"))} return {"cmd": cmd, "rail": _rail(req.get("rail")), "mv": mv}
if cmd == "key": if cmd in ("power_cycle", "edl"):
keys = req.get("keys") return {"cmd": cmd, "rail": _rail(req.get("rail"))}
if not isinstance(keys, str) or not keys or any(k not in KEYS for k in keys.split("+")): if cmd == "key":
raise CmdError("keys 只能是 pwr / vup / vdn 用 + 连起来") keys = req.get("keys")
ms = req.get("ms") if not isinstance(keys, str) or not keys or any(k not in KEYS for k in keys.split("+")):
if not isinstance(ms, int) or isinstance(ms, bool) or not 1 <= ms <= KEY_MAX_MS: raise CmdError("keys 只能是 pwr / vup / vdn 用 + 连起来")
raise CmdError("按键时长必须是 1~%d ms" % KEY_MAX_MS) ms = req.get("ms")
return {"cmd": cmd, "rail": _rail(req.get("rail")), "keys": keys, "ms": ms} if not isinstance(ms, int) or isinstance(ms, bool) or not 1 <= ms <= KEY_MAX_MS:
if cmd in ("vbus", "power"): raise CmdError("按键时长必须是 1~%d ms" % KEY_MAX_MS)
return {"cmd": cmd, "rail": _rail(req.get("rail")), "on": _bool(req.get("on"), "on")} return {"cmd": cmd, "rail": _rail(req.get("rail")), "keys": keys, "ms": ms}
if cmd == "fan": if cmd in ("vbus", "power"):
duty = req.get("duty") return {"cmd": cmd, "rail": _rail(req.get("rail")), "on": _bool(req.get("on"), "on")}
if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000: if cmd == "fan":
raise CmdError("风扇下限必须是 0~1000‰(0 = 自动)") duty = req.get("duty")
return {"cmd": cmd, "duty": duty} if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000:
raise CmdError("不支持的命令:%r" % (cmd,)) 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")
def is_mutating(cmd: str) -> bool:
return cmd not in ("status", "events", "net")
class SeqGen:
"""seq 生成器:以毫秒时间戳为底、进程内严格递增,落在 1~2^32−1。
class SeqGen:
板子的判重窗口按「值相等」判(cmd.h),不要求单调,只要求**不撞**: """seq 生成器:以毫秒时间戳为底、进程内严格递增,落在 1~2^32−1。
服务重启之后从当前时间重新起步,不会撞上重启前刚用过的那几十个号。
""" 板子的判重窗口按「值相等」判(cmd.h),不要求单调,只要求**不撞**:
服务重启之后从当前时间重新起步,不会撞上重启前刚用过的那几十个号。
def __init__(self, clock=time.time): """
self._clock = clock
self._last = 0 def __init__(self, clock=time.time):
self._clock = clock
def next(self) -> int: self._last = 0
now = int(self._clock() * 1000)
v = max(self._last + 1, now) def next(self) -> int:
self._last = v now = int(self._clock() * 1000)
return (v - 1) % 0xFFFFFFFF + 1 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 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)
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 连模拟板子。""" """HTTP / WebSocket 接口:用 TestClient 起完整的应用,Hub 经进程内假 broker 连模拟板子。"""
import time import time
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from prrack.api import create_app from prrack.api import create_app
from prrack.bus import MemBroker, MemBus from prrack.bus import MemBroker, MemBus
from prrack.db import Db, now_ms from prrack.db import Db, now_ms
from prrack.hub import Hub from prrack.hub import Hub
from prrack.recovery import RecoveryManager from prrack.recovery import RecoveryManager
from prrack.simboard import SimBoard from prrack.simboard import SimBoard
B = "b81f3fc9c8bc" B = "b81f3fc9c8bc"
@pytest.fixture @pytest.fixture
def env(tmp_path): def env(tmp_path):
broker = MemBroker() broker = MemBroker()
db = Db(str(tmp_path / "a.db")) db = Db(str(tmp_path / "a.db"))
hub = Hub(db, MemBus(broker), attempt_timeout=0.2) hub = Hub(db, MemBus(broker), attempt_timeout=0.2)
rec = RecoveryManager(hub, now_ms) rec = RecoveryManager(hub, now_ms)
sim = SimBoard(MemBus(broker), B, present=(True, True, True, False, True)) sim = SimBoard(MemBus(broker), B, present=(True, True, True, False, True))
dist = tmp_path / "dist" dist = tmp_path / "dist"
(dist / "assets").mkdir(parents=True) (dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text("<html>rack</html>", encoding="utf-8") (dist / "index.html").write_text("<html>rack</html>", encoding="utf-8")
async def up(): async def up():
await hub.start() await hub.start()
sim.start() sim.start()
sim.tick(now_ms()) sim.tick(now_ms())
app = create_app(hub, rec, str(dist), startup=[up], shutdown=[hub.stop]) app = create_app(hub, rec, str(dist), startup=[up], shutdown=[hub.stop])
with TestClient(app) as c: with TestClient(app) as c:
deadline = time.time() + 2 deadline = time.time() + 2
while B not in hub.latest and time.time() < deadline: while B not in hub.latest and time.time() < deadline:
time.sleep(0.01) time.sleep(0.01)
yield c, hub, sim, db yield c, hub, sim, db
def test_boards_and_snapshot(env): def test_boards_and_snapshot(env):
c, hub, sim, db = env c, hub, sim, db = env
boards = c.get("/api/boards").json() boards = c.get("/api/boards").json()
assert [b["id"] for b in boards] == [B] assert [b["id"] for b in boards] == [B]
assert boards[0]["online"] is True and len(boards[0]["phones"]) == 5 assert boards[0]["online"] is True and len(boards[0]["phones"]) == 5
assert c.get("/api/boards/nope").status_code == 404 assert c.get("/api/boards/nope").status_code == 404
def test_command_records_the_portal_user(env): def test_command_records_the_portal_user(env):
c, hub, sim, db = env c, hub, sim, db = env
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": False}, r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": False},
headers={"X-Auth-User": "zhouyang"}) headers={"X-Auth-User": "zhouyang"})
assert r.status_code == 200 and r.json()["ok"] is True assert r.status_code == 200 and r.json()["ok"] is True
assert sim.rails[0].en is False assert sim.rails[0].en is False
a = c.get("/api/audit").json()[0] a = c.get("/api/audit").json()[0]
assert a["user"] == "zhouyang" and a["request"]["cmd"] == "power" and a["ok"] == 1 assert a["user"] == "zhouyang" and a["request"]["cmd"] == "power" and a["ok"] == 1
# 没有门户头(服务器本机直接 curl)记作 local # 没有门户头(服务器本机直接 curl)记作 local
c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": True}) c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": True})
assert c.get("/api/audit").json()[0]["user"] == "local" assert c.get("/api/audit").json()[0]["user"] == "local"
def test_bad_commands_never_reach_the_board(env): def test_bad_commands_never_reach_the_board(env):
c, hub, sim, db = env c, hub, sim, db = env
n = len(sim.received) n = len(sim.received)
for body in ({"cmd": "wifi", "ssid": "x"}, {"cmd": "set_voltage", "rail": 1, "mv": 4300}, 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"}): {"cmd": "key", "rail": 1, "keys": "pwr", "ms": 60000}, {"cmd": "rm -rf"}):
r = c.post("/api/boards/%s/cmd" % B, json=body) r = c.post("/api/boards/%s/cmd" % B, json=body)
assert r.status_code == 400, body assert r.status_code == 400, body
assert len(sim.received) == n assert len(sim.received) == n
def test_board_rejection_is_passed_through(env): def test_board_rejection_is_passed_through(env):
c, hub, sim, db = env c, hub, sim, db = env
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power_cycle", "rail": 4}) r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power_cycle", "rail": 4})
assert r.status_code == 200 and r.json()["err"] == "empty" assert r.status_code == 200 and r.json()["err"] == "empty"
def test_timeout_is_504(env): def test_timeout_is_504(env):
c, hub, sim, db = env c, hub, sim, db = env
sim.drop_next_replies = 99 sim.drop_next_replies = 99
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 2, "on": True}) r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 2, "on": True})
assert r.status_code == 504 assert r.status_code == 504
def test_phone_settings_toggle_auto_recovery(env): def test_phone_settings_toggle_auto_recovery(env):
c, hub, sim, db = env c, hub, sim, db = env
r = c.patch("/api/boards/%s/rails/3" % B, json={"label": "A-03", "auto_recover": True}) r = c.patch("/api/boards/%s/rails/3" % B, json={"label": "A-03", "auto_recover": True})
assert r.status_code == 200 assert r.status_code == 200
ph = [p for p in r.json()["phones"] if p["rail"] == 3][0] ph = [p for p in r.json()["phones"] if p["rail"] == 3][0]
assert ph["label"] == "A-03" and ph["auto_recover"] == 1 assert ph["label"] == "A-03" and ph["auto_recover"] == 1
rec = c.get("/api/recovery").json() rec = c.get("/api/recovery").json()
assert rec["params"]["window_s"] == 300 assert rec["params"]["window_s"] == 300
r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1}) 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 # 负数不收 assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 5000 # 负数不收
def test_history_after_telemetry(env): def test_history_after_telemetry(env):
c, hub, sim, db = env c, hub, sim, db = env
for _ in range(3): for _ in range(3):
sim.tick(now_ms()) sim.tick(now_ms())
time.sleep(0.1) time.sleep(0.1)
hub.flush() hub.flush()
h = c.get("/api/boards/%s/rails/1/history" % B, params={"start": now_ms() - 60000}).json() 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["resolution"] == "raw" and len(h["points"]) >= 1
assert h["points"][0][2] is not None assert h["points"][0][2] is not None
def test_websocket_hello_and_live_tele(env): def test_websocket_hello_and_live_tele(env):
c, hub, sim, db = env c, hub, sim, db = env
with c.websocket_connect("/api/ws", headers={"X-Auth-User": "zhouyang"}) as ws: with c.websocket_connect("/api/ws", headers={"X-Auth-User": "zhouyang"}) as ws:
hello = ws.receive_json() hello = ws.receive_json()
assert hello["t"] == "hello" and hello["user"] == "zhouyang" and hello["boards"][0]["id"] == B assert hello["t"] == "hello" and hello["user"] == "zhouyang" and hello["boards"][0]["id"] == B
sim.tick(now_ms()) sim.tick(now_ms())
msg = ws.receive_json() msg = ws.receive_json()
while msg["t"] != "tele": while msg["t"] != "tele":
msg = ws.receive_json() msg = ws.receive_json()
assert msg["board"] == B and len(msg["status"]["rails"]) == 5 assert msg["board"] == B and len(msg["status"]["rails"]) == 5
def test_spa_fallback_is_not_cached(env): def test_spa_fallback_is_not_cached(env):
c, hub, sim, db = env c, hub, sim, db = env
r = c.get("/some/deep/route") r = c.get("/some/deep/route")
assert r.status_code == 200 and "rack" in r.text assert r.status_code == 200 and "rack" in r.text
assert r.headers["cache-control"] == "no-cache" assert r.headers["cache-control"] == "no-cache"
def test_a_real_websocket_library_is_installed(): def test_a_real_websocket_library_is_installed():
"""TestClient 自带 WS 实现,上面那条 WS 用例在没装 websockets 的机器上也是绿的; """TestClient 自带 WS 实现,上面那条 WS 用例在没装 websockets 的机器上也是绿的;
而真 uvicorn 缺了它会把 /api/ws 当普通 GET(落到 SPA 兜底,回 200 HTML)。 而真 uvicorn 缺了它会把 /api/ws 当普通 GET(落到 SPA 兜底,回 200 HTML)。
deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。""" deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。"""
from prrack.main import ws_library_ok from prrack.main import ws_library_ok
assert ws_library_ok() assert ws_library_ok()
def test_board_history_raw_and_minute(env): def test_board_history_raw_and_minute(env):
c, hub, sim, db = env c, hub, sim, db = env
base = now_ms() // 60000 * 60000 - 3 * 60000 base = now_ms() // 60000 * 60000 - 3 * 60000
rows = [(base + i * 1000, B, 12000, 1500000, 35000 + i, 40000 - i, 300) for i in range(120)] 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) db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)", rows)
h = c.get("/api/boards/%s/history" % B, params={"start": base, "end": base + 120000}).json() h = c.get("/api/boards/%s/history" % B, params={"start": base, "end": base + 120000}).json()
assert h["resolution"] == "raw" and len(h["points"]) >= 100 assert h["resolution"] == "raw" and len(h["points"]) >= 100
p = h["points"][0] p = h["points"][0]
assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000 assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000
# 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾 # 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾
assert db.downsample(base + 180000) > 0 assert db.downsample(base + 180000) > 0
m = db.board_history(B, base, base + 120000 + 1) # 跨度小也能强制走聚合:t0 早于原始保留期时 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,)) 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_min"] for r in rows_min][:2] == [35000, 35060]
assert [r["t0_max"] for r in rows_min][:2] == [35059, 35119] 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 rows_min[0]["t1_min"] == 40000 - 59 and rows_min[0]["fan_avg"] == 300
assert m["resolution"] == "raw" assert m["resolution"] == "raw"
old = db.board_history(B, base - 10 * 86400000, base + 120000) old = db.board_history(B, base - 10 * 86400000, base + 120000)
# 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大 # 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大
assert old["resolution"] == "minute" and old["points"][0][2] == 35000 assert old["resolution"] == "minute" and old["points"][0][2] == 35000
assert max(p[3] for p in old["points"]) == 35119 assert max(p[3] for p in old["points"]) == 35119
assert c.get("/api/boards/nope/history", params={"start": 0}).status_code == 404 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 对接模拟板子)+ 自动恢复状态机。 """协议镜像 + Hub(经进程内假 broker 对接模拟板子)+ 自动恢复状态机。
和固件测试同一个口径:断言打在「板子那头真的收到 / 执行了什么」上(SimBoard.received、 和固件测试同一个口径:断言打在「板子那头真的收到 / 执行了什么」上(SimBoard.received、
它的 rails 状态),不看 Hub 自己回了什么 —— 重传换了 seq 的现象恰恰是 Hub 这边一切正常、 它的 rails 状态),不看 Hub 自己回了什么 —— 重传换了 seq 的现象恰恰是 Hub 这边一切正常、
手机被按了两遍。""" 手机被按了两遍。"""
import asyncio import asyncio
import json import json
import pytest import pytest
from prrack import protocol from prrack import protocol
from prrack.bus import MemBroker, MemBus, topic_matches from prrack.bus import MemBroker, MemBus, topic_matches
from prrack.db import Db from prrack.db import Db
from prrack.hub import CmdTimeout, Hub from prrack.hub import CmdTimeout, Hub
from prrack.recovery import GAVE_UP, HARD, IDLE, SOFT, Params, PhoneRecovery, RecoveryManager from prrack.recovery import GAVE_UP, HARD, IDLE, SOFT, Params, PhoneRecovery, RecoveryManager
from prrack.simboard import HUNG, NORMAL, OFF, SimBoard from prrack.simboard import HUNG, NORMAL, OFF, SimBoard
B = "b81f3fc9c8bc" B = "b81f3fc9c8bc"
def run(coro): def run(coro):
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
try: try:
return loop.run_until_complete(coro) return loop.run_until_complete(coro)
finally: # 收掉 Hub 的后台任务(flusher / maint),免得退出时刷一屏 pending 警告 finally: # 收掉 Hub 的后台任务(flusher / maint),免得退出时刷一屏 pending 警告
tasks = asyncio.all_tasks(loop) tasks = asyncio.all_tasks(loop)
for t in tasks: for t in tasks:
t.cancel() t.cancel()
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
loop.close() loop.close()
async def settle(): async def settle():
for _ in range(5): for _ in range(5):
await asyncio.sleep(0) await asyncio.sleep(0)
def rig(tmp_path, present=(True,) * 5, timeout=0.05): def rig(tmp_path, present=(True,) * 5, timeout=0.05):
broker = MemBroker() broker = MemBroker()
db = Db(str(tmp_path / "t.db")) db = Db(str(tmp_path / "t.db"))
hub = Hub(db, MemBus(broker), attempt_timeout=timeout) hub = Hub(db, MemBus(broker), attempt_timeout=timeout)
sim = SimBoard(MemBus(broker), B, present=present) sim = SimBoard(MemBus(broker), B, present=present)
return hub, sim, db return hub, sim, db
# ---- 协议镜像 ----------------------------------------------------------------- # ---- 协议镜像 -----------------------------------------------------------------
def test_build_command_whitelist_and_ranges(): def test_build_command_whitelist_and_ranges():
assert protocol.build_command({"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}) == \ assert protocol.build_command({"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}) == \
{"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000} {"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}
bad = [ bad = [
{"cmd": "wifi", "ssid": "x"}, # 只收 USB 的命令不给界面 {"cmd": "wifi", "ssid": "x"}, # 只收 USB 的命令不给界面
{"cmd": "events"}, # 服务器自己同步 {"cmd": "events"}, # 服务器自己同步
{"cmd": "set_voltage", "rail": 1, "mv": 4011}, # SR-13 之上 {"cmd": "set_voltage", "rail": 1, "mv": 4011}, # SR-13 之上
{"cmd": "set_voltage", "rail": 1, "mv": "3900"}, {"cmd": "set_voltage", "rail": 1, "mv": "3900"},
{"cmd": "key", "rail": 1, "keys": "pwr+home", "ms": 100}, {"cmd": "key", "rail": 1, "keys": "pwr+home", "ms": 100},
{"cmd": "key", "rail": 1, "keys": "pwr", "ms": 15001}, {"cmd": "key", "rail": 1, "keys": "pwr", "ms": 15001},
{"cmd": "power", "rail": 6, "on": True}, {"cmd": "power", "rail": 6, "on": True},
{"cmd": "power", "rail": True, "on": True}, # bool 不是 int {"cmd": "power", "rail": True, "on": True}, # bool 不是 int
{"cmd": "vbus", "rail": 1, "on": 1}, {"cmd": "vbus", "rail": 1, "on": 1},
{"cmd": "fan", "duty": 1001}, {"cmd": "fan", "duty": 1001},
] ]
for b in bad: for b in bad:
with pytest.raises(protocol.CmdError): with pytest.raises(protocol.CmdError):
protocol.build_command(b) protocol.build_command(b)
def test_seq_is_strictly_increasing_and_in_range(): def test_seq_is_strictly_increasing_and_in_range():
t = [1700000000.0] t = [1700000000.0]
g = protocol.SeqGen(clock=lambda: t[0]) g = protocol.SeqGen(clock=lambda: t[0])
a = [g.next() for _ in range(5)] a = [g.next() for _ in range(5)]
assert a == sorted(set(a)) and all(1 <= x <= 0xFFFFFFFF for x in a) assert a == sorted(set(a)) and all(1 <= x <= 0xFFFFFFFF for x in a)
t[0] -= 100 # 时钟往回跳也不许重复 t[0] -= 100 # 时钟往回跳也不许重复
assert g.next() > a[-1] or g.next() == 1 assert g.next() > a[-1] or g.next() == 1
def test_event_table_matches_firmware_numbers(): def test_event_table_matches_firmware_numbers():
# firmware/main/evlog.h 冻结的数字(抽几个钉住) # firmware/main/evlog.h 冻结的数字(抽几个钉住)
assert protocol.EVENT_TYPES[3][0] == "OVER_VOLT" assert protocol.EVENT_TYPES[3][0] == "OVER_VOLT"
assert protocol.EVENT_TYPES[12][0] == "REBOOT" assert protocol.EVENT_TYPES[12][0] == "REBOOT"
assert protocol.EVENT_TYPES[13][0] == "CMD_REJECT" assert protocol.EVENT_TYPES[13][0] == "CMD_REJECT"
assert protocol.EVENT_TYPES[16][0] == "PD_LIMIT" assert protocol.EVENT_TYPES[16][0] == "PD_LIMIT"
assert protocol.CMD_NAMES[9] == "power" and protocol.CMD_NAMES[12] == "net" 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[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) def test_topic_matching():
assert not topic_matches("phonerail/+/rsp", "phonerail/%s/tele" % B) assert topic_matches("phonerail/+/rsp", "phonerail/%s/rsp" % B)
assert topic_matches("phonerail/#", "phonerail/x/y") assert not topic_matches("phonerail/+/rsp", "phonerail/%s/tele" % B)
assert protocol.parse_topic("phonerail/%s/tele" % B) == (B, "tele") assert topic_matches("phonerail/#", "phonerail/x/y")
assert protocol.parse_topic("evse/123/state") is None assert protocol.parse_topic("phonerail/%s/tele" % B) == (B, "tele")
assert protocol.parse_topic("evse/123/state") is None
# ---- Hub ---------------------------------------------------------------------
# ---- Hub ---------------------------------------------------------------------
def test_tele_is_ingested_and_board_discovered(tmp_path):
hub, sim, db = rig(tmp_path) def test_tele_is_ingested_and_board_discovered(tmp_path):
hub, sim, db = rig(tmp_path)
async def go():
await hub.start() async def go():
sim.start() await hub.start()
sim.tick(1000) sim.start()
await settle() sim.tick(1000)
hub.flush() await settle()
hub.flush()
run(go())
snap = hub.snapshot(B) run(go())
assert snap["online"] and snap["fw"] == "0.1.0-sim" snap = hub.snapshot(B)
assert snap["status"]["rails"][0]["en"] is True assert snap["online"] and snap["fw"] == "0.1.0-sim"
assert db.q("SELECT COUNT(*) c FROM tele")[0]["c"] == 5 assert snap["status"]["rails"][0]["en"] is True
assert len(snap["phones"]) == 5 and snap["phones"][0]["auto_recover"] == 0 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) def test_command_round_trip_and_audit(tmp_path):
hub, sim, db = rig(tmp_path)
async def go():
await hub.start() async def go():
sim.start() await hub.start()
return await hub.call(B, {"cmd": "power", "rail": 2, "on": False}, "alice") sim.start()
return await hub.call(B, {"cmd": "power", "rail": 2, "on": False}, "alice")
rsp = run(go())
assert rsp["ok"] is True rsp = run(go())
assert sim.rails[1].en is False assert rsp["ok"] is True
row = db.q("SELECT * FROM audit")[0] assert sim.rails[1].en is False
assert row["user"] == "alice" and row["rail"] == 2 and row["ok"] == 1 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** 重发,板子判重,只执行一次。""" def test_retransmit_reuses_the_same_seq_and_executes_once(tmp_path):
hub, sim, db = rig(tmp_path) """⭐ 回应丢了一次:Hub 用**同一个 seq** 重发,板子判重,只执行一次。"""
sim.drop_next_replies = 1 hub, sim, db = rig(tmp_path)
sim.drop_next_replies = 1
async def go():
await hub.start() async def go():
sim.start() await hub.start()
return await hub.call(B, {"cmd": "power_cycle", "rail": 1}, "bob") 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 rsp = run(go())
sent = [r for r in sim.received if r.get("cmd") == "power_cycle"] assert rsp["ok"] is True and rsp.get("dup") is True
assert len(sent) == 2 and sent[0]["seq"] == sent[1]["seq"] sent = [r for r in sim.received if r.get("cmd") == "power_cycle"]
assert [e[3] for e in sim.events].count(12) == 1 # 重启事件只记了一次 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) def test_timeout_is_audited_and_raised(tmp_path):
sim.drop_next_replies = 99 hub, sim, db = rig(tmp_path)
sim.drop_next_replies = 99
async def go():
await hub.start() async def go():
sim.start() await hub.start()
with pytest.raises(CmdTimeout): sim.start()
await hub.call(B, {"cmd": "power", "rail": 1, "on": True}, "carol") with pytest.raises(CmdTimeout):
await hub.call(B, {"cmd": "power", "rail": 1, "on": True}, "carol")
run(go())
row = db.q("SELECT * FROM audit")[0] run(go())
assert row["ok"] == 0 and json.loads(row["response"])["err"] == "timeout" row = db.q("SELECT * FROM audit")[0]
assert len({r["seq"] for r in sim.received}) == 1 # 三次都是同一个 seq 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) def test_events_are_synced_incrementally(tmp_path):
for i in range(19): hub, sim, db = rig(tmp_path)
sim._ev(1, 8, i) # 加上 BOOT 共 20 条:要分 3 页 for i in range(19):
sim._ev(1, 8, i) # 加上 BOOT 共 20 条:要分 3 页
async def go():
await hub.start() async def go():
sim.start() await hub.start()
sim.tick(1000) sim.start()
for _ in range(20): sim.tick(1000)
await settle() for _ in range(20):
sim._ev(2, 3, 0, 4200, 1) # 又来一条过压 await settle()
sim.tick(2000) sim._ev(2, 3, 0, 4200, 1) # 又来一条过压
for _ in range(20): sim.tick(2000)
await settle() for _ in range(20):
await settle()
run(go())
seqs = [r["seq"] for r in db.q("SELECT seq FROM events ORDER BY seq")] run(go())
assert seqs == list(range(21)) seqs = [r["seq"] for r in db.q("SELECT seq FROM events ORDER BY seq")]
assert db.q("SELECT ev_cursor FROM boards")[0]["ev_cursor"] == 21 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)) 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() async def go():
sim.start() await hub.start()
return await hub.call(B, {"cmd": "power", "rail": 4, "on": True}, "dave") 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"}
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)
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 = [] def feed(ph, t0, secs, ua_fn):
for s in range(secs): out = []
st, act = ph.feed(t0 + s * 1000, ua_fn(s), 3) for s in range(secs):
if st or act: st, act = ph.feed(t0 + s * 1000, ua_fn(s), 3)
out.append((t0 + s * 1000, st, act)) if st or act:
return out out.append((t0 + s * 1000, st, act))
return out
def test_flat_current_escalates_soft_then_hard_then_gives_up():
ph = PhoneRecovery(P) def test_flat_current_escalates_soft_then_hard_then_gives_up():
r = feed(ph, 0, 61, lambda s: 412000) ph = PhoneRecovery(P)
assert r[-1][1] == SOFT and r[-1][2][1] == {"cmd": "key", "rail": 3, "keys": "pwr", "ms": 10000} r = feed(ph, 0, 61, lambda s: 412000)
t = r[-1][0] assert r[-1][1] == SOFT and r[-1][2][1] == {"cmd": "key", "rail": 3, "keys": "pwr", "ms": 10000}
# settle 期间的样本一概不看(哪怕是平线) t = r[-1][0]
assert feed(ph, t + 1000, 29, lambda s: 412000) == [] # settle 期间的样本一概不看(哪怕是平线)
r = feed(ph, t + 30000, 62, lambda s: 412000) assert feed(ph, t + 1000, 29, lambda s: 412000) == []
assert r[-1][1] == HARD and r[-1][2][1] == {"cmd": "power_cycle", "rail": 3} r = feed(ph, t + 30000, 62, lambda s: 412000)
t = r[-1][0] assert r[-1][1] == HARD and r[-1][2][1] == {"cmd": "power_cycle", "rail": 3}
r = feed(ph, t + 30000, 62, lambda s: 412000) t = r[-1][0]
assert r[-1][1] == GAVE_UP and r[-1][2] is None r = feed(ph, t + 30000, 62, lambda s: 412000)
assert feed(ph, t + 200000, 100, 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) def test_recovers_after_soft_when_current_moves_again():
t = feed(ph, 0, 61, lambda s: 412000)[-1][0] ph = PhoneRecovery(P)
r = feed(ph, t + 30000, 70, lambda s: 300000 + (s % 7) * 40000) t = feed(ph, 0, 61, lambda s: 412000)[-1][0]
assert r[-1][1] == IDLE and ph.reason == "recovered" 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) def test_low_current_is_detected_and_normal_is_not():
assert feed(ph, 0, 200, lambda s: 300000 + (s % 5) * 50000) == [] ph = PhoneRecovery(P)
ph2 = PhoneRecovery(P) assert feed(ph, 0, 200, lambda s: 300000 + (s % 5) * 50000) == []
assert feed(ph2, 0, 61, lambda s: 800 + s % 3)[-1][2][0] == "low" 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)) def test_rate_limit_gives_up():
t = 0 ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2))
for _ in range(2): # 两次软重启都「恢复」了,但每次很快又卡 t = 0
assert feed(ph, t, 65, lambda s: 412000)[-1][1] == SOFT for _ in range(2): # 两次软重启都「恢复」了,但每次很快又卡
t += 65000 # 下一段接着上一段喂完的时刻往后走(时间不倒退) assert feed(ph, t, 65, lambda s: 412000)[-1][1] == SOFT
# 前 30 s 还在 settle 里不算数,之后要攒满一个 60 s 窗口才判「恢复」 t += 65000 # 下一段接着上一段喂完的时刻往后走(时间不倒退)
assert feed(ph, t, 100, lambda s: 300000 + (s % 7) * 40000)[-1][1] == IDLE # 前 30 s 还在 settle 里不算数,之后要攒满一个 60 s 窗口才判「恢复」
t += 100000 assert feed(ph, t, 100, lambda s: 300000 + (s % 7) * 40000)[-1][1] == IDLE
r = feed(ph, t, 65, lambda s: 412000) t += 100000
assert r[-1][1] == GAVE_UP and ph.reason == "rate_limit" r = feed(ph, t, 65, lambda s: 412000)
assert r[-1][1] == GAVE_UP and ph.reason == "rate_limit"
# ---- 自动恢复:接到 Hub + 模拟板上 -------------------------------------------------
# ---- 自动恢复:接到 Hub + 模拟板上 -------------------------------------------------
def test_auto_recovery_end_to_end_with_sim(tmp_path):
"""⭐ 模拟板上第 3 台卡死:开了自动恢复 → 5 分钟后下长按电源 → 板子执行 → 恢复。 def test_auto_recovery_end_to_end_with_sim(tmp_path):
没开自动恢复的第 2 台(也卡死)一个命令都不收到。""" """⭐ 模拟板上第 3 台卡死:开了自动恢复 → 5 分钟后下长按电源 → 板子执行 → 恢复。
hub, sim, db = rig(tmp_path) 没开自动恢复的第 2 台(也卡死)一个命令都不收到。"""
clock = [1_000_000] hub, sim, db = rig(tmp_path)
rec = RecoveryManager(hub, lambda: clock[0]) clock = [1_000_000]
sim.set_phone(3, HUNG) rec = RecoveryManager(hub, lambda: clock[0])
sim.set_phone(2, HUNG) sim.set_phone(3, HUNG)
sim.set_phone(2, HUNG)
async def go():
await hub.start() async def go():
sim.start() await hub.start()
await settle() # 等 Hub 处理完上线消息、建好这块板的 phones 行 sim.start()
db.x("UPDATE phones SET auto_recover=1 WHERE rail=3") await settle() # 等 Hub 处理完上线消息、建好这块板的 phones 行
rec.reload() db.x("UPDATE phones SET auto_recover=1 WHERE rail=3")
for s in range(300 + 130 + 320): rec.reload()
clock[0] += 1000 for s in range(300 + 130 + 320):
sim.tick(clock[0]) clock[0] += 1000
await settle() sim.tick(clock[0])
await settle()
run(go())
keys = [r for r in sim.received if r.get("cmd") == "key"] run(go())
assert len(keys) == 1 and keys[0]["rail"] == 3 and keys[0]["ms"] == 10000 keys = [r for r in sim.received if r.get("cmd") == "key"]
assert not [r for r in sim.received if r.get("rail") == 2] assert len(keys) == 1 and keys[0]["rail"] == 3 and keys[0]["ms"] == 10000
assert sim.rails[2].phone == NORMAL assert not [r for r in sim.received if r.get("rail") == 2]
states = [r["state"] for r in db.q("SELECT state FROM recovery_log ORDER BY id")] assert sim.rails[2].phone == NORMAL
assert states == [SOFT, IDLE] states = [r["state"] for r in db.q("SELECT state FROM recovery_log ORDER BY id")]
assert db.q("SELECT user FROM audit")[0]["user"] == "auto-recovery" 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) def test_auto_recovery_ignores_rails_that_are_off(tmp_path):
clock = [0] hub, sim, db = rig(tmp_path)
rec = RecoveryManager(hub, lambda: clock[0]) clock = [0]
sim.set_phone(1, OFF) rec = RecoveryManager(hub, lambda: clock[0])
sim.rails[0].en = False # 这一路本来就关着:电流 0 不是故障 sim.set_phone(1, OFF)
sim.rails[0].en = False # 这一路本来就关着:电流 0 不是故障
async def go():
await hub.start() async def go():
sim.start() await hub.start()
await settle() sim.start()
db.x("UPDATE phones SET auto_recover=1") await settle()
rec.reload() db.x("UPDATE phones SET auto_recover=1")
for s in range(400): rec.reload()
clock[0] += 1000 for s in range(400):
sim.tick(clock[0]) clock[0] += 1000
await settle() sim.tick(clock[0])
await settle()
run(go())
assert not [r for r in sim.received if r.get("cmd") in ("key", "power_cycle")] 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 的公共部分:按需引入 + 取色 + 两张上下叠放的图共用一条时间轴。 // ECharts 的公共部分:按需引入 + 取色 + 纵向叠放的几张小图共用一条时间轴。
// //
// 规矩(dataviz skill): // 规矩(dataviz skill):
// · 不用双 Y 轴 —— 电流和电压、温度和风扇,一律上下两个 grid,共用 x(dataZoom 联动); // · 不用双 Y 轴 —— 不同量纲一律各占一个 grid,纵向排、共用 x(dataZoom 联动);
// · 系列色取参考调色板第 1/2 槽,按本页亮暗两种面板底色跑过 validate_palette.js(全部通过); // · 系列色取参考调色板第 1/2 槽,按本页亮暗两种面板底色跑过 validate_palette.js(全部通过);
// · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线; // · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线;
// · 线宽 2px,不画数据点;min~max 画成同色 15% 的带子。 // · 线宽 2px,不画数据点;min~max 画成同色 15% 的带子。
import * as echarts from 'echarts/core' import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts' import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent } from 'echarts/components' import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { cssVar, dark } from './theme' import { cssVar, dark } from './theme'
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer]) echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer])
export { echarts } export { echarts }
// 字体是打包进来异步下的,而 zrender 按「字体 → 文字」缓存宽度、从不清空: // 字体是打包进来异步下的,而 zrender 按「字体 → 文字」缓存宽度、从不清空:
// 字形没到就量,会一直按回退字体的宽度排版(时间轴标签叠成一团,重画也救不回来)。 // 字形没到就量,会一直按回退字体的宽度排版(时间轴标签叠成一团,重画也救不回来)。
// 所以每张图 init 之前先把它 canvas 上要用的字形下完:全部 ASCII + 轴名里的汉字。 // 所以每张图 init 之前先把它 canvas 上要用的字形下完:全部 ASCII + 轴名里的汉字。
// 图例和 tooltip 是 DOM,字体到了自己会换,不用管。 // 图例和 tooltip 是 DOM,字体到了自己会换,不用管。
const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('') const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('')
export function chartFonts(...texts) { export function chartFonts(...texts) {
if (!document.fonts) return Promise.resolve() if (!document.fonts) return Promise.resolve()
const family = getComputedStyle(document.body).fontFamily const family = getComputedStyle(document.body).fontFamily
return document.fonts.load(`12px ${family}`, ASCII + '°' + texts.join('')).catch(() => {}) return document.fonts.load(`12px ${family}`, ASCII + '°' + texts.join('')).catch(() => {})
} }
const SERIES = { light: ['#2a78d6', '#eb6834'], dark: ['#3987e5', '#d95926'] } const SERIES = { light: ['#2a78d6', '#eb6834'], dark: ['#3987e5', '#d95926'] }
export function palette() { export function palette() {
const s = SERIES[dark.value ? 'dark' : 'light'] const s = SERIES[dark.value ? 'dark' : 'light']
return { return {
s1: s[0], s2: s[1], s1: s[0], s2: s[1],
ink: cssVar('--ink'), ink2: cssVar('--ink-2'), muted: cssVar('--muted'), ink: cssVar('--ink'), ink2: cssVar('--ink-2'), muted: cssVar('--muted'),
grid: cssVar('--line-soft'), axis: cssVar('--line'), panel: cssVar('--panel'), bay: cssVar('--bay'), grid: cssVar('--line-soft'), axis: cssVar('--line'), panel: cssVar('--panel'), bay: cssVar('--bay'),
} }
} }
export function band(hex, a = 0.15) { export function band(hex, a = 0.15) {
const n = parseInt(hex.slice(1), 16) const n = parseInt(hex.slice(1), 16)
return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})` return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`
} }
// 上下两块:上面主图(高),下面副图(矮)。返回 grid / xAxis / dataZoom 的公共配置。 // 纵向排 N 张各自独立的小图(各有自己的 Y 轴和名字),共用一条时间轴、缩放联动。
export function stacked(p, { topName, bottomName, topFmt, bottomFmt, bottomMin, bottomMax, topMin, topMax, bottomInterval }) { // panels:[{ name, fmt, min, max, interval, height }],height 是像素。返回 { option, height },
const axisLabel = { color: p.muted, fontSize: 11 } // height 是整张画布该给的像素高度(容器要按它设高,不然 echarts 会挤或留白)。
const x = i => ({ export function panels(p, defs, { gap = 44, top = 28, bottom = 64 } = {}) {
type: 'time', gridIndex: i, axisLine: { lineStyle: { color: p.axis } }, axisTick: { show: false }, const axisLabel = { color: p.muted, fontSize: 11 }
axisLabel: { ...axisLabel, show: i === 1, hideOverlap: true }, splitLine: { show: false }, const last = defs.length - 1
}) let y0 = top
const y = (i, name, fmt, min, max, interval) => ({ const grid = defs.map(d => { const g = { left: 52, right: 16, top: y0, height: d.height }; y0 += d.height + gap; return g })
type: 'value', gridIndex: i, name, nameTextStyle: { color: p.muted, fontSize: 11, align: 'left', padding: [0, 0, 0, -30] }, return {
scale: true, min, max, interval, splitNumber: i === 0 ? 4 : 2, axisLabel: { ...axisLabel, formatter: fmt }, height: y0 - gap + bottom,
splitLine: { lineStyle: { color: p.grid } }, axisLine: { show: false }, axisTick: { show: false }, option: {
}) animation: false,
return { textStyle: { fontFamily: getComputedStyle(document.body).fontFamily },
animation: false, grid,
textStyle: { fontFamily: getComputedStyle(document.body).fontFamily }, xAxis: defs.map((_, i) => ({
grid: [{ left: 48, right: 16, top: 30, height: '48%' }, { left: 48, right: 16, top: '75%', bottom: 44 }], type: 'time', gridIndex: i, axisLine: { lineStyle: { color: p.axis } }, axisTick: { show: false },
xAxis: [x(0), x(1)], axisLabel: { ...axisLabel, show: i === last, hideOverlap: true }, splitLine: { show: false },
yAxis: [y(0, topName, topFmt, topMin, topMax), y(1, bottomName, bottomFmt, bottomMin, bottomMax, bottomInterval)], })),
axisPointer: { link: [{ xAxisIndex: 'all' }], lineStyle: { color: p.muted } }, yAxis: defs.map((d, i) => ({
dataZoom: [{ type: 'inside', xAxisIndex: [0, 1] }, type: 'value', gridIndex: i, name: d.name,
{ type: 'slider', xAxisIndex: [0, 1], height: 16, bottom: 8, borderColor: p.axis, fillerColor: band(p.s1, 0.12), nameTextStyle: { color: p.ink2, fontSize: 12, align: 'left', padding: [0, 0, 0, -34] },
dataBackground: { lineStyle: { color: p.axis }, areaStyle: { color: p.bay } }, textStyle: { color: p.muted } }], scale: true, min: d.min, max: d.max, interval: d.interval, splitNumber: 2,
tooltip: { trigger: 'axis', backgroundColor: p.panel, borderColor: p.axis, textStyle: { color: p.ink, fontSize: 12 } }, 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> <template>
<section class="card bc"> <section class="card chart-card">
<div class="hh"> <div class="hh">
<h2 class="ttl">底板温度</h2> <h2 class="ttl">底板曲线</h2>
<RangePicker v-model="range" /> <RangePicker v-model="range" />
</div> </div>
<div class="legend" aria-hidden="true"> <div class="legend" aria-hidden="true">
<span><i :style="{ background: c.s1 }" />NTC1 Hub 稳压旁(U31)</span> <span><i :style="{ background: c.s1 }" />NTC1 Hub 稳压旁(U31)</span>
<span><i :style="{ background: c.s2 }" />NTC2 输入降压旁(U37)</span> <span><i :style="{ background: c.s2 }" />NTC2 输入降压旁(U37)</span>
</div> </div>
<div ref="el" class="chart" role="img" aria-label="底板两路温度与风扇占空比曲线" /> <div class="legend" aria-hidden="true">
<p class="muted note">{{ note }}</p> <span><i :style="{ background: c.s1 }" />功率:底板输入</span>
</section> <span><i :style="{ background: c.s2 }" />五路主板合计(两线之差 ≈ 板上损耗)</span>
</template> </div>
<div ref="el" class="chart" :style="{ height: h + 'px' }" role="img"
<script setup> aria-label="底板温度、风扇占空比、输入电压、输入电流、功率五张曲线" />
// 底板两路 NTC 温度(上)+ 风扇占空比(下)。温度两条线用 dataviz 参考调色板第 1/2 槽, <p class="muted note">{{ note }}</p>
// 亮暗两种面板底色都跑过 validate_palette.js(CVD ΔE ≥ 24,对比 ≥ 3:1)。 </section>
// 图例用自绘的一行(带位置说明),曲线末端不再直接标名:两条线常常贴得很近,标签会打架。 </template>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import RangePicker from './RangePicker.vue' <script setup>
import { api } from '../api' // 底板五张图纵向排:两路 NTC 温度 / 风扇占空比 / 12 V 输入电压 / 输入电流 / 功率(底板输入 vs 五路主板合计)。
import { onMessage } from '../store' // 各自独立的 Y 轴(不同量纲绝不共轴),共用时间轴、缩放和十字线联动。
import { dark } from '../theme' // 温度两条线用 dataviz 参考调色板第 1/2 槽,亮暗两种面板底色都跑过 validate_palette.js(CVD ΔE ≥ 24,对比 ≥ 3:1);
import { echarts, palette, band, stacked, chartFonts } from '../chart' // 电压电流各只有一条线,用第 1 槽;风扇沿用次墨色;功率两条线同样按第 1/2 槽(输入 / 主板),另起一行图例。
// 图例用自绘的一行(带位置说明),曲线末端不再直接标名:两条温度线常常贴得很近,标签会打架。
const props = defineProps({ board: String }) import { onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
const range = ref(3600e3) import RangePicker from './RangePicker.vue'
const el = ref(null) import { api } from '../api'
const note = ref('') import { onMessage } from '../store'
const c = ref(palette()) import { railsMw } from '../util'
let chart = null import { dark } from '../theme'
let pts = [] // [t, t0, t0min, t0max, t1, t1min, t1max, fan, in_mv, in_ua] import { echarts, palette, band, panels, atLeast, chartFonts, breakGaps } from '../chart'
let live = false
const props = defineProps({ board: String })
const degC = v => (v == null ? null : v / 1000) const range = ref(3600e3)
const el = ref(null)
function render() { const note = ref('')
if (!chart) return // 还在等字体 const c = ref(palette())
const p = palette() const h = ref(560)
c.value = p let chart = null
const hasBand = pts.some(x => x[2] != null && x[2] !== x[3]) let raw = [] // [t, t0, t0min, t0max, t1, t1min, t1max, fan, in_mv, in_ua, rails_mw]
const bandOf = (lo, hi, color) => [ let live = false
{ type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], degC(x[lo])]), stack: `b${lo}`, let step = 1000 // 每个点代表的毫秒数,判断断档用
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]), const degC = v => (v == null ? null : v / 1000)
stack: `b${lo}`, symbol: 'none', lineStyle: { opacity: 0 }, areaStyle: { color: band(color, 0.12) }, silent: true, tooltip: { show: false } }, const NAMES = ['温度 °C', '风扇 %', '输入电压 V', '输入电流 A', '功率 W']
]
const fmtC = v => (v == null ? '—' : `${v.toFixed(1)} °C`) function render() {
chart.setOption({ if (!chart) return // 还在等字体
...stacked(p, { const p = palette()
topName: '温度 °C', bottomName: '风扇 %', c.value = p
topFmt: v => v.toFixed(0), bottomFmt: v => v.toFixed(0), bottomMin: 0, bottomMax: 100, bottomInterval: 50, const pts = breakGaps(raw, step)
// 温度按数据撑满,但至少 10 °C 的跨度:别把 0.2 °C 的抖动放大成山峰 const hasBand = pts.some(x => x[2] != null && x[2] !== x[3])
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)), const bandOf = (lo, hi, color) => [
}), { type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], degC(x[lo])]), stack: `b${lo}`,
series: [ symbol: 'none', lineStyle: { opacity: 0 }, silent: true, tooltip: { show: false } },
...(hasBand ? [...bandOf(2, 3, p.s1), ...bandOf(5, 6, p.s2)] : []), { type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], x[hi] == null || x[lo] == null ? null : (x[hi] - x[lo]) / 1000]),
{ name: 'NTC1', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], degC(x[1])]), symbol: 'none', stack: `b${lo}`, symbol: 'none', lineStyle: { opacity: 0 }, areaStyle: { color: band(color, 0.12) }, silent: true, tooltip: { show: false } },
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', const unit = (d, u) => v => (v == null ? '—' : `${v.toFixed(d)} ${u}`)
lineStyle: { width: 2 }, color: p.s2, tooltip: { valueFormatter: fmtC } }, const line = (i, name, color, data, fmt, extra = {}) => ({
{ name: '风扇', type: 'line', xAxisIndex: 1, yAxisIndex: 1, step: 'end', data: pts.map(x => [x[0], x[7] == null ? null : x[7] / 10]), name, type: 'line', xAxisIndex: i, yAxisIndex: i, data, symbol: 'none',
symbol: 'none', lineStyle: { width: 2 }, color: p.ink2, tooltip: { valueFormatter: v => (v == null ? '—' : `${v.toFixed(0)} %`) } }, lineStyle: { width: 2 }, color, tooltip: { valueFormatter: fmt }, ...extra,
], })
}, true) 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 },
async function load() { { name: NAMES[1], height: 64, fmt: v => v.toFixed(0), min: 0, max: 100, interval: 50 },
const end = Date.now() { name: NAMES[2], height: 90, fmt: v => v.toFixed(1), ...atLeast(0.5, 0.1) },
try { { name: NAMES[3], height: 90, fmt: v => v.toFixed(1), ...atLeast(0.5, 0.1) },
const h = await api.boardHistory(props.board, end - range.value, end) // 功率从 0 起:两条线之间的缝就是损耗,要看得出比例
pts = h.points { name: NAMES[4], height: 120, fmt: v => v.toFixed(0), min: 0 },
live = h.resolution === 'raw' && range.value <= 3600e3 ])
note.value = pts.length === 0 ? '这段时间没有温度数据(板子离线,或者刚上线)' if (h.value !== L.height) { h.value = L.height; nextTick(() => chart && chart.resize()) }
: h.resolution === 'raw' ? `每秒一个点${live ? ',实时追加' : ''}` chart.setOption({
: '每分钟一个点;浅色带是这一分钟里的最低到最高温度' ...L.option,
render() series: [
} catch (e) { ...(hasBand ? [...bandOf(2, 3, p.s1), ...bandOf(5, 6, p.s2)] : []),
note.value = '曲线没取到:' + e.message 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')),
const off = onMessage(m => { line(3, '输入电流', p.s1, pts.map(x => [x[0], x[9] == null ? null : x[9] / 1e6]), unit(2, 'A')),
if (!live || m.t !== 'tele' || m.board !== props.board) return line(4, '底板输入', p.s1, pts.map(x => [x[0], x[8] == null || x[9] == null ? null : x[8] * x[9] / 1e9]), unit(2, 'W')),
const s = m.status line(4, '主板合计', p.s2, pts.map(x => [x[0], x[10] == null ? null : x[10] / 1000]), unit(2, 'W')),
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]) }, true)
const cut = Date.now() - range.value }
while (pts.length && pts[0][0] < cut) pts.shift()
render() async function load() {
}) const end = Date.now()
try {
const onResize = () => chart && chart.resize() const r = await api.boardHistory(props.board, end - range.value, end)
let gone = false raw = r.points
onMounted(async () => { step = r.bucket_ms || 1000
window.addEventListener('resize', onResize) live = r.resolution === 'raw' && range.value <= 3600e3
await chartFonts('温度 °C', '风扇 %') note.value = raw.length === 0 ? '这段时间没有底板数据(板子离线,或者刚上线)'
if (gone) return : r.resolution === 'raw' ? `每秒一个点${live ? ',实时追加' : ''}`
chart = echarts.init(el.value) : '每分钟一个点;浅色带是这一分钟里的最低到最高温度(电压电流只存了分钟均值)'
load() render()
}) } catch (e) {
onBeforeUnmount(() => { gone = true; off(); window.removeEventListener('resize', onResize); chart && chart.dispose() }) note.value = '曲线没取到:' + e.message
watch(() => [props.board, range.value], load) }
watch(dark, () => chart && render()) }
</script>
const off = onMessage(m => {
<style scoped> if (!live || m.t !== 'tele' || m.board !== props.board) return
.hh { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; } const s = m.status
.hh .ttl { margin: 0; } const t = s.temp || []
.legend { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 13px; color: var(--ink-2); margin-top: 8px; } 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)])
.legend i { display: inline-block; width: 14px; height: 3px; border-radius: 2px; margin-right: 6px; vertical-align: middle; } const cut = Date.now() - range.value
.chart { height: 320px; } while (raw.length && raw[0][0] < cut) raw.shift()
.note { margin: 4px 0 0; } render()
@media (max-width: 520px) { .chart { height: 260px; } } })
</style>
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> <template>
<section class="card hc"> <section class="card chart-card">
<div class="hh"> <div class="hh">
<h2 class="ttl">第 {{ rail }} 路</h2> <h2 class="ttl">第 {{ rail }} 路</h2>
<RangePicker v-model="range" /> <RangePicker v-model="range" />
</div> </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> <p class="muted note">{{ note }}</p>
</section> </section>
</template> </template>
<script setup> <script setup>
// 一路的电流(上)与电压(下)。**不用双 Y 轴**:3.9 V 上的几十毫伏起伏和几百 mA 的电流 // 一路的电流(上)、主板功率(中,= 电压 × 电流)与电压(下)。**不用双 Y 轴**:3.9 V 上的几十毫伏起伏和几百 mA 的电流
// 放在一张图的两个刻度上,谁也看不清(dataviz 头号反模式)。上下两块共用时间轴,缩放联动。 // 放在一张图的两个刻度上,谁也看不清(dataviz 头号反模式)。三块共用时间轴,缩放联动。
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' // 功率由每个点的均值电压 × 均值电流算出来(分钟聚合时是近似,电压几乎不动,误差可忽略)。
import { onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
import RangePicker from './RangePicker.vue' import RangePicker from './RangePicker.vue'
import { api } from '../api' import { api } from '../api'
import { onMessage } from '../store' import { onMessage } from '../store'
import { dark } from '../theme' 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 props = defineProps({ board: String, rail: Number })
const range = ref(600e3) const range = ref(600e3)
const el = ref(null) const el = ref(null)
const note = ref('') const note = ref('')
const c = ref(palette())
const h = ref(560)
let chart = null 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 live = false
let step = 1000 // 每个点代表的毫秒数,判断断档用
function render() { function render() {
if (!chart) return // 还在等字体 if (!chart) return // 还在等字体
const p = palette() const p = palette()
c.value = p
const pts = breakGaps(raw, step)
const hasBand = pts.some(x => x[3] != null && x[3] !== x[4]) const hasBand = pts.some(x => x[3] != null && x[3] !== x[4])
const mA = v => (v == null ? null : v / 1000) 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({ chart.setOption({
...stacked(p, { ...L.option,
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,
}),
series: [ series: [
...(hasBand ? [ ...(hasBand ? [
{ type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], mA(x[3])]), stack: 'b', symbol: 'none', { 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', { 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`) } }, 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`) } }, symbol: 'none', lineStyle: { width: 2 }, color: p.s1, tooltip: { valueFormatter: v => (v == null ? '—' : `${v.toFixed(3)} V`) } },
], ],
}, true) }, true)
@@ -59,7 +74,8 @@ async function load() {
const end = Date.now() const end = Date.now()
try { try {
const h = await api.history(props.board, props.rail, end - range.value, end) 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 live = h.resolution === 'raw' && range.value <= 3600e3
note.value = h.resolution === 'raw' note.value = h.resolution === 'raw'
? `每秒一个点${live ? ',实时追加' : ''}` ? `每秒一个点${live ? ',实时追加' : ''}`
@@ -74,9 +90,9 @@ const off = onMessage(m => {
if (!live || m.t !== 'tele' || m.board !== props.board) return if (!live || m.t !== 'tele' || m.board !== props.board) return
const r = m.status.rails?.[props.rail - 1] const r = m.status.rails?.[props.rail - 1]
if (!r) return 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 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() render()
}) })
@@ -84,7 +100,7 @@ const onResize = () => chart && chart.resize()
let gone = false let gone = false
onMounted(async () => { onMounted(async () => {
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
await chartFonts('电流 mA', '电压 V') await chartFonts('电流 mA', '功率 W', '电压 V')
if (gone) return if (gone) return
chart = echarts.init(el.value) chart = echarts.init(el.value)
load() load()
@@ -93,11 +109,3 @@ onBeforeUnmount(() => { gone = true; off(); window.removeEventListener('resize',
watch(() => [props.board, props.rail, range.value], load) watch(() => [props.board, props.rail, range.value], load)
watch(dark, () => chart && render()) watch(dark, () => chart && render())
</script> </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 /> <el-input v-model="label" size="small" class="label" placeholder="手机编号" @change="saveLabel" @click.stop />
<div class="nums" v-if="r?.pr"> <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><b class="readout">{{ V(r.mv) }}</b><small>V</small><div class="muted set">设定 {{ V(r.set) }}</div></div>
</div> </div>
<div class="flags" v-if="r?.pr"> <div class="flags" v-if="r?.pr">
@@ -62,7 +62,7 @@ import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue' import { ArrowDown } from '@element-plus/icons-vue'
import { api, rspText } from '../api' 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 }) const props = defineProps({ board: Object, rail: Number, selected: Boolean, recState: String })
defineEmits(['select']) defineEmits(['select'])
+21 -15
查看文件
@@ -1,15 +1,21 @@
<template> <template>
<el-radio-group :model-value="modelValue" size="small" @update:model-value="v => $emit('update:modelValue', v)"> <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-button v-for="o in RANGES" :key="o.v" :value="o.v">{{ o.t }}</el-radio-button>
</el-radio-group> </el-radio-group>
</template> </template>
<script setup> <script setup>
// 曲线的时间范围。与后端的取数规则对应:6 小时以内回每秒原始数据,更长回分钟聚合(db.py)。 // 曲线的时间范围。与后端的取数规则对应:6 小时以内回每秒原始数据,更长回分钟聚合(db.py)。
defineProps({ modelValue: Number }) defineProps({ modelValue: Number })
defineEmits(['update:modelValue']) defineEmits(['update:modelValue'])
const RANGES = [ const RANGES = [
{ v: 600e3, t: '10 分钟' }, { v: 3600e3, t: '1 小时' }, { v: 6 * 3600e3, t: '6 小时' }, { v: 600e3, t: '10 分钟' }, { v: 3600e3, t: '1 小时' }, { v: 6 * 3600e3, t: '6 小时' },
{ v: 86400e3, t: '1 天' }, { v: 7 * 86400e3, t: '7 天' }, { v: 86400e3, t: '1 天' }, { v: 7 * 86400e3, t: '7 天' },
] ]
</script> </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> <template>
<div v-if="!b" class="card"><el-empty description="没有这块板子,或者还没收到它的数据" /></div> <div v-if="!b" class="card"><el-empty description="没有这块板子,或者还没收到它的数据" /></div>
<div v-else class="bd"> <div v-else class="bd">
<section class="card head"> <section class="card head">
<div class="names"> <div class="idrow">
<span :class="['led', !b.online || b.stale ? 'lost' : 'ok']" /> <span :class="['led', 'big', !b.online || b.stale ? 'lost' : 'ok']" />
<el-input v-model="name" class="ed name" placeholder="给这块板起个名字" @change="saveMeta" /> <div class="ident">
<el-input v-model="location" class="ed" placeholder="位置,比如 机架 A 第 3 层" @change="saveMeta" /> <el-input v-model="name" class="ed name" placeholder="给这块板起个名字" @change="saveMeta" />
</div> <el-input v-model="location" class="ed loc" size="small" placeholder="位置,比如 机架 A 第 3 层" @change="saveMeta" />
<dl class="meta"> </div>
<div><dt>状态</dt><dd :class="{ bad: !b.online || b.stale }">{{ !b.online || b.stale ? `通信中断,最后数据 ${ago(b.last_tele)}` : '在线' }}</dd></div> <dl class="meta">
<div><dt>ID</dt><dd class="readout">{{ b.id }}</dd></div> <div><dt>状态</dt><dd :class="{ bad: !b.online || b.stale }">{{ !b.online || b.stale ? `通信中断,最后数据 ${ago(b.last_tele)}` : '在线' }}</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>ID</dt><dd class="readout">{{ b.id }}</dd></div>
<div><dt>IP</dt><dd class="readout">{{ b.ip || '—' }}</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>
</dl> <div><dt>IP</dt><dd class="readout">{{ b.ip || '—' }}</dd></div>
<div class="gauges" v-if="b.status"> </dl>
<div class="g"><span class="gk">输入</span> </div>
<span class="gv readout">{{ V(b.status.in?.mv) }}<small>V</small></span> <div class="gauges" v-if="b.status">
<span class="gv readout">{{ mA(b.status.in?.ua) }}<small>mA</small></span></div> <div class="g"><div class="gh"><span class="gk">输入</span></div>
<div class="g"><span class="gk">温度</span> <div class="gvs"><span class="gv readout">{{ V(b.status.in?.mv) }}<small>V</small></span>
<span v-for="(t, i) in b.status.temp || []" :key="i" class="gv readout">{{ t == null ? '—' : C(t) }}<small>°C</small></span></div> <span class="gv readout">{{ mA(b.status.in?.ua) }}<small>mA</small></span></div></div>
<div class="g"><span class="gk">风扇</span> <div class="g"><div class="gh"><span class="gk">功率</span><span class="gs">底板输入 · 主板合计 · 损耗</span></div>
<span class="gv readout">{{ (b.status.fan?.duty ?? 0) / 10 }}<small>%</small></span> <div class="gvs"><span class="gv readout">{{ W(b.status.in?.mv, b.status.in?.ua) }}<small>W</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> <span class="gv sm readout">{{ fmtW(pw.rails) }}<small>W 主板</small></span>
<el-select v-model="fanFloor" size="small" class="floor" aria-label="风扇占空比下限" @change="setFan"> <span class="gv sm readout">{{ fmtW(pw.loss) }}<small>W 损耗</small></span></div></div>
<el-option :value="0" label="自动" /> <div class="g"><div class="gh"><span class="gk">温度</span><span class="gs">NTC1 · NTC2</span></div>
<el-option v-for="d in [500, 700, 850, 1000]" :key="d" :value="d" :label="`不低于 ${d / 10}%`" /> <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>
</el-select></div> <div class="g"><div class="gh"><span class="gk">风扇</span>
<div class="g"><span class="gk">巡检</span> <el-select v-model="fanFloor" size="small" class="floor" aria-label="风扇占空比下限" @change="setFan">
<span class="gv readout">{{ b.status.safety?.cycles }}<small>圈</small></span> <el-option :value="0" label="自动" />
<span class="gv readout" :class="{ bad: b.status.safety?.skipped }">{{ b.status.safety?.skipped }}<small>跳过</small></span> <el-option v-for="d in [500, 700, 850, 1000]" :key="d" :value="d" :label="`不低于 ${d / 10}%`" />
<span class="gv readout" :class="{ bad: b.status.safety?.i2c_err }">{{ b.status.safety?.i2c_err }}<small>I²C 错</small></span></div> </el-select></div>
</div> <div class="gvs"><span class="gv readout">{{ (b.status.fan?.duty ?? 0) / 10 }}<small>%</small></span>
</section> <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="rails"> <div class="gvs"><span class="gv readout">{{ b.status.safety?.cycles }}<small>圈</small></span>
<RailCard v-for="i in 5" :key="i" :board="b" :rail="i" :selected="sel === i" <span class="gv sm readout" :class="{ bad: b.status.safety?.skipped }">{{ b.status.safety?.skipped }}<small>跳过</small></span>
:rec-state="recStates[i]" @select="sel = i" /> <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> </div>
</section>
<div class="charts">
<HistoryChart :board="b.id" :rail="sel" /> <div class="rails">
<BoardChart :board="b.id" /> <RailCard v-for="i in 5" :key="i" :board="b" :rail="i" :selected="sel === i"
</div> :rec-state="recStates[i]" @select="sel = i" />
</div>
<div class="two">
<section class="card"> <div class="charts">
<h2 class="ttl">最近事件</h2> <HistoryChart :board="b.id" :rail="sel" />
<el-table :data="events" size="small" max-height="360" empty-text="这块板还没有事件"> <BoardChart :board="b.id" />
<el-table-column label="收到" width="130"><template #default="{ row }"><span class="readout">{{ fmtTime(row.received) }}</span></template></el-table-column> </div>
<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> <div class="two">
<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> <section class="card">
</el-table> <h2 class="ttl">最近事件</h2>
</section> <el-table :data="events" size="small" max-height="360" empty-text="这块板还没有事件">
<section class="card"> <el-table-column label="收到" width="130"><template #default="{ row }"><span class="readout">{{ fmtTime(row.received) }}</span></template></el-table-column>
<h2 class="ttl">操作记录</h2> <el-table-column label="路" width="50"><template #default="{ row }">{{ row.rail || '整板' }}</template></el-table-column>
<el-table :data="audit" size="small" max-height="360" empty-text="还没有人操作过这块板"> <el-table-column label="事件"><template #default="{ row }"><span :class="{ bad: row.severe }">{{ row.name }}</span></template></el-table-column>
<el-table-column label="时间" width="130"><template #default="{ row }"><span class="readout">{{ fmtTime(row.ts) }}</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-column label="谁" prop="user" width="110" /> </el-table>
<el-table-column label="操作"><template #default="{ row }">{{ row.rail ? `#${row.rail} ` : '' }}{{ row.request.cmd }}{{ row.request.keys ? ' ' + row.request.keys : '' }}</template></el-table-column> </section>
<el-table-column label="结果" width="120"><template #default="{ row }"><span :class="{ bad: !row.ok }">{{ rspText(row.response) }}</span></template></el-table-column> <section class="card">
</el-table> <h2 class="ttl">操作记录</h2>
</section> <el-table :data="audit" size="small" max-height="360" empty-text="还没有人操作过这块板">
</div> <el-table-column label="时间" width="130"><template #default="{ row }"><span class="readout">{{ fmtTime(row.ts) }}</span></template></el-table-column>
</div> <el-table-column label="谁" prop="user" width="110" />
</template> <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>
<script setup> </el-table>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' </section>
import { ElMessage } from 'element-plus' </div>
import RailCard from '../components/RailCard.vue' </div>
import HistoryChart from '../components/HistoryChart.vue' </template>
import BoardChart from '../components/BoardChart.vue'
import OtaState from '../components/OtaState.vue' <script setup>
import { api, rspText } from '../api' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { state, onMessage } from '../store' import { ElMessage } from 'element-plus'
import { mA, V, C, ago, fmtTime } from '../util' import RailCard from '../components/RailCard.vue'
import HistoryChart from '../components/HistoryChart.vue'
const props = defineProps({ id: String }) import BoardChart from '../components/BoardChart.vue'
const b = computed(() => state.boards[props.id]) import OtaState from '../components/OtaState.vue'
const sel = ref(1) import { api, rspText } from '../api'
const name = ref('') import { state, onMessage } from '../store'
const location = ref('') import { mA, V, W, C, ago, fmtTime, mW, railsMw } from '../util'
const fanFloor = ref(0)
const events = ref([]) const props = defineProps({ id: String })
const audit = ref([]) const b = computed(() => state.boards[props.id])
const recStates = ref({}) // 功率(mW):五路主板合计,和底板输入之差算作板上损耗(DC-DC 效率 + 走线 + 自身耗电)
const pw = computed(() => {
watch(b, v => { const s = b.value?.status
if (!v) return const input = mW(s?.in?.mv, s?.in?.ua)
if (document.activeElement?.tagName !== 'INPUT') { name.value = v.name; location.value = v.location } const rails = railsMw(s?.rails)
fanFloor.value = v.status?.fan?.floor ?? 0 return { rails, loss: input == null || rails == null ? null : input - rails }
}, { immediate: true }) })
const fmtW = v => (v == null ? '—' : (v / 1000).toFixed(2))
async function saveMeta() { const sel = ref(1)
try { await api.patchBoard(props.id, { name: name.value, location: location.value }) } catch (e) { ElMessage.error(e.message) } const name = ref('')
} const location = ref('')
async function setFan(d) { const fanFloor = ref(0)
try { const events = ref([])
const rsp = await api.cmd(props.id, { cmd: 'fan', duty: d }) const audit = ref([])
ElMessage({ type: rsp.ok ? 'success' : 'error', message: `风扇:${rspText(rsp)}` }) const recStates = ref({})
} catch (e) { ElMessage.error(e.message) }
} watch(b, v => {
if (!v) return
async function refresh() { if (document.activeElement?.tagName !== 'INPUT') { name.value = v.name; location.value = v.location }
try { fanFloor.value = v.status?.fan?.floor ?? 0
const [e, a, r] = await Promise.all([api.events({ board: props.id, limit: 50 }), api.audit({ board: props.id, limit: 50 }), api.recovery()]) }, { immediate: true })
events.value = e
audit.value = a async function saveMeta() {
recStates.value = Object.fromEntries(r.states.filter(s => s.board === props.id).map(s => [s.rail, s.state])) try { await api.patchBoard(props.id, { name: name.value, location: location.value }) } catch (e) { ElMessage.error(e.message) }
} catch (e) { /* 列表刷新失败不打扰:下一条推送会再试 */ } }
} async function setFan(d) {
try {
let timer = null const rsp = await api.cmd(props.id, { cmd: 'fan', duty: d })
const off = onMessage(m => { ElMessage({ type: rsp.ok ? 'success' : 'error', message: `风扇:${rspText(rsp)}` })
if (['events', 'audit', 'recovery'].includes(m.t) && (m.board === props.id || m.row?.board_id === props.id)) { } catch (e) { ElMessage.error(e.message) }
clearTimeout(timer) }
timer = setTimeout(refresh, 300)
} async function refresh() {
}) try {
onMounted(refresh) const [e, a, r] = await Promise.all([api.events({ board: props.id, limit: 50 }), api.audit({ board: props.id, limit: 50 }), api.recovery()])
onBeforeUnmount(off) events.value = e
watch(() => props.id, refresh) audit.value = a
</script> recStates.value = Object.fromEntries(r.states.filter(s => s.board === props.id).map(s => [s.rail, s.state]))
} catch (e) { /* 列表刷新失败不打扰:下一条推送会再试 */ }
<style scoped> }
.bd { display: flex; flex-direction: column; gap: 16px; }
.head { display: flex; flex-direction: column; gap: 12px; } let timer = null
.names { display: grid; grid-template-columns: auto minmax(0, 1.2fr) minmax(0, 1fr); gap: 10px; align-items: center; } const off = onMessage(m => {
/* 就地编辑:平时像标题,点进去才像输入框 */ if (['events', 'audit', 'recovery'].includes(m.t) && (m.board === props.id || m.row?.board_id === props.id)) {
.ed :deep(.el-input__wrapper) { box-shadow: none; background: transparent; padding-left: 4px; } clearTimeout(timer)
.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); } timer = setTimeout(refresh, 300)
.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; } onMounted(refresh)
.meta dt { color: var(--muted); } onBeforeUnmount(off)
.meta dd { margin: 0; color: var(--ink-2); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } watch(() => props.id, refresh)
.gauges { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 8px; </script>
border-top: 1px solid var(--line-soft); padding-top: 12px; }
.g { display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; } <style scoped>
.gk { color: var(--muted); font-size: 13px; width: 32px; flex-shrink: 0; } .bd { display: flex; flex-direction: column; gap: 16px; }
.gv { font-size: 20px; color: var(--ink); } .head { display: flex; flex-direction: column; gap: 14px; }
.gv small { font-size: 11px; color: var(--muted); margin-left: 3px; font-family: var(--ui); } .idrow { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.floor { width: 130px; align-self: center; } .led.big { width: 10px; height: 10px; margin-top: 13px; }
.rails { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; } .ident { flex: 1 1 280px; min-width: 0; display: flex; flex-direction: column; }
.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; } .ed :deep(.el-input__wrapper) { box-shadow: none; background: transparent; padding-left: 4px; }
@media (max-width: 1200px) { .rails { grid-template-columns: repeat(3, minmax(0, 1fr)); } .charts { grid-template-columns: 1fr; } } .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); }
@media (max-width: 800px) { .rails { grid-template-columns: 1fr 1fr; } .two { grid-template-columns: 1fr; } } .name :deep(.el-input__inner) { font-size: 20px; font-weight: 700; color: var(--ink); height: 34px; }
@media (max-width: 520px) { .loc :deep(.el-input__inner) { color: var(--ink-2); font-size: 13px; }
.rails { grid-template-columns: 1fr; } .meta { display: grid; grid-template-columns: auto auto; gap: 4px 24px; margin: 4px 0 0; font-size: 13px; }
.names { grid-template-columns: auto 1fr; } .meta div { display: flex; gap: 10px; align-items: center; min-width: 0; }
.names .ed:not(.name) { grid-column: 1 / -1; } .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; }
</style> /* 五个仪表格:凹进面板的小槽,标签在上、读数在下 */
.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> <div class="bl" :title="phone(b, i).label">{{ phone(b, i).label || ' ' }}</div>
<template v-if="rail(b, i)?.pr"> <template v-if="rail(b, i)?.pr">
<div class="bv readout">{{ mA(rail(b, i)?.ua) }}<small>mA</small></div> <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)'" /> <Spark :board="b.id" :rail="i" :color="loud(b, i) ? LEVEL_COLOR[health(b, i).level] : 'var(--ink-2)'" />
</template> </template>
<div class="bs" :style="{ color: loud(b, i) ? LEVEL_COLOR[health(b, i).level] : 'var(--muted)' }">{{ health(b, i).text }}</div> <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"> <footer class="pf" v-if="b.status">
<div><span class="k">输入</span><span class="readout">{{ V(b.status.in?.mv) }}</span> V <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> <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> <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> <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> % <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> <time class="muted readout">{{ fmtTime(f.ts) }}</time>
<span class="who">{{ boardName(f.board) }}</span> <span class="who">{{ boardName(f.board) }}</span>
<template v-if="f.kind === 'event'"> <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>
<template v-else-if="f.kind === 'audit'"> <template v-else-if="f.kind === 'audit'">
<span>{{ f.row.user }} 对 #{{ f.row.rail ?? '' }} 执行 {{ f.row.request.cmd }}:{{ rspText(f.row.response) }}</span> <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 Spark from '../components/Spark.vue'
import { state } from '../store' import { state } from '../store'
import { rspText } from '../api' 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) // 与 backend/prrack/protocol.py 的 EVENT_TYPES / EVENT_SEVERE 同一张表(数字来自固件 evlog.h)
const EV = ['', '板子上电', '事件丢失', '过压关路', '欠压关路', '反灌断 VBUS', '顶高断 VBUS', 'eFuse 故障', const EV = ['', '板子上电', '事件丢失', '过压关路', '欠压关路', '反灌断 VBUS', '顶高断 VBUS', 'eFuse 故障',
'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率', 'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率',
'上电等不到 PG', '固件升级'] '上电等不到 PG', '固件升级', '开机重按']
const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14, 17]) 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 evName = t => EV[t] || `事件 ${t}`
const A = ua => (ua == null ? '—' : (ua / 1e6).toFixed(2)) 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) => const boards = computed(() => Object.values(state.boards).sort((a, b) =>
(a.location + a.name + a.id).localeCompare(b.location + b.name + b.id))) (a.location + a.name + a.id).localeCompare(b.location + b.name + b.id)))
const rail = (b, i) => b.status?.rails?.[i - 1] 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; } .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 { 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); } .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; } .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); .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; } border-top: 1px solid var(--line-soft); padding-top: 10px; }
.pf .k { color: var(--muted); margin-right: 6px; } .pf .k { color: var(--muted); margin-right: 6px; }
.pf .k.sub { margin-left: 6px; }
.pf .readout { font-size: 15px; color: var(--ink); } .pf .readout { font-size: 15px; color: var(--ink); }
.pf .t + .t::before { content: '/'; margin: 0 4px; color: var(--muted); } .pf .t + .t::before { content: '/'; margin: 0 4px; color: var(--muted); }
+141 -132
查看文件
@@ -1,132 +1,141 @@
/* PhoneRail 机架 —— 仪器前面板风格。 /* PhoneRail 机架 —— 仪器前面板风格。
一处用力:每块板是一块「面板」,五个槽是凹进去的「刀片槽」,槽里是状态灯 + 读数 + 电流小曲线。 一处用力:每块板是一块「面板」,五个槽是凹进去的「刀片槽」,槽里是状态灯 + 读数 + 电流小曲线。
其余一律安静:冷灰阳极铝底、细线分隔、读数用 DIN 系等宽数字。 其余一律安静:冷灰阳极铝底、细线分隔、读数用 DIN 系等宽数字。
颜色只有两类职责: 颜色只有两类职责:
· 状态(运行 / 忙 / 异常 / 故障 / 断电 / 空槽 / 失联)—— 只用来表示状态,旁边一定有文字; · 状态(运行 / 忙 / 异常 / 故障 / 断电 / 空槽 / 失联)—— 只用来表示状态,旁边一定有文字;
· 交互(选中、链接、主按钮)—— 阳极蓝。 · 交互(选中、链接、主按钮)—— 阳极蓝。
图表的系列色在 chart.js 里(dataviz 参考调色板第 1/2 槽,按本页底色校验过)。 */ 图表的系列色在 chart.js 里(dataviz 参考调色板第 1/2 槽,按本页底色校验过)。 */
:root { :root {
color-scheme: light; color-scheme: light;
--plane: #eceff2; /* 页面底 */ --plane: #eceff2; /* 页面底 */
--panel: #fbfcfd; /* 面板 */ --panel: #fbfcfd; /* 面板 */
--bay: #f2f4f7; /* 凹槽(比面板深一档) */ --bay: #f2f4f7; /* 凹槽(比面板深一档) */
--bay-edge: #dde2e8; --bay-edge: #dde2e8;
--ink: #16202b; --ink: #16202b;
--ink-2: #566270; --ink-2: #566270;
--muted: #8a94a0; --muted: #8a94a0;
--line: #d9dee4; --line: #d9dee4;
--line-soft: #e8ecf0; --line-soft: #e8ecf0;
--accent: #1d5fd1; --accent: #1d5fd1;
--accent-soft: #e3ecfb; --accent-soft: #e3ecfb;
--bar: #fbfcfd; --bar: #fbfcfd;
--st-ok: #0c8a0c; /* 运行(比 dataviz 的 good 深一档:它要当文字用) */ --st-ok: #0c8a0c; /* 运行(比 dataviz 的 good 深一档:它要当文字用) */
--st-busy: #1d5fd1; --st-busy: #1d5fd1;
--st-warn: #b07a00; --st-warn: #b07a00;
--st-bad: #c62f2f; --st-bad: #c62f2f;
--st-off: #5d6875; --st-off: #5d6875;
--st-empty: #b9c1ca; --st-empty: #b9c1ca;
--st-lost: #98a1ab; --st-lost: #98a1ab;
--led-ok: #16b416; /* 灯本身:亮一点,但只当圆点用,不当文字 */ --led-ok: #16b416; /* 灯本身:亮一点,但只当圆点用,不当文字 */
--led-warn: #fab219; --led-warn: #fab219;
--led-bad: #e04343; --led-bad: #e04343;
/* 两套字体都随前端打包(main.js 引入),系统字体只在字体文件还没下完时顶一下 */ /* 两套字体都随前端打包(main.js 引入),系统字体只在字体文件还没下完时顶一下 */
--readout: "Barlow", "Noto Sans SC Variable", sans-serif; --readout: "Barlow", "Noto Sans SC Variable", sans-serif;
--ui: "Noto Sans SC Variable", sans-serif; --ui: "Noto Sans SC Variable", sans-serif;
--r-panel: 10px; --r-panel: 10px;
--r-bay: 6px; --r-bay: 6px;
/* Element Plus:主色换成阳极蓝,其余跟着面板走 */ /* Element Plus:主色换成阳极蓝,其余跟着面板走 */
--el-color-primary: #1d5fd1; --el-color-primary: #1d5fd1;
--el-color-primary-light-3: #6190e0; --el-color-primary-light-3: #6190e0;
--el-color-primary-light-5: #8eafe8; --el-color-primary-light-5: #8eafe8;
--el-color-primary-light-7: #bbcff1; --el-color-primary-light-7: #bbcff1;
--el-color-primary-light-8: #d2dff6; --el-color-primary-light-8: #d2dff6;
--el-color-primary-light-9: #e8effb; --el-color-primary-light-9: #e8effb;
--el-color-primary-dark-2: #184ca7; --el-color-primary-dark-2: #184ca7;
--el-font-family: var(--ui); --el-font-family: var(--ui);
--el-border-radius-base: 6px; --el-border-radius-base: 6px;
} }
html.dark { html.dark {
color-scheme: dark; color-scheme: dark;
--plane: #0f1216; --plane: #0f1216;
--panel: #171b21; --panel: #171b21;
--bay: #12161b; --bay: #12161b;
--bay-edge: #262c34; --bay-edge: #262c34;
--ink: #e8ecf1; --ink: #e8ecf1;
--ink-2: #a7b0bb; --ink-2: #a7b0bb;
--muted: #76808b; --muted: #76808b;
--line: #2a3038; --line: #2a3038;
--line-soft: #20252c; --line-soft: #20252c;
--accent: #5b95f2; --accent: #5b95f2;
--accent-soft: #1b2a44; --accent-soft: #1b2a44;
--bar: #13171c; --bar: #13171c;
--st-ok: #3cc43c; --st-ok: #3cc43c;
--st-busy: #5b95f2; --st-busy: #5b95f2;
--st-warn: #f0b23a; --st-warn: #f0b23a;
--st-bad: #ef6464; --st-bad: #ef6464;
--st-off: #8a95a1; --st-off: #8a95a1;
--st-empty: #3a414a; --st-empty: #3a414a;
--st-lost: #5d6671; --st-lost: #5d6671;
--led-ok: #2fd12f; --led-ok: #2fd12f;
--led-warn: #fab219; --led-warn: #fab219;
--led-bad: #ef5353; --led-bad: #ef5353;
--el-color-primary: #5b95f2; --el-color-primary: #5b95f2;
--el-color-primary-light-3: #3f6fb8; --el-color-primary-light-3: #3f6fb8;
--el-color-primary-light-5: #2f548b; --el-color-primary-light-5: #2f548b;
--el-color-primary-light-7: #203a5f; --el-color-primary-light-7: #203a5f;
--el-color-primary-light-8: #1a2f4c; --el-color-primary-light-8: #1a2f4c;
--el-color-primary-light-9: #142338; --el-color-primary-light-9: #142338;
--el-color-primary-dark-2: #7eabf5; --el-color-primary-dark-2: #7eabf5;
--el-bg-color: #171b21; --el-bg-color: #171b21;
--el-bg-color-overlay: #1c2128; --el-bg-color-overlay: #1c2128;
--el-fill-color-blank: #171b21; --el-fill-color-blank: #171b21;
--el-border-color: #2f363f; --el-border-color: #2f363f;
--el-border-color-light: #2a3038; --el-border-color-light: #2a3038;
--el-border-color-lighter: #242a31; --el-border-color-lighter: #242a31;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { background: var(--plane); } html, body { background: var(--plane); }
body { margin: 0; font-family: var(--ui); color: var(--ink); font-size: 14px; line-height: 1.5; body { margin: 0; font-family: var(--ui); color: var(--ink); font-size: 14px; line-height: 1.5;
-webkit-font-smoothing: antialiased; } -webkit-font-smoothing: antialiased; }
a { color: var(--accent); } a { color: var(--accent); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } :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; } .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-panel); padding: 16px; }
.muted { color: var(--muted); font-size: 13px; } .muted { color: var(--muted); font-size: 13px; }
.readout { font-family: var(--readout); font-variant-numeric: tabular-nums; letter-spacing: .01em; } .readout { font-family: var(--readout); font-variant-numeric: tabular-nums; letter-spacing: .01em; }
.bad { color: var(--st-bad); } .bad { color: var(--st-bad); }
.warn { color: var(--st-warn); } .warn { color: var(--st-warn); }
/* 面板标题:粗一点的正文字,不加眉标、不全大写 */ /* 面板标题:粗一点的正文字,不加眉标、不全大写 */
.ttl { font-weight: 600; font-size: 15px; margin: 0 0 10px; color: var(--ink); } .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; .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); } background: var(--st-empty); box-shadow: 0 0 0 2px var(--panel); }
.led.ok { background: var(--led-ok); } .led.ok { background: var(--led-ok); }
.led.busy { background: var(--st-busy); } .led.busy { background: var(--st-busy); }
.led.warn { background: var(--led-warn); } .led.warn { background: var(--led-warn); }
.led.bad { background: var(--led-bad); } .led.bad { background: var(--led-bad); }
.led.off { background: var(--st-off); } .led.off { background: var(--st-off); }
.led.lost { background: var(--st-lost); } .led.lost { background: var(--st-lost); }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.led.busy { animation: led-blink 1s steps(2, jump-none) infinite; } .led.busy { animation: led-blink 1s steps(2, jump-none) infinite; }
} }
@keyframes led-blink { 50% { opacity: .35; } } @keyframes led-blink { 50% { opacity: .35; } }
/* 表格:去掉 Element Plus 的斑马感,行线用细线 */ /* 表格:去掉 Element Plus 的斑马感,行线用细线 */
.el-table { --el-table-border-color: var(--line-soft); --el-table-header-bg-color: transparent; .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-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-text-color: var(--ink); --el-table-header-text-color: var(--muted); }
.el-table th.el-table__cell { font-weight: 500; } .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; code { font-size: 12px; background: var(--bay); border: 1px solid var(--line-soft); padding: 0 4px; border-radius: 4px;
word-break: break-all; } 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 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 V = mv => (mv == null ? '—' : (mv / 1000).toFixed(3))
export const C = mc => (mc == null ? '—' : (mc / 1000).toFixed(1)) 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) { export function ago(ts) {
if (!ts) return '从未' if (!ts) return '从未'