文件
phonerail/rack/backend/prrack/api.py
T
zhou yang和Claude Opus 5.5 580468e48e 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]>
2026-10-06 06:31:04 +08:00

289 行
13 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""HTTP + WebSocket 接口。前端静态文件也由这里托管(同源,门户 vhost 直接反代到本进程)。
身份:统一门户在 vhost 里**覆盖式**注入 `X-Auth-User`(gen-vhosts.py),本进程只绑 127.0.0.1,
所以这个头只可能来自门户 —— 与 evse 一样按 L2 信任它,用来记审计「是谁按的」。
直接在服务器本机 curl(没有这个头)记作 `local`。
⚠️ 这个信任**只在绑 127.0.0.1 时成立**。哪天改绑 0.0.0.0,外面加一个 `X-Auth-User: admin`
就能冒名写审计 —— 所以 main.py 拒绝非回环地址的绑定。
"""
import asyncio
import json
from contextlib import asynccontextmanager
from typing import Awaitable, Callable, List, Optional
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import protocol
from .db import now_ms
from .firmware import SLOT_BYTES, FwError, FwStore
from .hub import CmdTimeout, Hub
from .recovery import Params, RecoveryManager
def _user(req: Request) -> str:
return req.headers.get("x-auth-user") or "local"
def _board_or_404(hub: Hub, board: str):
if board not in hub.latest:
raise HTTPException(404, "没有这块板子:%s" % board)
Hook = Callable[[], Awaitable[None]]
def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
startup: Optional[List[Hook]] = None, shutdown: Optional[List[Hook]] = None,
fw: Optional[FwStore] = None) -> FastAPI:
# 用 lifespan 而不是 on_event:后者在新版 Starlette 里已经删了,服务器上的旧版两种都认。
@asynccontextmanager
async def lifespan(app):
for fn in startup or []:
await fn()
yield
for fn in shutdown or []:
await fn()
app = FastAPI(title="PhoneRail 机架管理", docs_url="/api/docs", openapi_url="/api/openapi.json",
lifespan=lifespan)
@app.get("/api/health")
def health():
return {"ok": True, "mqtt": hub.mqtt_up, "boards": len(hub.latest), "ts": now_ms()}
@app.get("/api/me")
def me(req: Request):
return {"user": _user(req), "roles": req.headers.get("x-auth-roles", "")}
# ---- 板子 ----------------------------------------------------------------
@app.get("/api/boards")
def boards():
return hub.boards()
@app.get("/api/boards/{board}")
def board(board: str):
_board_or_404(hub, board)
return hub.snapshot(board)
@app.patch("/api/boards/{board}")
async def patch_board(board: str, req: Request):
_board_or_404(hub, board)
body = await req.json()
for k in ("name", "location"):
if k in body:
hub.db.x("UPDATE boards SET %s=? WHERE id=?" % k, (str(body[k])[:64], board))
snap = hub.snapshot(board)
hub.broadcast({"t": "board", "board": snap})
return snap
@app.patch("/api/boards/{board}/rails/{rail}")
async def patch_phone(board: str, rail: int, req: Request):
_board_or_404(hub, board)
if not 1 <= rail <= protocol.RAILS:
raise HTTPException(400, "rail 必须是 1~5")
body = await req.json()
for k in ("label", "adb_addr", "notes"):
if k in body:
hub.db.x("UPDATE phones SET %s=? WHERE board_id=? AND rail=?" % k,
(str(body[k])[:128], board, rail))
if "auto_recover" in body:
hub.db.x("UPDATE phones SET auto_recover=? WHERE board_id=? AND rail=?",
(1 if body["auto_recover"] else 0, board, rail))
rec.reload()
snap = hub.snapshot(board)
hub.broadcast({"t": "board", "board": snap})
return snap
@app.post("/api/boards/{board}/cmd")
async def command(board: str, req: Request):
_board_or_404(hub, board)
try:
cmd = protocol.build_command(await req.json())
except (protocol.CmdError, ValueError) as e:
raise HTTPException(400, str(e))
try:
return await hub.call(board, cmd, _user(req))
except CmdTimeout as e:
raise HTTPException(504, str(e))
@app.get("/api/boards/{board}/history")
def board_history(board: str, start: int, end: Optional[int] = None):
"""整板曲线(温度 / 风扇 / 输入 / 五路主板合计功率),见 Db.board_history。"""
_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)
return hub.db.history(board, rail, start, end or now_ms())
# ---- 固件 / OTA(firmware.py)---------------------------------------------
def _fw() -> FwStore:
if fw is None:
raise HTTPException(503, "后端没有配置固件目录(--fw-dir)")
return fw
@app.get("/api/firmware")
def fw_list():
return {"items": _fw().list(), "slot": SLOT_BYTES}
@app.post("/api/firmware")
async def fw_upload(req: Request, filename: str = ""):
# 请求体就是 .bin 原样(application/octet-stream),不走 multipart:省一个依赖(python-multipart)
store = _fw()
data = await req.body()
try:
row = store.add(data, filename, _user(req))
except FwError as e:
raise HTTPException(400, str(e))
hub.broadcast({"t": "firmware"})
return row
@app.delete("/api/firmware/{sha}")
def fw_delete(sha: str):
if not _fw().delete(sha):
raise HTTPException(404, "固件库里没有这个镜像")
hub.broadcast({"t": "firmware"})
return {"ok": True}
@app.post("/api/boards/{board}/ota")
async def ota(board: str, req: Request):
"""推固件。body = {"sha256": ..., "reboot": false}。回应是板子的原话(act = start / reboot / ready),
进度看遥测里的 `ota` 一块。⚠️ reboot=true 会让这块板上的五台手机断电一次。"""
_board_or_404(hub, board)
body = await req.json()
try:
cmd = _fw().command(body.get("sha256"), body.get("reboot", False))
except FwError as e:
raise HTTPException(400, str(e))
try:
return await hub.call(board, cmd, _user(req))
except CmdTimeout as e:
raise HTTPException(504, str(e))
# ---- 事件 / 审计 / 恢复 ----------------------------------------------------
@app.get("/api/events")
def events(board: Optional[str] = None, limit: int = 200, before: Optional[int] = None):
sql = "SELECT * FROM events WHERE 1=1"
args = []
if board:
sql += " AND board_id=?"
args.append(board)
if before:
sql += " AND received<?"
args.append(before)
sql += " ORDER BY received DESC, seq DESC LIMIT ?"
args.append(min(limit, 1000))
out = []
for r in hub.db.q(sql, args):
d = dict(r)
code, zh = protocol.EVENT_TYPES.get(d["type"], ("?", "未知事件 %d" % d["type"]))
d.update(code=code, name=zh, severe=d["type"] in protocol.EVENT_SEVERE)
if d["type"] == 13:
d["name"] += "(%s)" % protocol.CMD_NAMES.get(d["arg"], d["arg"])
elif d["type"] == 18:
d["name"] += "(%s)" % protocol.OTA_EV_NAMES.get(d["arg"], d["arg"])
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
"到期" if a == protocol.CHG_EV_EXPIRED else
"断电自动退出" if a == protocol.CHG_EV_DROPPED else
"加钟 %d 分钟" % (a & 0x7F) if a & protocol.CHG_EV_EXTEND else
"开 %d 分钟" % a)
elif d["type"] == 22:
a = d["arg"]
if a & protocol.HUB_EV_MODE:
m = protocol.HUB_MODES[a & 0x0F] if (a & 0x0F) < len(protocol.HUB_MODES) else str(a & 0x0F)
d["name"] += "(模式改为%s)" % protocol.HUB_MODE_NAMES.get(m, m)
elif a == protocol.HUB_EV_ON:
d["name"] += "(唤醒:%s)" % protocol.HUB_WHY_NAMES.get(d["mv"], d["mv"])
else:
d["name"] += "(休眠)"
d["mv"] = None # mV 列装的是理由码,不是电压 —— 别让界面显示成「0.002 V」
elif d["type"] == 19:
failed = bool(d["arg"] & protocol.BOOT_CHECK_FAILED)
d["name"] += "(按了 %d 次,%s)" % (d["arg"] & 0x0F, "没开起来" if failed else "开起来了")
d["severe"] = failed
out.append(d)
return out
@app.get("/api/audit")
def audit(limit: int = 200, board: Optional[str] = None):
sql, args = "SELECT * FROM audit", []
if board:
sql, args = sql + " WHERE board_id=?", [board]
rows = hub.db.q(sql + " ORDER BY id DESC LIMIT ?", args + [min(limit, 1000)])
return [dict(r, request=json.loads(r["request"]), response=json.loads(r["response"]))
for r in rows]
@app.get("/api/recovery")
def recovery():
from dataclasses import asdict
log = hub.db.q("SELECT * FROM recovery_log ORDER BY id DESC LIMIT 200")
return {"params": asdict(rec.params), "states": rec.states(), "log": [dict(r) for r in log]}
@app.put("/api/recovery/params")
async def recovery_params(req: Request):
from dataclasses import asdict
p = Params.load(json.dumps(await req.json()))
rec.set_params(p)
return asdict(p)
@app.post("/api/recovery/{board}/{rail}/reset")
def recovery_reset(board: str, rail: int):
rec.reset(board, rail)
return {"ok": True}
# ---- 实时推送 --------------------------------------------------------------
@app.websocket("/api/ws")
async def ws(sock: WebSocket):
await sock.accept()
q = hub.subscribe_client()
try:
await sock.send_json({"t": "hello", "boards": hub.boards(), "mqtt": hub.mqtt_up,
"user": sock.headers.get("x-auth-user") or "local"})
while hub.is_subscribed(q):
try:
msg = await asyncio.wait_for(q.get(), 20)
except asyncio.TimeoutError:
msg = {"t": "ping", "ts": now_ms()} # 保活,顺带让前端判断连接还活着
await sock.send_json(msg)
except WebSocketDisconnect:
pass
finally:
hub.unsubscribe_client(q)
# ---- 前端 ------------------------------------------------------------------
if static_dir:
app.mount("/assets", StaticFiles(directory=static_dir + "/assets"), name="assets")
@app.get("/{path:path}")
def spa(path: str):
# index.html 不许缓存(home-server-deploy §关键约定 8 的教训);assets 带哈希,可以长缓存
resp = FileResponse(static_dir + "/index.html")
resp.headers["Cache-Control"] = "no-cache"
return resp
return app