rack:PD 供电信息、每日耗电量与平均功率
PD 供电(板卡页新增一格):
- 优先用遥测里的实时值 status.pd(固件 ≥0.1.17);老固件退回最近一条 PD_LIMIT 事件(快照的 pd)。
显示 20 V / 档位电流 / 整板功率上限 / 输入功率占上限的比例。
- PD_LIMIT 事件写成「PD 20 V / 3.00 A,上限 60 W」,不再把那两列当电压电流显示;arg 2 = 运行中换档。
耗电量(板卡页新增卡片):
- 新表 energy_day(永久保留):从分钟聚合滚出每日能量与有数据秒数,rail 0 = 底板 12 V 输入,1~5 = 各路主板。
每分钟重算今天 / 昨天,更早的只补缺不覆盖(分钟数据 90 天后会被清)。按服务器本地零点切日。
- GET /api/boards/{id}/energy;前端 EnergyCard:今日 / 昨日 / 近 7 天 / 近 30 天 / 累计(各带平均功率)、
每日柱状图、今日各路主板。量的是 12 V 输入,不含 PD 20→12 V 降压损耗。
后端测试 57 项全过;已部署。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -114,6 +114,15 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
|
|||||||
_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())
|
||||||
|
|
||||||
|
@app.get("/api/boards/{board}/energy")
|
||||||
|
def energy(board: str, days: int = 90):
|
||||||
|
"""每日耗电量(Db.energy_rollup)。单位 mW·s 与秒,换算留给前端;`today` 是服务器本地日期。
|
||||||
|
今天这一行最多滞后两分钟(分钟聚合 + 60 s 一次的滚动)。"""
|
||||||
|
_board_or_404(hub, board)
|
||||||
|
today = hub.db.q("SELECT date(?/1000,'unixepoch','localtime') d", (now_ms(),))[0]["d"]
|
||||||
|
return {"today": today, "days": hub.db.energy(board, max(1, min(days, 3660))),
|
||||||
|
"total": hub.db.energy_total(board)}
|
||||||
|
|
||||||
@app.get("/api/boards/{board}/rails/{rail}/history")
|
@app.get("/api/boards/{board}/rails/{rail}/history")
|
||||||
def history(board: str, rail: int, start: int, end: Optional[int] = None):
|
def history(board: str, rail: int, start: int, end: Optional[int] = None):
|
||||||
_board_or_404(hub, board)
|
_board_or_404(hub, board)
|
||||||
@@ -188,6 +197,13 @@ 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"] == protocol.EV_PD_LIMIT:
|
||||||
|
pd = protocol.pd_from_event(d["arg"], d["mv"], d["ua"])
|
||||||
|
d["name"] += "(%s,上限 %g W)" % (
|
||||||
|
"CH224A 读不到,按保守值" if pd["ma"] is None else
|
||||||
|
"%sPD %g V / %.2f A" % ("运行中换档:" if d["arg"] == protocol.PD_EV_CHANGED else "",
|
||||||
|
pd["mv"] / 1000, pd["ma"] / 1000), pd["limit_mw"] / 1000)
|
||||||
|
d["mv"] = d["ua"] = None # 两列装的是上限和档位电流,不是当时的电压电流
|
||||||
elif d["type"] == 21:
|
elif d["type"] == 21:
|
||||||
a = d["arg"]
|
a = d["arg"]
|
||||||
d["name"] += "(%s)" % ("手动关" if a == protocol.CHG_EV_OFF else
|
d["name"] += "(%s)" % ("手动关" if a == protocol.CHG_EV_OFF else
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
每分钟降采样一行(均值 / 最小 / 最大)留 `MIN_KEEP_S`(90 天)——
|
每分钟降采样一行(均值 / 最小 / 最大)留 `MIN_KEEP_S`(90 天)——
|
||||||
电流曲线的用途是「这台手机前天夜里是不是卡死过」,一分钟的 min/max 足够看出来。
|
电流曲线的用途是「这台手机前天夜里是不是卡死过」,一分钟的 min/max 足够看出来。
|
||||||
事件、审计、恢复记录**永久保留**(量小,而且是排查的唯一线索)。
|
事件、审计、恢复记录**永久保留**(量小,而且是排查的唯一线索)。
|
||||||
|
每日耗电量(`energy_day`)也永久保留:它从分钟聚合里滚出来,分钟数据过了 90 天被清掉之后就再也算不回来了。
|
||||||
|
|
||||||
⚠️ 时间戳一律是**服务器收到的时刻**(毫秒)。板子没有墙钟(需求 §10.12:
|
⚠️ 时间戳一律是**服务器收到的时刻**(毫秒)。板子没有墙钟(需求 §10.12:
|
||||||
上电从 0 计),它自己的 `t_ms` 只存在事件表的 `t_ms` 列里,作参考。
|
上电从 0 计),它自己的 `t_ms` 只存在事件表的 `t_ms` 列里,作参考。
|
||||||
@@ -60,6 +61,14 @@ CREATE TABLE IF NOT EXISTS board_tele_min (
|
|||||||
fan_avg INTEGER, in_mv_avg INTEGER, in_ua_avg INTEGER, n INTEGER,
|
fan_avg INTEGER, in_mv_avg INTEGER, in_ua_avg INTEGER, n INTEGER,
|
||||||
PRIMARY KEY (board_id, ts)
|
PRIMARY KEY (board_id, ts)
|
||||||
);
|
);
|
||||||
|
-- 每日耗电量(Db.energy_rollup)。rail 0 = 底板 12 V 输入,1~5 = 各路手机主板。
|
||||||
|
-- mws = 这一天累计的能量(mW·s,÷3.6e6 = Wh),secs = 这一天里有数据的秒数(平均功率 = mws / secs)。
|
||||||
|
-- day 是服务器本地时间的日期:「当日」按机房的零点切,不按 UTC。
|
||||||
|
CREATE TABLE IF NOT EXISTS energy_day (
|
||||||
|
board_id TEXT NOT NULL, day TEXT NOT NULL, rail INTEGER NOT NULL,
|
||||||
|
mws INTEGER NOT NULL, secs INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (board_id, day, rail)
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
board_id TEXT NOT NULL, seq INTEGER NOT NULL,
|
board_id TEXT NOT NULL, seq INTEGER NOT NULL,
|
||||||
t_ms INTEGER, rail INTEGER, type INTEGER, arg INTEGER, mv INTEGER, ua INTEGER,
|
t_ms INTEGER, rail INTEGER, type INTEGER, arg INTEGER, mv INTEGER, ua INTEGER,
|
||||||
@@ -190,6 +199,46 @@ class Db:
|
|||||||
self.conn.execute("DELETE FROM tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
|
self.conn.execute("DELETE FROM tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
|
||||||
self.conn.execute("DELETE FROM board_tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
|
self.conn.execute("DELETE FROM board_tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
|
||||||
|
|
||||||
|
# ---- 耗电量 --------------------------------------------------------
|
||||||
|
def energy_rollup(self, days_back: int = 1, now: Optional[int] = None):
|
||||||
|
"""把分钟聚合滚成每日耗电量。重算最近 `days_back`+1 个本地日(含今天,幂等覆盖),
|
||||||
|
更早的日子**只补缺、不覆盖**:分钟数据过了保留期会被清,拿残缺的一天去覆盖就把历史改小了。
|
||||||
|
每分钟的能量 = 均值功率 × 这一分钟里的样本数(遥测 1 s 一条,n 就是有数据的秒数,最多按 60 算);
|
||||||
|
功率拿 mv_avg × ua_avg 近似,电压几乎不动(同 board_history)。板子离线的时段没有行,自然不计。"""
|
||||||
|
t = now if now is not None else now_ms()
|
||||||
|
day = "date(ts/1000,'unixepoch','localtime')"
|
||||||
|
srcs = (("0", "in_mv_avg", "in_ua_avg", "board_tele_min"), ("rail", "mv_avg", "ua_avg", "tele_min"))
|
||||||
|
with self._lock:
|
||||||
|
cut = self.conn.execute("SELECT date(?/1000,'unixepoch','localtime',?)",
|
||||||
|
(t, "-%d days" % max(0, days_back))).fetchone()[0]
|
||||||
|
for rail, mv, ua, table in srcs:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO energy_day (board_id, day, rail, mws, secs) "
|
||||||
|
"SELECT board_id, {day} AS d, {rail} AS r, "
|
||||||
|
"CAST(SUM({mv}*{ua}/1e6*MIN(n,60)) AS INTEGER), SUM(MIN(n,60)) FROM {table} "
|
||||||
|
"WHERE {mv} IS NOT NULL AND {ua} IS NOT NULL GROUP BY board_id, d, r "
|
||||||
|
"ON CONFLICT(board_id, day, rail) DO UPDATE SET mws=excluded.mws, secs=excluded.secs "
|
||||||
|
"WHERE excluded.day >= ?".format(day=day, rail=rail, mv=mv, ua=ua, table=table), (cut,))
|
||||||
|
|
||||||
|
def energy(self, board: str, days: int = 90) -> List[dict]:
|
||||||
|
"""最近 `days` 个有数据的本地日,旧 → 新。每天:底板输入的能量 / 有数据秒数,各路主板的能量。"""
|
||||||
|
rows = self.q("SELECT day, rail, mws, secs FROM energy_day WHERE board_id=? AND day IN "
|
||||||
|
"(SELECT DISTINCT day FROM energy_day WHERE board_id=? ORDER BY day DESC LIMIT ?) "
|
||||||
|
"ORDER BY day, rail", (board, board, days))
|
||||||
|
out: dict = {}
|
||||||
|
for r in rows:
|
||||||
|
d = out.setdefault(r["day"], {"day": r["day"], "in_mws": None, "secs": 0, "rails": [None] * 5})
|
||||||
|
if r["rail"] == 0:
|
||||||
|
d["in_mws"], d["secs"] = r["mws"], r["secs"]
|
||||||
|
elif 1 <= r["rail"] <= 5:
|
||||||
|
d["rails"][r["rail"] - 1] = {"mws": r["mws"], "secs": r["secs"]}
|
||||||
|
return list(out.values())
|
||||||
|
|
||||||
|
def energy_total(self, board: str) -> dict:
|
||||||
|
r = self.q("SELECT COALESCE(SUM(mws),0) mws, COALESCE(SUM(secs),0) secs, MIN(day) since, COUNT(*) days "
|
||||||
|
"FROM energy_day WHERE board_id=? AND rail=0", (board,))[0]
|
||||||
|
return dict(r)
|
||||||
|
|
||||||
def history(self, board: str, rail: int, t0: int, t1: int, max_points: int = 1500) -> dict:
|
def history(self, board: str, rail: int, t0: int, t1: int, max_points: int = 1500) -> dict:
|
||||||
"""[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据,
|
"""[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据,
|
||||||
否则回分钟聚合(带 min/max,界面画成带状)。点太多时按时间桶再抽一次。"""
|
否则回分钟聚合(带 min/max,界面画成带状)。点太多时按时间桶再抽一次。"""
|
||||||
|
|||||||
+15
-1
@@ -58,6 +58,7 @@ class Hub:
|
|||||||
self.bus.start()
|
self.bus.start()
|
||||||
for kind in ("rsp", "tele", "state"):
|
for kind in ("rsp", "tele", "state"):
|
||||||
self.bus.subscribe("%s/+/%s" % (protocol.TOPIC_ROOT, kind), 0 if kind == "tele" else 1)
|
self.bus.subscribe("%s/+/%s" % (protocol.TOPIC_ROOT, kind), 0 if kind == "tele" else 1)
|
||||||
|
self.db.energy_rollup(days_back=1) # 补上停机期间没滚的日子(更早的只补缺,见 Db.energy_rollup)
|
||||||
self._tasks = [asyncio.ensure_future(self._flusher()), asyncio.ensure_future(self._maint())]
|
self._tasks = [asyncio.ensure_future(self._flusher()), asyncio.ensure_future(self._maint())]
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
@@ -192,6 +193,8 @@ class Hub:
|
|||||||
self.db.x("UPDATE boards SET ev_cursor=? WHERE id=?", (nxt, board))
|
self.db.x("UPDATE boards SET ev_cursor=? WHERE id=?", (nxt, board))
|
||||||
if ev:
|
if ev:
|
||||||
self.broadcast({"t": "events", "board": board, "ev": ev, "received": now})
|
self.broadcast({"t": "events", "board": board, "ev": ev, "received": now})
|
||||||
|
if any(e[3] == protocol.EV_PD_LIMIT for e in ev): # 板子重启过:供电档位可能换了
|
||||||
|
self.broadcast({"t": "board", "board": self.snapshot(board)})
|
||||||
if nxt >= rsp.get("next", nxt) or not ev:
|
if nxt >= rsp.get("next", nxt) or not ev:
|
||||||
return
|
return
|
||||||
except CmdTimeout:
|
except CmdTimeout:
|
||||||
@@ -200,6 +203,16 @@ class Hub:
|
|||||||
self._syncing.discard(board)
|
self._syncing.discard(board)
|
||||||
|
|
||||||
# ---- 查 ----------------------------------------------------------------
|
# ---- 查 ----------------------------------------------------------------
|
||||||
|
def pd(self, board: str) -> Optional[dict]:
|
||||||
|
"""CH224A 协商到的供电档位。固件只在启动时读一次、记成 PD_LIMIT 事件(不进遥测),
|
||||||
|
所以取最近一条;板子还没同步过事件是 None。充电器不断电换不了,启动那一次就是现状。"""
|
||||||
|
rows = self.db.q("SELECT arg, mv, ua, received FROM events WHERE board_id=? AND type=? "
|
||||||
|
"ORDER BY received DESC, seq DESC LIMIT 1", (board, protocol.EV_PD_LIMIT))
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
r = rows[0]
|
||||||
|
return dict(protocol.pd_from_event(r["arg"], r["mv"], r["ua"]), ts=r["received"])
|
||||||
|
|
||||||
def snapshot(self, board: str) -> dict:
|
def snapshot(self, board: str) -> dict:
|
||||||
b = self._board(board)
|
b = self._board(board)
|
||||||
meta = self.db.q("SELECT name, location FROM boards WHERE id=?", (board,))
|
meta = self.db.q("SELECT name, location FROM boards WHERE id=?", (board,))
|
||||||
@@ -208,7 +221,7 @@ class Hub:
|
|||||||
stale = b["last_tele"] == 0 or now_ms() - b["last_tele"] > STALE_MS
|
stale = b["last_tele"] == 0 or now_ms() - b["last_tele"] > STALE_MS
|
||||||
return {"id": board, "name": meta[0]["name"] if meta else "", "location": meta[0]["location"] if meta else "",
|
return {"id": board, "name": meta[0]["name"] if meta else "", "location": meta[0]["location"] if meta else "",
|
||||||
"online": b["online"], "stale": stale, "fw": b["fw"], "ip": b["ip"],
|
"online": b["online"], "stale": stale, "fw": b["fw"], "ip": b["ip"],
|
||||||
"last_tele": b["last_tele"], "status": b["status"],
|
"last_tele": b["last_tele"], "status": b["status"], "pd": self.pd(board),
|
||||||
"phones": [dict(p) for p in phones]}
|
"phones": [dict(p) for p in phones]}
|
||||||
|
|
||||||
def boards(self) -> List[dict]:
|
def boards(self) -> List[dict]:
|
||||||
@@ -257,6 +270,7 @@ class Hub:
|
|||||||
await asyncio.sleep(MAINT_S)
|
await asyncio.sleep(MAINT_S)
|
||||||
try:
|
try:
|
||||||
self.db.downsample()
|
self.db.downsample()
|
||||||
|
self.db.energy_rollup() # 先滚再清:今天 / 昨天每分钟重算一遍,量是几千行
|
||||||
self.db.prune()
|
self.db.prune()
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("降采样 / 清理失败")
|
log.exception("降采样 / 清理失败")
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ EVENT_TYPES: Dict[int, Tuple[str, str]] = {
|
|||||||
22: ("HUB", "Hub 启停"), # 0.1.14:arg 0 = 休眠、1 = 唤醒、0x10|模式 = 改模式;mV 列 = 唤醒理由(HUB_WHY)
|
22: ("HUB", "Hub 启停"), # 0.1.14:arg 0 = 休眠、1 = 唤醒、0x10|模式 = 改模式;mV 列 = 唤醒理由(HUB_WHY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# LOG_EV_PD_LIMIT(firmware/main/bringup.c 的 pd_up):每次启动记一条,arg 1 = CH224A 读不到 / 读到 0、按保守值;
|
||||||
|
# 固件 0.1.17 起运行中档位变了也记(arg 2,pdmon.h),并且遥测里有实时的 `pd`:{st,ma}(界面优先用它)。
|
||||||
|
# mV 列 = 整板功率上限 mW/100,µA 列 = CH224A 0x50 读到的档位电流 mA(读不到是 0)。两列都不是电压电流。
|
||||||
|
EV_PD_LIMIT = 16
|
||||||
|
PD_EV_FALLBACK, PD_EV_CHANGED = 1, 2
|
||||||
|
PD_FIXED_MV = 20000 # 板上 R7 硬件固定请求 20 V 档(ch224a.h 的 CH224A_FIXED_MV)
|
||||||
|
|
||||||
|
|
||||||
|
def pd_from_event(arg: int, mv: int, ua: int) -> dict:
|
||||||
|
"""PD_LIMIT 事件 → 界面用的供电信息。`ma` 是充电器在 20 V 档给的最大电流(读不到是 None),
|
||||||
|
`limit_mw` 是固件据此定的整板功率上限,`fallback` = 没读到、按保守值。"""
|
||||||
|
return {"mv": PD_FIXED_MV, "ma": ua if ua and arg != PD_EV_FALLBACK else None,
|
||||||
|
"limit_mw": (mv or 0) * 100, "fallback": arg == PD_EV_FALLBACK}
|
||||||
|
|
||||||
|
|
||||||
# LOG_EV_HUB 的 arg 与理由(firmware/main/hubctl.h)。⚠️ 冻结。
|
# LOG_EV_HUB 的 arg 与理由(firmware/main/hubctl.h)。⚠️ 冻结。
|
||||||
HUB_EV_OFF, HUB_EV_ON, HUB_EV_MODE = 0, 1, 0x10
|
HUB_EV_OFF, HUB_EV_ON, HUB_EV_MODE = 0, 1, 0x10
|
||||||
HUB_MODES = ("auto", "on") # hub_mode_t 的下标
|
HUB_MODES = ("auto", "on") # hub_mode_t 的下标
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ class SimBoard:
|
|||||||
self.ota_req: Optional[dict] = None
|
self.ota_req: Optional[dict] = None
|
||||||
self.ota_rate = 400000 # 每次 tick 下载多少字节(真板 ≈125 KB/s)
|
self.ota_rate = 400000 # 每次 tick 下载多少字节(真板 ≈125 KB/s)
|
||||||
self.hub = {"on": True, "mode": "auto", "why": "idle", "hold": 0, "idle": 600} # 0.1.14,只回显不计时
|
self.hub = {"on": True, "mode": "auto", "why": "idle", "hold": 0, "idle": 600} # 0.1.14,只回显不计时
|
||||||
|
self.pd: Optional[dict] = {"st": 8, "ma": 3250}
|
||||||
bus.on_message = self._on_message
|
bus.on_message = self._on_message
|
||||||
self._ev(0, 1, 0) # BOOT
|
self._ev(0, 1, 0) # BOOT
|
||||||
|
self._ev(0, 16, 0, 650, 3250) # PD_LIMIT:65 W 充电器(mV 列 = 上限 mW/100,µA 列 = 档位 mA)
|
||||||
|
|
||||||
# ---- 对外 --------------------------------------------------------------
|
# ---- 对外 --------------------------------------------------------------
|
||||||
def start(self):
|
def start(self):
|
||||||
@@ -143,6 +145,7 @@ class SimBoard:
|
|||||||
"rpm": [3300, 3280], "stall": [False, False]}, "derate": False,
|
"rpm": [3300, 3280], "stall": [False, False]}, "derate": False,
|
||||||
"safety": {"cycles": self.up_ms // 20, "skipped": 0, "i2c_err": 0, "events": 0},
|
"safety": {"cycles": self.up_ms // 20, "skipped": 0, "i2c_err": 0, "events": 0},
|
||||||
"hub": dict(self.hub),
|
"hub": dict(self.hub),
|
||||||
|
"pd": dict(self.pd) if self.pd else None, # 0.1.17:CH224A 实时档位(st = 0x09 原值,bit3 = PD)
|
||||||
"ota": dict(self.ota), "ev_next": len(self.events), "ev_pending": 0,
|
"ota": dict(self.ota), "ev_next": len(self.events), "ev_pending": 0,
|
||||||
"cmd": {"lines": len(self.received), "dups": 0, "rej": 0}}
|
"cmd": {"lines": len(self.received), "dups": 0, "rej": 0}}
|
||||||
|
|
||||||
|
|||||||
@@ -238,3 +238,44 @@ def test_hub_round_trip_and_event_names(env):
|
|||||||
assert "唤醒:有手机开着 VBUS" in ev[9201]["name"] and ev[9201]["mv"] is None
|
assert "唤醒:有手机开着 VBUS" in ev[9201]["name"] and ev[9201]["mv"] is None
|
||||||
assert "休眠" in ev[9202]["name"] and ev[9202]["severe"] is False
|
assert "休眠" in ev[9202]["name"] and ev[9202]["severe"] is False
|
||||||
assert "模式改为常开" in ev[9203]["name"]
|
assert "模式改为常开" in ev[9203]["name"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pd_info_comes_from_the_latest_pd_limit_event(env):
|
||||||
|
# CH224A 的档位不在遥测里:取最近一条 PD_LIMIT 事件。模拟板启动时记的是 65 W
|
||||||
|
c, hub, sim, db = env
|
||||||
|
deadline = time.time() + 3
|
||||||
|
while hub.pd(B) is None and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
pd = c.get("/api/boards/%s" % B).json()["pd"]
|
||||||
|
assert (pd["mv"], pd["ma"], pd["limit_mw"], pd["fallback"]) == (20000, 3250, 65000, False)
|
||||||
|
now = 10 ** 13
|
||||||
|
db.many("INSERT INTO events (board_id, seq, t_ms, rail, type, arg, mv, ua, received) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
[(B, 9301, 1, 0, 16, 1, 600, 0, now)]) # 换了充电器重启,这次没读到
|
||||||
|
pd = c.get("/api/boards/%s" % B).json()["pd"]
|
||||||
|
assert pd["ma"] is None and pd["limit_mw"] == 60000 and pd["fallback"] is True and pd["ts"] == now
|
||||||
|
ev = {e["seq"]: e for e in c.get("/api/events", params={"board": B}).json()}
|
||||||
|
assert "读不到" in ev[9301]["name"] and "60 W" in ev[9301]["name"]
|
||||||
|
assert ev[9301]["mv"] is None and ev[9301]["ua"] is None # 不能显示成「0.600 V」
|
||||||
|
boot = next(e for e in ev.values() if e["type"] == 16 and e["seq"] != 9301)
|
||||||
|
assert "PD 20 V / 3.25 A" in boot["name"] and "65 W" in boot["name"]
|
||||||
|
# 固件 0.1.17:遥测里带实时档位;运行中换档的事件(arg 2)单独说明
|
||||||
|
assert c.post("/api/boards/%s/cmd" % B, json={"cmd": "status"}).json()["pd"] == {"st": 8, "ma": 3250}
|
||||||
|
db.many("INSERT INTO events (board_id, seq, t_ms, rail, type, arg, mv, ua, received) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
[(B, 9302, 2, 0, 16, 2, 1000, 5000, now + 1)])
|
||||||
|
ev = {e["seq"]: e for e in c.get("/api/events", params={"board": B}).json()}
|
||||||
|
assert "运行中换档:PD 20 V / 5.00 A" in ev[9302]["name"] and "100 W" in ev[9302]["name"]
|
||||||
|
assert c.get("/api/boards/%s" % B).json()["pd"]["ma"] == 5000
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_endpoint(env):
|
||||||
|
c, hub, sim, db = env
|
||||||
|
ts = now_ms() // 60000 * 60000 - 120000
|
||||||
|
db.many("INSERT INTO board_tele_min (ts, board_id, in_mv_avg, in_ua_avg, n) VALUES (?,?,?,?,?)",
|
||||||
|
[(ts, B, 12000, 250000, 60)])
|
||||||
|
db.energy_rollup()
|
||||||
|
r = c.get("/api/boards/%s/energy" % B).json()
|
||||||
|
day = time.strftime("%Y-%m-%d", time.localtime(ts / 1000))
|
||||||
|
row = next(d for d in r["days"] if d["day"] == day)
|
||||||
|
assert row["in_mws"] >= 180000 and row["secs"] >= 60 and len(row["rails"]) == 5
|
||||||
|
assert r["total"]["mws"] >= 180000 and r["today"] == time.strftime("%Y-%m-%d")
|
||||||
|
assert c.get("/api/boards/ffffffffffff/energy").status_code == 404
|
||||||
|
|||||||
@@ -163,8 +163,8 @@ def test_timeout_is_audited_and_raised(tmp_path):
|
|||||||
|
|
||||||
def test_events_are_synced_incrementally(tmp_path):
|
def test_events_are_synced_incrementally(tmp_path):
|
||||||
hub, sim, db = rig(tmp_path)
|
hub, sim, db = rig(tmp_path)
|
||||||
for i in range(19):
|
for i in range(18):
|
||||||
sim._ev(1, 8, i) # 加上 BOOT 共 20 条:要分 3 页
|
sim._ev(1, 8, i) # 加上 BOOT、PD_LIMIT 共 20 条:要分 3 页
|
||||||
|
|
||||||
async def go():
|
async def go():
|
||||||
await hub.start()
|
await hub.start()
|
||||||
@@ -369,3 +369,33 @@ def test_flash_mode_extend_command():
|
|||||||
{"cmd": "flash_mode", "rail": 1, "add": 30, "min": 10}):
|
{"cmd": "flash_mode", "rail": 1, "add": 30, "min": 10}):
|
||||||
with pytest.raises(protocol.CmdError):
|
with pytest.raises(protocol.CmdError):
|
||||||
protocol.build_command(bad)
|
protocol.build_command(bad)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 耗电量 -------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_energy_is_rolled_up_per_local_day_and_old_days_are_not_shrunk(tmp_path):
|
||||||
|
import time
|
||||||
|
db = Db(str(tmp_path / "e.db"))
|
||||||
|
day_ms = lambda y, m, d, h=12: int(time.mktime((y, m, d, h, 0, 0, 0, 0, -1)) * 1000)
|
||||||
|
d1, d2 = day_ms(2026, 10, 4), day_ms(2026, 10, 5)
|
||||||
|
# 10-04:两个满分钟 12 V × 0.5 A = 6 W ⇒ 720 W·s;10-05:一个只有 30 个样本的分钟 12 W ⇒ 360 W·s
|
||||||
|
db.many("INSERT INTO board_tele_min (ts, board_id, in_mv_avg, in_ua_avg, n) VALUES (?,?,?,?,?)",
|
||||||
|
[(d1, B, 12000, 500000, 60), (d1 + 60000, B, 12000, 500000, 60), (d2, B, 12000, 1000000, 30),
|
||||||
|
(d2 + 60000, B, None, None, 60)]) # 输入侧 INA226 没读到的那一分钟:不计,也不算时长
|
||||||
|
db.many("INSERT INTO tele_min (ts, board_id, rail, mv_avg, ua_avg, ua_min, ua_max, n) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
[(d1, B, 2, 4000, 250000, 0, 0, 60)]) # 第 2 路 1 W × 60 s
|
||||||
|
db.energy_rollup(days_back=1, now=d2)
|
||||||
|
days = db.energy(B)
|
||||||
|
assert [d["day"] for d in days] == ["2026-10-04", "2026-10-05"]
|
||||||
|
assert (days[0]["in_mws"], days[0]["secs"]) == (720000, 120)
|
||||||
|
assert (days[1]["in_mws"], days[1]["secs"]) == (360000, 30)
|
||||||
|
assert days[0]["rails"][1] == {"mws": 60000, "secs": 60} and days[0]["rails"][0] is None
|
||||||
|
assert db.energy_total(B)["mws"] == 1080000 and db.energy_total(B)["since"] == "2026-10-04"
|
||||||
|
|
||||||
|
# 分钟数据被清掉一半之后再滚:窗口内的日子照实重算,窗口外的旧日子保持原值
|
||||||
|
db.x("DELETE FROM board_tele_min WHERE ts=?", (d1,))
|
||||||
|
db.energy_rollup(days_back=0, now=d2)
|
||||||
|
assert db.energy(B)[0]["in_mws"] == 720000
|
||||||
|
db.energy_rollup(days_back=1, now=d2)
|
||||||
|
assert db.energy(B)[0]["in_mws"] == 360000
|
||||||
|
assert len(db.energy(B, days=1)) == 1 and db.energy(B, days=1)[0]["day"] == "2026-10-05"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const api = {
|
|||||||
req('GET', `/api/boards/${board}/rails/${rail}/history?start=${start}${end ? `&end=${end}` : ''}`),
|
req('GET', `/api/boards/${board}/rails/${rail}/history?start=${start}${end ? `&end=${end}` : ''}`),
|
||||||
boardHistory: (board, start, end) =>
|
boardHistory: (board, start, end) =>
|
||||||
req('GET', `/api/boards/${board}/history?start=${start}${end ? `&end=${end}` : ''}`),
|
req('GET', `/api/boards/${board}/history?start=${start}${end ? `&end=${end}` : ''}`),
|
||||||
|
energy: (board, days = 90) => req('GET', `/api/boards/${board}/energy?days=${days}`),
|
||||||
events: (q = {}) => req('GET', '/api/events?' + new URLSearchParams(q)),
|
events: (q = {}) => req('GET', '/api/events?' + new URLSearchParams(q)),
|
||||||
audit: (q = {}) => req('GET', '/api/audit?' + new URLSearchParams(q)),
|
audit: (q = {}) => req('GET', '/api/audit?' + new URLSearchParams(q)),
|
||||||
recovery: () => req('GET', '/api/recovery'),
|
recovery: () => req('GET', '/api/recovery'),
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
// · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线;
|
// · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线;
|
||||||
// · 线宽 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, BarChart } 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, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer])
|
||||||
export { echarts }
|
export { echarts }
|
||||||
|
|
||||||
// 字体是打包进来异步下的,而 zrender 按「字体 → 文字」缓存宽度、从不清空:
|
// 字体是打包进来异步下的,而 zrender 按「字体 → 文字」缓存宽度、从不清空:
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card chart-card">
|
||||||
|
<div class="hh">
|
||||||
|
<h2 class="ttl">耗电量</h2>
|
||||||
|
<el-radio-group v-model="span" size="small">
|
||||||
|
<el-radio-button v-for="o in SPANS" :key="o.v" :value="o.v">{{ o.t }}</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
<div class="tiles">
|
||||||
|
<div v-for="t in tiles" :key="t.k" class="g" :title="t.tip">
|
||||||
|
<div class="gh"><span class="gk">{{ t.k }}</span><span class="gs">{{ t.sub }}</span></div>
|
||||||
|
<div class="gvs"><span class="gv readout">{{ t.e.v }}<small>{{ t.e.u }}</small></span>
|
||||||
|
<span class="gv sm readout">{{ t.w }}<small>W 平均</small></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="el" class="chart" style="height: 220px" role="img" aria-label="每日耗电量柱状图" />
|
||||||
|
<div class="rails" v-if="today">
|
||||||
|
<span class="muted">今日各路主板</span>
|
||||||
|
<span v-for="r in railsToday" :key="r.i" class="rl">
|
||||||
|
<b>#{{ r.i }}</b><template v-if="r.label"> {{ r.label }}</template>
|
||||||
|
<span class="readout">{{ r.e.v }}</span><small>{{ r.e.u }}</small>
|
||||||
|
<span class="readout w">{{ r.w }}</span><small>W</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted note">{{ note }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 每日耗电量:后端把分钟聚合滚成 energy_day(db.py energy_rollup),这里只做单位换算和求和。
|
||||||
|
// 量的是 12 V 母线上的底板输入(U13),不含 20 → 12 V 那级降压的损耗,墙上实际再多几个点。
|
||||||
|
// 平均功率 = 能量 ÷ 有数据的时长:板子离线的时段不算进分母,所以它是「开着的时候平均多少瓦」。
|
||||||
|
// 一个量一种颜色:柱子用调色板第 1 槽;今天还没过完,用同色半透明区分,不另起一种颜色。
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { state } from '../store'
|
||||||
|
import { dark } from '../theme'
|
||||||
|
import { echarts, palette, band, chartFonts } from '../chart'
|
||||||
|
|
||||||
|
const props = defineProps({ board: String })
|
||||||
|
const SPANS = [{ v: 14, t: '14 天' }, { v: 30, t: '30 天' }, { v: 90, t: '90 天' }, { v: 3660, t: '全部' }]
|
||||||
|
const span = ref(30)
|
||||||
|
const el = ref(null)
|
||||||
|
const data = ref(null) // { today, days: [{ day, in_mws, secs, rails }], total }
|
||||||
|
const note = ref('')
|
||||||
|
let chart = null
|
||||||
|
|
||||||
|
// mW·s → 显示:不到 1 kWh 用 Wh
|
||||||
|
const fmtE = mws => {
|
||||||
|
if (mws == null) return { v: '—', u: 'Wh' }
|
||||||
|
const wh = mws / 3.6e6
|
||||||
|
return wh >= 1000 ? { v: (wh / 1000).toFixed(2), u: 'kWh' } : { v: wh.toFixed(wh >= 100 ? 0 : 1), u: 'Wh' }
|
||||||
|
}
|
||||||
|
const fmtW = (mws, secs) => (mws == null || !secs ? '—' : (mws / secs / 1000).toFixed(2))
|
||||||
|
const hours = secs => (secs / 3600).toFixed(1)
|
||||||
|
|
||||||
|
const byDay = computed(() => Object.fromEntries((data.value?.days || []).map(d => [d.day, d])))
|
||||||
|
const today = computed(() => byDay.value[data.value?.today] || null)
|
||||||
|
// 最近 n 个日历日(含今天)的合计
|
||||||
|
function lastDays(n) {
|
||||||
|
if (!data.value) return { mws: null, secs: 0 }
|
||||||
|
const end = new Date(data.value.today + 'T00:00:00')
|
||||||
|
let mws = null, secs = 0
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const d = new Date(end); d.setDate(end.getDate() - i)
|
||||||
|
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
const row = byDay.value[k]
|
||||||
|
if (row?.in_mws != null) { mws = (mws ?? 0) + row.in_mws; secs += row.secs }
|
||||||
|
}
|
||||||
|
return { mws, secs }
|
||||||
|
}
|
||||||
|
const tiles = computed(() => {
|
||||||
|
const mk = (k, x, sub, tip) => ({ k, sub, tip, e: fmtE(x.mws), w: fmtW(x.mws, x.secs) })
|
||||||
|
const t = lastDays(1), y2 = lastDays(2), w = lastDays(7), m = lastDays(30)
|
||||||
|
const y = { mws: y2.mws == null || !(y2.secs - t.secs) ? null : y2.mws - (t.mws ?? 0), secs: y2.secs - t.secs }
|
||||||
|
const tot = data.value?.total
|
||||||
|
return [
|
||||||
|
mk('今日', t, t.secs ? `已记 ${hours(t.secs)} h` : '还没有数据', '从今天 0 点到现在,最多滞后两分钟'),
|
||||||
|
mk('昨日', y, y.secs ? `${hours(y.secs)} h` : ''),
|
||||||
|
mk('近 7 天', w, w.secs ? `${hours(w.secs)} h` : ''),
|
||||||
|
mk('近 30 天', m, m.secs ? `${hours(m.secs)} h` : ''),
|
||||||
|
mk('累计', tot?.days ? tot : { mws: null, secs: 0 }, tot?.since ? `自 ${tot.since.slice(5)} 起 ${tot.days} 天` : ''),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const railsToday = computed(() => {
|
||||||
|
const phones = state.boards[props.board]?.phones || []
|
||||||
|
return (today.value?.rails || []).map((r, i) => ({
|
||||||
|
i: i + 1, label: phones.find(p => p.rail === i + 1)?.label || '',
|
||||||
|
e: fmtE(r?.mws), w: fmtW(r?.mws, r?.secs),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!chart || !data.value) return
|
||||||
|
const p = palette()
|
||||||
|
const days = data.value.days
|
||||||
|
const railsSum = d => d.rails.reduce((s, r) => (r ? (s ?? 0) + r.mws : s), null)
|
||||||
|
chart.setOption({
|
||||||
|
animation: false,
|
||||||
|
textStyle: { fontFamily: getComputedStyle(document.body).fontFamily },
|
||||||
|
grid: { left: 52, right: 16, top: 28, bottom: 28 },
|
||||||
|
xAxis: { type: 'category', data: days.map(d => d.day.slice(5)), axisLine: { lineStyle: { color: p.axis } },
|
||||||
|
axisTick: { show: false }, axisLabel: { color: p.muted, fontSize: 11, hideOverlap: true } },
|
||||||
|
yAxis: { type: 'value', name: '每日耗电 Wh', min: 0, splitNumber: 3,
|
||||||
|
nameTextStyle: { color: p.ink2, fontSize: 12, align: 'left', padding: [0, 0, 0, -34] },
|
||||||
|
axisLabel: { color: p.muted, fontSize: 11 }, splitLine: { lineStyle: { color: p.grid } } },
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow', shadowStyle: { color: band(p.s1, 0.06) } },
|
||||||
|
backgroundColor: p.panel, borderColor: p.axis, textStyle: { color: p.ink, fontSize: 12 },
|
||||||
|
formatter: ps => {
|
||||||
|
const d = days[ps[0].dataIndex]
|
||||||
|
const e = fmtE(d.in_mws), r = railsSum(d), re = fmtE(r)
|
||||||
|
const loss = d.in_mws == null || r == null ? null : fmtE(d.in_mws - r)
|
||||||
|
return [`<b>${d.day}</b>${d.day === data.value.today ? '(今天,未满)' : ''}`,
|
||||||
|
`底板输入 ${e.v} ${e.u}`, `平均功率 ${fmtW(d.in_mws, d.secs)} W`,
|
||||||
|
`五路主板 ${re.v} ${re.u}`, ...(loss ? [`板上损耗 ${loss.v} ${loss.u}`] : []),
|
||||||
|
`有数据 ${hours(d.secs)} h`].join('<br>')
|
||||||
|
} },
|
||||||
|
series: [{ type: 'bar', barMaxWidth: 22, itemStyle: { borderRadius: [2, 2, 0, 0] },
|
||||||
|
data: days.map(d => ({ value: d.in_mws == null ? null : +(d.in_mws / 3.6e6).toFixed(1),
|
||||||
|
itemStyle: { color: d.day === data.value.today ? band(p.s1, 0.5) : p.s1 } })) }],
|
||||||
|
}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
data.value = await api.energy(props.board, span.value)
|
||||||
|
note.value = data.value.days.length === 0 ? '还没有耗电记录(板子上线一两分钟后出现)'
|
||||||
|
: '量的是底板 12 V 输入,不含 PD 20 → 12 V 降压的损耗;平均功率只按有数据的时段算。浅色柱是今天(未满一天)'
|
||||||
|
render()
|
||||||
|
} catch (e) {
|
||||||
|
note.value = '耗电量没取到:' + e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onResize = () => chart && chart.resize()
|
||||||
|
let gone = false
|
||||||
|
let timer = null
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
timer = setInterval(load, 60000)
|
||||||
|
await chartFonts('每日耗电')
|
||||||
|
if (gone) return
|
||||||
|
chart = echarts.init(el.value)
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => { gone = true; clearInterval(timer); window.removeEventListener('resize', onResize); chart && chart.dispose() })
|
||||||
|
watch(() => [props.board, span.value], load)
|
||||||
|
watch(dark, render)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tiles { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; margin: 12px 0 4px; }
|
||||||
|
.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: 20px; }
|
||||||
|
.gk { color: var(--muted); font-size: 13px; white-space: nowrap; }
|
||||||
|
.gs { color: var(--muted); font-size: 11px; text-align: right; min-width: 0; }
|
||||||
|
.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 small { font-size: 11px; color: var(--muted); margin-left: 3px; font-family: var(--ui); }
|
||||||
|
.rails { display: flex; flex-wrap: wrap; gap: 4px 20px; align-items: baseline; font-size: 13px; color: var(--ink-2); margin-top: 4px; }
|
||||||
|
.rl small { color: var(--muted); font-size: 11px; margin: 0 2px; }
|
||||||
|
.rl b { color: var(--ink); font-weight: 600; margin-right: 4px; }
|
||||||
|
.rl .readout { margin-left: 4px; }
|
||||||
|
.rl .w { margin-left: 8px; }
|
||||||
|
@media (max-width: 1200px) { .tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 700px) { .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } .gv { font-size: 20px; } }
|
||||||
|
</style>
|
||||||
@@ -31,6 +31,12 @@
|
|||||||
<div class="g"><div class="gh"><span class="gk">输入</span></div>
|
<div class="g"><div class="gh"><span class="gk">输入</span></div>
|
||||||
<div class="gvs"><span class="gv readout">{{ V(b.status.in?.mv) }}<small>V</small></span>
|
<div class="gvs"><span class="gv readout">{{ V(b.status.in?.mv) }}<small>V</small></span>
|
||||||
<span class="gv readout">{{ mA(b.status.in?.ua) }}<small>mA</small></span></div></div>
|
<span class="gv readout">{{ mA(b.status.in?.ua) }}<small>mA</small></span></div></div>
|
||||||
|
<div class="g" :title="PD_TIP"><div class="gh"><span class="gk">PD 供电</span><span class="gs">{{ pdNote }}</span></div>
|
||||||
|
<div class="gvs" v-if="pd"><span class="gv readout">{{ pd.mv / 1000 }}<small>V</small></span>
|
||||||
|
<span class="gv readout" :class="{ bad: pd.fallback }">{{ pd.ma == null ? '—' : (pd.ma / 1000).toFixed(2) }}<small>A</small></span>
|
||||||
|
<span class="gv sm readout">{{ pd.limit_mw / 1000 }}<small>W 上限</small></span>
|
||||||
|
<span v-if="pdUsed != null" class="gv sm readout" :class="{ bad: pdUsed >= 90 }">{{ pdUsed }}<small>% 已用</small></span></div>
|
||||||
|
<div class="gvs" v-else><span class="gv readout">—</span></div></div>
|
||||||
<div class="g"><div class="gh"><span class="gk">功率</span><span class="gs">底板输入 · 主板合计 · 损耗</span></div>
|
<div class="g"><div class="gh"><span class="gk">功率</span><span class="gs">底板输入 · 主板合计 · 损耗</span></div>
|
||||||
<div class="gvs"><span class="gv readout">{{ W(b.status.in?.mv, b.status.in?.ua) }}<small>W</small></span>
|
<div class="gvs"><span class="gv readout">{{ W(b.status.in?.mv, b.status.in?.ua) }}<small>W</small></span>
|
||||||
<span class="gv sm readout">{{ fmtW(pw.rails) }}<small>W 主板</small></span>
|
<span class="gv sm readout">{{ fmtW(pw.rails) }}<small>W 主板</small></span>
|
||||||
@@ -58,6 +64,8 @@
|
|||||||
<BoardChart :board="b.id" />
|
<BoardChart :board="b.id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EnergyCard :board="b.id" />
|
||||||
|
|
||||||
<div class="two">
|
<div class="two">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2 class="ttl">最近事件</h2>
|
<h2 class="ttl">最近事件</h2>
|
||||||
@@ -86,6 +94,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import HistoryChart from '../components/HistoryChart.vue'
|
import HistoryChart from '../components/HistoryChart.vue'
|
||||||
import BoardChart from '../components/BoardChart.vue'
|
import BoardChart from '../components/BoardChart.vue'
|
||||||
|
import EnergyCard from '../components/EnergyCard.vue'
|
||||||
import OtaState from '../components/OtaState.vue'
|
import OtaState from '../components/OtaState.vue'
|
||||||
import Topology from '../components/Topology.vue'
|
import Topology from '../components/Topology.vue'
|
||||||
import { api, rspText } from '../api'
|
import { api, rspText } from '../api'
|
||||||
@@ -101,6 +110,36 @@ const pw = computed(() => {
|
|||||||
const rails = railsMw(s?.rails)
|
const rails = railsMw(s?.rails)
|
||||||
return { rails, loss: input == null || rails == null ? null : input - rails }
|
return { rails, loss: input == null || rails == null ? null : input - rails }
|
||||||
})
|
})
|
||||||
|
// CH224A(U36)协商到的 PD 档位。固件 ≥0.1.17 每秒读一次、放在遥测的 status.pd({st, ma},这一拍没读到是 null);
|
||||||
|
// 老固件只在启动时读一次、记成「PD 限功率」事件,后端取最近一条放进快照的 b.pd —— 没有实时值时退回它。
|
||||||
|
// 「已用」拿 12 V 母线上的输入功率比上限,没算 20 → 12 V 那级降压的损耗,实际从充电器取的还要多几个点。
|
||||||
|
const PD_TIP = 'CH224A(U36)向充电器请求的是固定 20 V 档;电流是充电器在这一档此刻最多肯给的(它广播的 Source Capabilities),' +
|
||||||
|
'固件按它定整板功率上限(读不到按 60 W)。只有 3 A:充电器本身 ≤65 W、线不是带 E-Marker 的 5 A 线,' +
|
||||||
|
'或者多口充电器别的口正在用、把这一口降了档 —— 三种情况受电端看到的都一样'
|
||||||
|
// 0x09 协议状态的各位(CH224A 手册):哪种快充协议握上了
|
||||||
|
const PD_PROTO = [[0x10, 'EPR'], [0x08, 'PD'], [0x04, 'QC3'], [0x02, 'QC2'], [0x01, 'BC1.2']]
|
||||||
|
const pd = computed(() => {
|
||||||
|
const ev = b.value?.pd
|
||||||
|
const live = b.value?.status?.pd
|
||||||
|
if (live) {
|
||||||
|
const ma = live.ma || null
|
||||||
|
return { mv: 20000, ma, limit_mw: ma ? 20 * ma : ev?.limit_mw ?? 60000, fallback: !ma, live: true,
|
||||||
|
proto: PD_PROTO.filter(([bit]) => live.st & bit).map(([, n]) => n).join('+') || '未握手' }
|
||||||
|
}
|
||||||
|
return ev ? { ...ev, live: false, missed: live === null } : null
|
||||||
|
})
|
||||||
|
const pdNote = computed(() => {
|
||||||
|
const p = pd.value
|
||||||
|
if (!p) return '还没收到启动事件'
|
||||||
|
if (p.live) return `实时 · ${p.proto}`
|
||||||
|
if (p.missed) return 'CH224A 这一拍没读到'
|
||||||
|
return p.fallback ? 'CH224A 读不到 · 按保守值' : `启动时读 · ${ago(p.ts)}`
|
||||||
|
})
|
||||||
|
const pdUsed = computed(() => {
|
||||||
|
const p = pd.value
|
||||||
|
const input = mW(b.value?.status?.in?.mv, b.value?.status?.in?.ua)
|
||||||
|
return !p?.limit_mw || input == null ? null : Math.round(input / p.limit_mw * 100)
|
||||||
|
})
|
||||||
// 固件 0.1.3 起才有 host 字段;老固件没有 ⇒ 显示「—」
|
// 固件 0.1.3 起才有 host 字段;老固件没有 ⇒ 显示「—」
|
||||||
const HOST = { true: '已连电脑', false: '未连电脑' }
|
const HOST = { true: '已连电脑', false: '未连电脑' }
|
||||||
const fmtW = v => (v == null ? '—' : (v / 1000).toFixed(2))
|
const fmtW = v => (v == null ? '—' : (v / 1000).toFixed(2))
|
||||||
@@ -188,8 +227,8 @@ watch(() => props.id, refresh)
|
|||||||
.meta div { display: flex; gap: 10px; align-items: center; min-width: 0; }
|
.meta div { display: flex; gap: 10px; align-items: center; min-width: 0; }
|
||||||
.meta dt { color: var(--muted); width: 28px; flex-shrink: 0; }
|
.meta dt { color: var(--muted); width: 28px; flex-shrink: 0; }
|
||||||
.meta dd { margin: 0; color: var(--ink-2); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
.meta dd { margin: 0; color: var(--ink-2); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
/* 五个仪表格:凹进面板的小槽,标签在上、读数在下 */
|
/* 六个仪表格:凹进面板的小槽,标签在上、读数在下 */
|
||||||
.gauges { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; }
|
.gauges { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }
|
||||||
.g { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay); padding: 10px 14px 12px;
|
.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; }
|
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; }
|
.gh { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; }
|
||||||
@@ -210,7 +249,7 @@ watch(() => props.id, refresh)
|
|||||||
.quiet { color: var(--muted); }
|
.quiet { color: var(--muted); }
|
||||||
.charts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
|
.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; }
|
.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: 1700px) { .gauges { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||||
@media (max-width: 1200px) { .charts { grid-template-columns: 1fr; }
|
@media (max-width: 1200px) { .charts { grid-template-columns: 1fr; }
|
||||||
.gauges { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
.gauges { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
@media (max-width: 800px) { .two { grid-template-columns: 1fr; } }
|
@media (max-width: 800px) { .two { grid-template-columns: 1fr; } }
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户