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]>
这个提交包含在:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
在新工单中引用
屏蔽一个用户