diff --git a/rack/backend/prrack/api.py b/rack/backend/prrack/api.py
index 4732513..123a4a5 100644
--- a/rack/backend/prrack/api.py
+++ b/rack/backend/prrack/api.py
@@ -114,6 +114,15 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
_board_or_404(hub, board)
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")
def history(board: str, rail: int, start: int, end: Optional[int] = None):
_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:
d["name"] += ":" + protocol.OTA_ERR_NAMES.get(d["mv"], str(d["mv"]))
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:
a = d["arg"]
d["name"] += "(%s)" % ("手动关" if a == protocol.CHG_EV_OFF else
diff --git a/rack/backend/prrack/db.py b/rack/backend/prrack/db.py
index 0397db0..01fe807 100644
--- a/rack/backend/prrack/db.py
+++ b/rack/backend/prrack/db.py
@@ -4,6 +4,7 @@
每分钟降采样一行(均值 / 最小 / 最大)留 `MIN_KEEP_S`(90 天)——
电流曲线的用途是「这台手机前天夜里是不是卡死过」,一分钟的 min/max 足够看出来。
事件、审计、恢复记录**永久保留**(量小,而且是排查的唯一线索)。
+每日耗电量(`energy_day`)也永久保留:它从分钟聚合里滚出来,分钟数据过了 90 天被清掉之后就再也算不回来了。
⚠️ 时间戳一律是**服务器收到的时刻**(毫秒)。板子没有墙钟(需求 §10.12:
上电从 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,
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 (
board_id TEXT NOT NULL, seq INTEGER NOT NULL,
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 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:
"""[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据,
否则回分钟聚合(带 min/max,界面画成带状)。点太多时按时间桶再抽一次。"""
diff --git a/rack/backend/prrack/hub.py b/rack/backend/prrack/hub.py
index db6fdf1..096e7fb 100644
--- a/rack/backend/prrack/hub.py
+++ b/rack/backend/prrack/hub.py
@@ -58,6 +58,7 @@ class Hub:
self.bus.start()
for kind in ("rsp", "tele", "state"):
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())]
async def stop(self):
@@ -192,6 +193,8 @@ class Hub:
self.db.x("UPDATE boards SET ev_cursor=? WHERE id=?", (nxt, board))
if ev:
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:
return
except CmdTimeout:
@@ -200,6 +203,16 @@ class Hub:
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:
b = self._board(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
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"],
- "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]}
def boards(self) -> List[dict]:
@@ -257,6 +270,7 @@ class Hub:
await asyncio.sleep(MAINT_S)
try:
self.db.downsample()
+ self.db.energy_rollup() # 先滚再清:今天 / 昨天每分钟重算一遍,量是几千行
self.db.prune()
except Exception:
log.exception("降采样 / 清理失败")
diff --git a/rack/backend/prrack/protocol.py b/rack/backend/prrack/protocol.py
index 8100ba2..9087e67 100644
--- a/rack/backend/prrack/protocol.py
+++ b/rack/backend/prrack/protocol.py
@@ -42,6 +42,21 @@ EVENT_TYPES: Dict[int, Tuple[str, str]] = {
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)。⚠️ 冻结。
HUB_EV_OFF, HUB_EV_ON, HUB_EV_MODE = 0, 1, 0x10
HUB_MODES = ("auto", "on") # hub_mode_t 的下标
diff --git a/rack/backend/prrack/simboard.py b/rack/backend/prrack/simboard.py
index 61924c7..247afcd 100644
--- a/rack/backend/prrack/simboard.py
+++ b/rack/backend/prrack/simboard.py
@@ -52,8 +52,10 @@ class SimBoard:
self.ota_req: Optional[dict] = None
self.ota_rate = 400000 # 每次 tick 下载多少字节(真板 ≈125 KB/s)
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
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):
@@ -143,6 +145,7 @@ class SimBoard:
"rpm": [3300, 3280], "stall": [False, False]}, "derate": False,
"safety": {"cycles": self.up_ms // 20, "skipped": 0, "i2c_err": 0, "events": 0},
"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,
"cmd": {"lines": len(self.received), "dups": 0, "rej": 0}}
diff --git a/rack/backend/tests/test_api.py b/rack/backend/tests/test_api.py
index 6837c01..dbce44a 100644
--- a/rack/backend/tests/test_api.py
+++ b/rack/backend/tests/test_api.py
@@ -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 "休眠" in ev[9202]["name"] and ev[9202]["severe"] is False
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
diff --git a/rack/backend/tests/test_core.py b/rack/backend/tests/test_core.py
index fb8d5a5..22ce37f 100644
--- a/rack/backend/tests/test_core.py
+++ b/rack/backend/tests/test_core.py
@@ -163,8 +163,8 @@ def test_timeout_is_audited_and_raised(tmp_path):
def test_events_are_synced_incrementally(tmp_path):
hub, sim, db = rig(tmp_path)
- for i in range(19):
- sim._ev(1, 8, i) # 加上 BOOT 共 20 条:要分 3 页
+ for i in range(18):
+ sim._ev(1, 8, i) # 加上 BOOT、PD_LIMIT 共 20 条:要分 3 页
async def go():
await hub.start()
@@ -369,3 +369,33 @@ def test_flash_mode_extend_command():
{"cmd": "flash_mode", "rail": 1, "add": 30, "min": 10}):
with pytest.raises(protocol.CmdError):
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"
diff --git a/rack/frontend/src/api.js b/rack/frontend/src/api.js
index 50aef89..f3d2ac2 100644
--- a/rack/frontend/src/api.js
+++ b/rack/frontend/src/api.js
@@ -26,6 +26,7 @@ export const api = {
req('GET', `/api/boards/${board}/rails/${rail}/history?start=${start}${end ? `&end=${end}` : ''}`),
boardHistory: (board, start, 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)),
audit: (q = {}) => req('GET', '/api/audit?' + new URLSearchParams(q)),
recovery: () => req('GET', '/api/recovery'),
diff --git a/rack/frontend/src/chart.js b/rack/frontend/src/chart.js
index 482aafa..fc1657a 100644
--- a/rack/frontend/src/chart.js
+++ b/rack/frontend/src/chart.js
@@ -6,12 +6,12 @@
// · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线;
// · 线宽 2px,不画数据点;min~max 画成同色 15% 的带子。
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 { CanvasRenderer } from 'echarts/renderers'
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 }
// 字体是打包进来异步下的,而 zrender 按「字体 → 文字」缓存宽度、从不清空:
diff --git a/rack/frontend/src/components/EnergyCard.vue b/rack/frontend/src/components/EnergyCard.vue
new file mode 100644
index 0000000..4cb6a2c
--- /dev/null
+++ b/rack/frontend/src/components/EnergyCard.vue
@@ -0,0 +1,169 @@
+
+ {{ note }}耗电量
+