- 总览:每块板一块面板、五个刀片槽(状态灯 + DIN 读数 + 最近两分钟电流小曲线,平线 = 疑似卡死);
只有异常 / 故障 / 执行中上色;顶上一句话概况,有问题直接列出是哪几路
- 单板页:新增「底板温度」(两路 NTC + 风扇占空比,上下两块共用时间轴,10 分钟 ~ 7 天);
每路电流 / 电压去掉双 Y 轴,改上下两块,电压固定 3.5~4.1 V;名称 / 位置就地编辑
- 主题:theme.css 亮 / 暗两套变量(跟随系统,Element Plus 暗色变量同挂 html.dark),图表切换时重画;
读数用系统自带 DIN 系字体(不加载网络字体);温度两色按本页亮暗底色跑过 dataviz validate_palette.js 全通过
- 后端:board_tele_min 分钟聚合(温度带 min/max,留 90 天)+ GET /api/boards/{id}/history(Db.board_history)
- 修一个偶发失败的测试(test_ota_events_are_named_by_arg:只推一次遥测,赶上 Hub 正在同步时会漏触发);
后端 42 例本机 + 服务器全绿,已部署;线上已取到板 B 温度 24.4 / 26.0 °C
Co-Authored-By: Claude Opus 5.5 <[email protected]>
252 行
10 KiB
Python
252 行
10 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}/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)
|
||
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
|