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]>
289 行
13 KiB
Python
289 行
13 KiB
Python
"""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
|