- 每路主板功率(V×I):总览槽位、单板卡片、单路曲线新增「功率」一张 - 底板:单板页新增功率仪表(底板输入 / 五路主板合计 / 损耗),底板曲线加 功率图(输入 vs 主板合计);board_history 每点多回一列 rails_mw - 底板曲线改为纵向 N 张(温度 / 风扇 / 输入电压 / 输入电流 / 功率),断档断线 - LOG_EV_BOOT_CHECK(19)事件命名,只有「没开起来」标红 Co-Authored-By: Claude Opus 5.5 <[email protected]>
256 行
10 KiB
Python
256 行
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)
|
||
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
|