机架管理前后端(rack/):FastAPI + SQLite + Vue3,经统一门户上线
- 后端 prrack:MQTT(phonerail-backend @ 127.0.0.1:1884)↔ 遥测入库 / 事件增量同步 / WebSocket 推送; 命令白名单 + 重传同 seq;审计记门户 X-Auth-User(只绑 127.0.0.1,main.py 拒绝非回环)。 - 自动恢复:只看电流(平线 / 过低,服务器够不到手机 adb),先长按电源再断电重启, 每小时限次后停手;默认逐台关闭。adb 探活接口 recovery.Prober 预留。 - 前端:总览 / 板子页(五路操作、电流电压曲线)/ 事件 / 操作记录 / 自动恢复;手机宽度可用。 - 部署 deploy/deploy.sh:服务器 Python 3.8 上先跑测试再替换;systemd phonerail-rack,每日备份。 - 门户应用 id 16、key rack(phonerail 已被需求文档站占用),授权 admin + staff。 测试 26 例(本机 3.14 / 服务器 3.8 均过)。线上板 b81f3fc9c8bc 在线,WS 经门户 101 + 1 条/s 遥测。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
+8
@@ -1,5 +1,13 @@
|
||||
# 进度与交接
|
||||
|
||||
## ⭐ 2026-09-27 下午:机架管理全链路打通(固件 A12~A14 + 服务器前后端)
|
||||
|
||||
- **固件**(`firmware/HANDOFF.md`):A12 命令处理器、A13 USB 分帧、A14 WiFi/MQTT + C1 配网,板 B(`b81f3fc9c8bc`,COM5)已烧 0.1.0-a14、真连网。
|
||||
- **服务器**(`rack/README.md`):FastAPI + SQLite + Vue3,systemd `phonerail-rack` 只绑 127.0.0.1:7764,经门户 key `rack` 访问;
|
||||
板 B 已自动出现、遥测入库、事件 27 条补齐。部署 `bash rack/deploy/deploy.sh`(服务器上 Python 3.8 跑完 25 例才换上去)。
|
||||
- **broker**:复用 evse 的 mosquitto 8883 TLS;顺手修了 evse 8883 缺 cafile ⇒ 公网明文的漏洞(备份 `.bak-20260927-*`)。
|
||||
- 还没做:固件 A15 / A16;自动恢复阈值待刀片 + 手机上架后实测;固件分区只剩 12%(C2 前重排)。
|
||||
|
||||
## 固定执行约束(2026-09-10 用户明确要求)
|
||||
|
||||
- **以后所有 PCB 布线运算都用 `10.5.5.13` 服务器跑。** 本机仅做快照、结果校验、
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
backend/.pytest_cache/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# PhoneRail 机架管理(服务器前后端)
|
||||
|
||||
需求文档 D7 的实现:板子经 WiFi → MQTT(TLS)连到 home 服务器,这里把遥测存下来、画出来,
|
||||
让人在网页上操作每一台手机,并按电流判据做自动崩溃恢复(先软后硬)。
|
||||
|
||||
```
|
||||
板子 ──mqtts 8883──▶ mosquitto(evse 共用)──127.0.0.1:1884──▶ 后端 prrack(127.0.0.1:7764)
|
||||
│ SQLite(WAL)
|
||||
浏览器 ──▶ rack.home.example:7755(统一门户,auth_request)──────────┘ 同进程托管前端 dist
|
||||
```
|
||||
|
||||
| 目录 | 内容 |
|
||||
|---|---|
|
||||
| `backend/prrack/` | `protocol.py` 板侧协议镜像(命令白名单 / 事件表 / seq)· `bus.py` MQTT 抽象(paho / 进程内假 broker)· `hub.py` 核心 · `recovery.py` 自动恢复 · `db.py` 存储 · `api.py` HTTP+WS · `simboard.py` 模拟板子 · `main.py` 入口 |
|
||||
| `backend/tests/` | 25 例(pytest),本机与服务器(Python 3.8)都跑 |
|
||||
| `frontend/` | Vue3 + Vite + Element Plus + ECharts(按需引入) |
|
||||
| `deploy/` | `deploy.sh` 一键部署 · systemd unit · 每日备份 |
|
||||
|
||||
## 规矩(都来自板侧协议 §10.8,改代码前先看)
|
||||
|
||||
1. **重传用同一个 seq**(`Hub.call`)。换 seq 重发 = 「按 10 秒电源键」可能执行两遍。测试 `test_retransmit_reuses_the_same_seq…` 钉着。
|
||||
2. **失联不动作**:板子离线 / 遥测中断只标状态;自动恢复只在「开了 + 在线 + 在位 + 有电 + 执行器空闲」时判定。
|
||||
3. **时间用服务器收到的时刻**(板子没有墙钟),事件表另存板子的 `t_ms` 作参考。
|
||||
4. **只绑 127.0.0.1**:`X-Auth-User`(门户覆盖式注入)按 L2 信任,只用于审计「谁按的」;`main.py` 拒绝非回环绑定。
|
||||
5. **命令白名单在 `protocol.build_command`**:`wifi` / `mqtt`(只收 USB)与 `events`(服务器自己同步)不给界面。板子的校验仍是唯一权威。
|
||||
|
||||
## 本机开发
|
||||
|
||||
```bash
|
||||
cd backend && python -m prrack.main --sim 2 --db dev.db --static ../frontend/dist # 两块模拟板,第 1 块第 2 路「卡死」
|
||||
cd frontend && pnpm dev # http://localhost:5173,/api 反代到 7764
|
||||
cd backend && python -m pytest -q tests
|
||||
```
|
||||
|
||||
## 部署 / 运维
|
||||
|
||||
```bash
|
||||
bash rack/deploy/deploy.sh # 构建 → 传 → 服务器上 Python 3.8 跑测试(不过不换)→ 重启
|
||||
```
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 服务 | systemd `phonerail-rack`,`/root/phonerail-rack/{backend,frontend/dist,venv,data}` |
|
||||
| 日志 | `/var/log/phonerail-rack.log` |
|
||||
| 库 | `/root/phonerail-rack/data/rack.db`;每日 03:40 `sqlite3 .backup` 到 `backup/`,留 14 份 |
|
||||
| MQTT | `phonerail-backend` @ 127.0.0.1:1884,口令读 `/root/phonerail/mqtt.env` |
|
||||
| 门户 | 应用 id 16,key `rack`、端口 7764(`phonerail` 已被需求文档站占用);授权 admin + staff,**不给 viewer**(没有只读模式,能断电重启) |
|
||||
| 保留期 | 原始 1 s 遥测 2 天;分钟聚合 90 天;事件 / 审计 / 恢复记录永久 |
|
||||
|
||||
加一块新板:USB 配网(`firmware/tools/prlink.py` 发 `wifi` / `mqtt`)+ 服务器上给它开 MQTT 账号(见 memory 里的 broker 说明),上线后自动出现在总览。
|
||||
|
||||
## 踩过的坑
|
||||
|
||||
- **uvicorn 没装 `websockets` 时不报错**:`/api/ws` 被当成普通 GET 落到 SPA 兜底,回 200 HTML,页面能开但永不刷新。
|
||||
TestClient 自带 WS 实现测不出来 ⇒ `main.py` 启动自检 + `test_a_real_websocket_library_is_installed`(部署时在服务器上跑)。
|
||||
- **服务器 Python 3.8 + 老 pip**:starlette 最高 0.44.0、websockets 13.1;新版本在清华镜像上直接「找不到」。
|
||||
- 本机有 HTTP 代理(`HTTP_PROXY=127.0.0.1:7890`),经它访问不存在的公网端口会回 **502 而不是连不上**;验「端口没暴露」要加 `--noproxy '*'`。
|
||||
- 门户的 `/admin/grants` 是**整张矩阵覆盖写**:脚本化加授权要先读出现有全部授权再加新的。改门户前先备份 `portal.db`。
|
||||
- 门户进程把应用表缓存在内存,**直接改库它看不见**(没有重载信号,只能重启);走它自己的 `/admin/apps` 接口会自动 `reloadAcl()`。
|
||||
|
||||
## 已知限制 / 下一步
|
||||
|
||||
- **自动恢复阈值没有实测依据**(平线 5 mA / 过低 30 mA / 窗口 5 分钟)。插上刀片和手机后先看几天曲线再定,默认逐台关闭。
|
||||
- **adb 探活**:接口 `recovery.Prober` 已留,服务器目前够不着手机(机架在 192.168.8.x 的 NAT 后面)。
|
||||
- 前端 Element Plus 是整包引入(gzip ≈335 KB),手机首屏偏慢;改成按需引入可以再砍一半。
|
||||
- 没有细分权限:门户授权了这个应用的人都能操作;审计记着是谁。
|
||||
@@ -0,0 +1,194 @@
|
||||
"""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 .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) -> 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}/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())
|
||||
|
||||
# ---- 事件 / 审计 / 恢复 ----------------------------------------------------
|
||||
@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"])
|
||||
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
|
||||
@@ -0,0 +1,137 @@
|
||||
"""MQTT 总线的薄抽象:真实的 paho,和测试 / 本机开发用的进程内假 broker。
|
||||
|
||||
Hub 只认三件事:`publish(topic, payload, qos, retain)`、`subscribe(pattern)`、
|
||||
收到消息时回调 `on_message(topic, payload_bytes)`。回调**可能在任意线程**里被调
|
||||
(paho 的网络线程),Hub 自己负责把它搬回 asyncio 循环。
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
from typing import Callable, Dict, List, Optional, Tuple
|
||||
|
||||
log = logging.getLogger("prrack.bus")
|
||||
|
||||
OnMessage = Callable[[str, bytes], None]
|
||||
|
||||
|
||||
def topic_matches(pattern: str, topic: str) -> bool:
|
||||
"""MQTT 通配:`+` 一级,`#` 余下全部。"""
|
||||
p, t = pattern.split("/"), topic.split("/")
|
||||
for i, seg in enumerate(p):
|
||||
if seg == "#":
|
||||
return True
|
||||
if i >= len(t) or (seg != "+" and seg != t[i]):
|
||||
return False
|
||||
return len(p) == len(t)
|
||||
|
||||
|
||||
class Bus:
|
||||
on_message: Optional[OnMessage] = None
|
||||
on_connect: Optional[Callable[[bool], None]] = None
|
||||
|
||||
def start(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def stop(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def publish(self, topic: str, payload: str, qos: int = 1, retain: bool = False):
|
||||
raise NotImplementedError
|
||||
|
||||
def subscribe(self, pattern: str, qos: int = 1):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class PahoBus(Bus):
|
||||
def __init__(self, host: str, port: int, user: str, password: str, client_id: str):
|
||||
import paho.mqtt.client as mqtt # 只在真连 broker 时才需要
|
||||
|
||||
self._subs: List[Tuple[str, int]] = []
|
||||
self._c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id,
|
||||
clean_session=True)
|
||||
self._c.username_pw_set(user, password)
|
||||
self._c.reconnect_delay_set(1, 30)
|
||||
self._c.on_connect = self._on_connect
|
||||
self._c.on_disconnect = self._on_disconnect
|
||||
self._c.on_message = lambda c, u, m: self.on_message and self.on_message(m.topic, m.payload)
|
||||
self._host, self._port = host, port
|
||||
|
||||
def _on_connect(self, c, userdata, flags, reason, props=None):
|
||||
ok = not reason.is_failure
|
||||
log.info("MQTT %s(%s)", "已连上" if ok else "连接被拒", reason)
|
||||
if ok:
|
||||
# clean_session:每次重连都要重新订阅,否则断一次线就再也收不到东西
|
||||
for pat, qos in self._subs:
|
||||
c.subscribe(pat, qos)
|
||||
if self.on_connect:
|
||||
self.on_connect(ok)
|
||||
|
||||
def _on_disconnect(self, c, userdata, flags, reason, props=None):
|
||||
log.warning("MQTT 断开(%s),paho 会自动重连", reason)
|
||||
if self.on_connect:
|
||||
self.on_connect(False)
|
||||
|
||||
def start(self):
|
||||
self._c.connect_async(self._host, self._port, keepalive=30)
|
||||
self._c.loop_start()
|
||||
|
||||
def stop(self):
|
||||
self._c.loop_stop()
|
||||
self._c.disconnect()
|
||||
|
||||
def publish(self, topic: str, payload: str, qos: int = 1, retain: bool = False):
|
||||
self._c.publish(topic, payload, qos=qos, retain=retain)
|
||||
|
||||
def subscribe(self, pattern: str, qos: int = 1):
|
||||
self._subs.append((pattern, qos))
|
||||
self._c.subscribe(pattern, qos)
|
||||
|
||||
|
||||
class MemBroker:
|
||||
"""进程内的假 broker:多个 MemBus 挂在同一个上面互相收发,支持 retained。
|
||||
投递是**同步**的(publish 返回前订阅者已经收到),测试因此是确定的。"""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.RLock()
|
||||
self.clients: List["MemBus"] = []
|
||||
self.retained: Dict[str, str] = {}
|
||||
self.log: List[Tuple[str, str]] = []
|
||||
|
||||
def deliver(self, topic: str, payload: str, retain: bool):
|
||||
with self._lock:
|
||||
self.log.append((topic, payload))
|
||||
if retain:
|
||||
self.retained[topic] = payload
|
||||
targets = [c for c in self.clients if c.wants(topic)]
|
||||
for c in targets:
|
||||
if c.on_message:
|
||||
c.on_message(topic, payload.encode())
|
||||
|
||||
|
||||
class MemBus(Bus):
|
||||
def __init__(self, broker: MemBroker):
|
||||
self.broker = broker
|
||||
self.subs: List[str] = []
|
||||
self.up = False
|
||||
|
||||
def wants(self, topic: str) -> bool:
|
||||
return self.up and any(topic_matches(p, topic) for p in self.subs)
|
||||
|
||||
def start(self):
|
||||
self.up = True
|
||||
self.broker.clients.append(self)
|
||||
if self.on_connect:
|
||||
self.on_connect(True)
|
||||
|
||||
def stop(self):
|
||||
self.up = False
|
||||
if self in self.broker.clients:
|
||||
self.broker.clients.remove(self)
|
||||
|
||||
def publish(self, topic: str, payload: str, qos: int = 1, retain: bool = False):
|
||||
self.broker.deliver(topic, payload, retain)
|
||||
|
||||
def subscribe(self, pattern: str, qos: int = 1):
|
||||
self.subs.append(pattern)
|
||||
for t, p in list(self.broker.retained.items()):
|
||||
if topic_matches(pattern, t) and self.on_message:
|
||||
self.on_message(t, p.encode())
|
||||
@@ -0,0 +1,181 @@
|
||||
"""SQLite 存储(WAL)。一个文件,备份就是拷它(同门户 / evse 的做法)。
|
||||
|
||||
数据量估算:一块板 5 路 × 1 条/s = 43 万行/天。原始 1 s 数据只留 `RAW_KEEP_S`(2 天),
|
||||
每分钟降采样一行(均值 / 最小 / 最大)留 `MIN_KEEP_S`(90 天)——
|
||||
电流曲线的用途是「这台手机前天夜里是不是卡死过」,一分钟的 min/max 足够看出来。
|
||||
事件、审计、恢复记录**永久保留**(量小,而且是排查的唯一线索)。
|
||||
|
||||
⚠️ 时间戳一律是**服务器收到的时刻**(毫秒)。板子没有墙钟(需求 §10.12:
|
||||
上电从 0 计),它自己的 `t_ms` 只存在事件表的 `t_ms` 列里,作参考。
|
||||
"""
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Iterable, List, Optional, Sequence
|
||||
|
||||
RAW_KEEP_S = 2 * 86400
|
||||
MIN_KEEP_S = 90 * 86400
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
id TEXT PRIMARY KEY, -- STA MAC 12 位小写十六进制
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
fw TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
online INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen INTEGER NOT NULL DEFAULT 0,
|
||||
ev_cursor INTEGER NOT NULL DEFAULT 0, -- 下一条要拉的事件序号
|
||||
created INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS phones (
|
||||
board_id TEXT NOT NULL,
|
||||
rail INTEGER NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
adb_addr TEXT NOT NULL DEFAULT '', -- 预留:服务器够得着手机时填 host:port
|
||||
auto_recover INTEGER NOT NULL DEFAULT 0, -- 默认关:逐台打开
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (board_id, rail)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tele (
|
||||
ts INTEGER NOT NULL, board_id TEXT NOT NULL, rail INTEGER NOT NULL,
|
||||
mv INTEGER, ua INTEGER, en INTEGER, vb INTEGER, pr INTEGER, pg INTEGER, flt INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS tele_idx ON tele (board_id, rail, ts);
|
||||
CREATE TABLE IF NOT EXISTS tele_min (
|
||||
ts INTEGER NOT NULL, board_id TEXT NOT NULL, rail INTEGER NOT NULL,
|
||||
mv_avg INTEGER, ua_avg INTEGER, ua_min INTEGER, ua_max INTEGER, n INTEGER,
|
||||
PRIMARY KEY (board_id, rail, ts)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS board_tele (
|
||||
ts INTEGER NOT NULL, board_id TEXT NOT NULL,
|
||||
in_mv INTEGER, in_ua INTEGER, temp0 INTEGER, temp1 INTEGER, fan_duty INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS board_tele_idx ON board_tele (board_id, ts);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
board_id TEXT NOT NULL, seq INTEGER NOT NULL,
|
||||
t_ms INTEGER, rail INTEGER, type INTEGER, arg INTEGER, mv INTEGER, ua INTEGER,
|
||||
received INTEGER NOT NULL,
|
||||
PRIMARY KEY (board_id, seq)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS events_recv_idx ON events (received);
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL, user TEXT NOT NULL, board_id TEXT NOT NULL, rail INTEGER,
|
||||
request TEXT NOT NULL, response TEXT NOT NULL, ok INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS recovery_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL, board_id TEXT NOT NULL, rail INTEGER NOT NULL,
|
||||
state TEXT NOT NULL, reason TEXT NOT NULL, detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY, value TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
class Db:
|
||||
"""一个连接 + 一把锁。后端是单进程(uvicorn 单 worker),写入量是每秒几十行,
|
||||
不值得上连接池;锁是为了 paho 线程之外偶尔还有线程池里的查询。"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self._lock = threading.Lock()
|
||||
self.conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||
self.conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self.conn.executescript(SCHEMA)
|
||||
|
||||
def close(self):
|
||||
with self._lock:
|
||||
self.conn.close()
|
||||
|
||||
# ---- 通用 ----------------------------------------------------------
|
||||
def q(self, sql: str, args: Sequence = ()) -> List[sqlite3.Row]:
|
||||
with self._lock:
|
||||
return self.conn.execute(sql, args).fetchall()
|
||||
|
||||
def x(self, sql: str, args: Sequence = ()) -> int:
|
||||
with self._lock:
|
||||
cur = self.conn.execute(sql, args)
|
||||
return cur.lastrowid
|
||||
|
||||
def many(self, sql: str, rows: Iterable[Sequence]):
|
||||
with self._lock:
|
||||
self.conn.execute("BEGIN")
|
||||
try:
|
||||
self.conn.executemany(sql, rows)
|
||||
self.conn.execute("COMMIT")
|
||||
except Exception:
|
||||
self.conn.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
# ---- 板子 ----------------------------------------------------------
|
||||
def ensure_board(self, board: str):
|
||||
self.x("INSERT OR IGNORE INTO boards (id, created) VALUES (?, ?)", (board, now_ms()))
|
||||
self.many("INSERT OR IGNORE INTO phones (board_id, rail) VALUES (?, ?)",
|
||||
[(board, r) for r in range(1, 6)])
|
||||
|
||||
def set_board_state(self, board: str, online: bool, fw: str = None, ip: str = None):
|
||||
self.ensure_board(board)
|
||||
self.x("UPDATE boards SET online=?, fw=COALESCE(?, fw), ip=COALESCE(?, ip), last_seen=? "
|
||||
"WHERE id=?", (1 if online else 0, fw, ip, now_ms(), board))
|
||||
|
||||
def setting(self, key: str, default: str) -> str:
|
||||
rows = self.q("SELECT value FROM settings WHERE key=?", (key,))
|
||||
return rows[0]["value"] if rows else default
|
||||
|
||||
def set_setting(self, key: str, value: str):
|
||||
self.x("INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
|
||||
|
||||
# ---- 遥测 ----------------------------------------------------------
|
||||
def downsample(self, upto_ms: Optional[int] = None) -> int:
|
||||
"""把还没聚合的整分钟原始数据聚成 tele_min。幂等(主键冲突就跳过)。
|
||||
只聚到 `upto_ms` 之前的**整分钟**:正在进行的这一分钟数据还不全。"""
|
||||
upto = (upto_ms if upto_ms is not None else now_ms()) // 60000 * 60000
|
||||
with self._lock:
|
||||
row = self.conn.execute("SELECT MAX(ts) FROM tele_min").fetchone()
|
||||
start = (row[0] + 60000) if row[0] is not None else 0
|
||||
cur = self.conn.execute(
|
||||
"INSERT OR IGNORE INTO tele_min (ts, board_id, rail, mv_avg, ua_avg, ua_min, ua_max, n) "
|
||||
"SELECT ts/60000*60000 AS m, board_id, rail, CAST(AVG(mv) AS INTEGER), "
|
||||
"CAST(AVG(ua) AS INTEGER), MIN(ua), MAX(ua), COUNT(*) FROM tele "
|
||||
"WHERE ts >= ? AND ts < ? AND ua IS NOT NULL GROUP BY m, board_id, rail",
|
||||
(start, upto))
|
||||
return cur.rowcount
|
||||
|
||||
def prune(self, now: Optional[int] = None):
|
||||
t = now if now is not None else now_ms()
|
||||
with self._lock:
|
||||
self.conn.execute("DELETE FROM tele WHERE ts < ?", (t - RAW_KEEP_S * 1000,))
|
||||
self.conn.execute("DELETE FROM board_tele WHERE ts < ?", (t - RAW_KEEP_S * 1000,))
|
||||
self.conn.execute("DELETE FROM tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
|
||||
|
||||
def history(self, board: str, rail: int, t0: int, t1: int, max_points: int = 1500) -> dict:
|
||||
"""[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据,
|
||||
否则回分钟聚合(带 min/max,界面画成带状)。点太多时按时间桶再抽一次。"""
|
||||
span = max(1, t1 - t0)
|
||||
raw_ok = t0 >= now_ms() - RAW_KEEP_S * 1000 and span <= 6 * 3600 * 1000
|
||||
if raw_ok:
|
||||
bucket = max(1000, span // max_points)
|
||||
rows = self.q(
|
||||
"SELECT ts/?*? AS t, CAST(AVG(mv) AS INTEGER) mv, CAST(AVG(ua) AS INTEGER) ua, "
|
||||
"MIN(ua) ua_min, MAX(ua) ua_max FROM tele WHERE board_id=? AND rail=? "
|
||||
"AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, rail, t0, t1))
|
||||
res = "raw"
|
||||
else:
|
||||
bucket = max(60000, span // max_points // 60000 * 60000)
|
||||
rows = self.q(
|
||||
"SELECT ts/?*? AS t, CAST(AVG(mv_avg) AS INTEGER) mv, CAST(AVG(ua_avg) AS INTEGER) ua, "
|
||||
"MIN(ua_min) ua_min, MAX(ua_max) ua_max FROM tele_min WHERE board_id=? AND rail=? "
|
||||
"AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, rail, t0, t1))
|
||||
res = "minute"
|
||||
return {"resolution": res, "bucket_ms": bucket,
|
||||
"points": [[r["t"], r["mv"], r["ua"], r["ua_min"], r["ua_max"]] for r in rows]}
|
||||
@@ -0,0 +1,262 @@
|
||||
"""服务器侧的核心:MQTT ↔ 状态 ↔ 数据库 ↔ 网页推送。
|
||||
|
||||
四条规矩(都来自板侧协议,需求 §10.8):
|
||||
1. **重传用同一个 seq。** 超时之后原样再发,板子的判重兜住;换 seq 重发 =
|
||||
「按 10 秒电源键」可能执行两遍。`call()` 是唯一的下发入口。
|
||||
2. **失联不动作。** 板子掉线、遥测中断,服务器只把它标成离线,没有任何自动动作会因此触发
|
||||
(自动恢复在板子离线时直接停摆,recovery.py)。
|
||||
3. **时间一律用服务器收到的时刻。** 板子没有墙钟。
|
||||
4. **事件按序号增量拉**(遥测里的 `ev_next` 比游标大就拉),一次 8 条、拉到追平为止;
|
||||
板子序号比游标还小 = 板子 flash 被清过,游标归零重拉。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Callable, Dict, List, Optional, Set
|
||||
|
||||
from . import protocol
|
||||
from .bus import Bus
|
||||
from .db import Db, now_ms
|
||||
|
||||
log = logging.getLogger("prrack.hub")
|
||||
|
||||
STALE_MS = 10000 # 这么久没遥测 = 通信中断(遥测默认 1 s 一条)
|
||||
FLUSH_S = 2.0 # 遥测攒批写库
|
||||
MAINT_S = 60.0 # 降采样 + 清理
|
||||
|
||||
|
||||
class CmdTimeout(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Hub:
|
||||
def __init__(self, db: Db, bus: Bus, attempt_timeout: float = 1.5, attempts: int = 3):
|
||||
self.db = db
|
||||
self.bus = bus
|
||||
self.attempt_timeout = attempt_timeout
|
||||
self.attempts = attempts
|
||||
self.seq = protocol.SeqGen()
|
||||
self.loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self.latest: Dict[str, dict] = {} # board → {status, last_tele, online, fw, ip}
|
||||
self.mqtt_up = False
|
||||
self._pending: Dict[tuple, asyncio.Future] = {}
|
||||
self._rows: List[tuple] = []
|
||||
self._brows: List[tuple] = []
|
||||
self._syncing: Set[str] = set()
|
||||
self._clients: Set[asyncio.Queue] = set()
|
||||
self._tasks: List[asyncio.Task] = []
|
||||
self.tele_observers: List[Callable[[str, int, dict], None]] = []
|
||||
|
||||
# ---- 生命周期 ------------------------------------------------------------
|
||||
async def start(self):
|
||||
self.loop = asyncio.get_running_loop()
|
||||
for r in self.db.q("SELECT id, fw, ip, online FROM boards"):
|
||||
self.latest[r["id"]] = {"status": None, "last_tele": 0, "online": False,
|
||||
"fw": r["fw"], "ip": r["ip"]}
|
||||
self.bus.on_message = self._from_bus
|
||||
self.bus.on_connect = lambda ok: self.loop.call_soon_threadsafe(self._set_mqtt, ok)
|
||||
self.bus.start()
|
||||
for kind in ("rsp", "tele", "state"):
|
||||
self.bus.subscribe("%s/+/%s" % (protocol.TOPIC_ROOT, kind), 0 if kind == "tele" else 1)
|
||||
self._tasks = [asyncio.ensure_future(self._flusher()), asyncio.ensure_future(self._maint())]
|
||||
|
||||
async def stop(self):
|
||||
for t in self._tasks:
|
||||
t.cancel()
|
||||
self.flush()
|
||||
self.bus.stop()
|
||||
|
||||
def _set_mqtt(self, ok: bool):
|
||||
self.mqtt_up = ok
|
||||
self.broadcast({"t": "mqtt", "up": ok})
|
||||
|
||||
# ---- 收:paho 线程 → asyncio ----------------------------------------------
|
||||
def _from_bus(self, t: str, payload: bytes):
|
||||
if self.loop is None:
|
||||
return
|
||||
self.loop.call_soon_threadsafe(self._handle, t, payload)
|
||||
|
||||
def _handle(self, t: str, payload: bytes):
|
||||
parsed = protocol.parse_topic(t)
|
||||
if parsed is None:
|
||||
return
|
||||
board, kind = parsed
|
||||
try:
|
||||
msg = json.loads(payload)
|
||||
except ValueError:
|
||||
log.warning("%s 的 %s 不是 JSON:%r", board, kind, payload[:80])
|
||||
return
|
||||
if kind == "rsp":
|
||||
fut = self._pending.get((board, msg.get("seq")))
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_result(msg)
|
||||
elif kind == "tele":
|
||||
self._on_tele(board, msg)
|
||||
elif kind == "state":
|
||||
self._on_state(board, msg)
|
||||
|
||||
def _board(self, board: str) -> dict:
|
||||
b = self.latest.get(board)
|
||||
if b is None:
|
||||
self.db.ensure_board(board)
|
||||
b = self.latest[board] = {"status": None, "last_tele": 0, "online": False, "fw": "", "ip": ""}
|
||||
return b
|
||||
|
||||
def _on_state(self, board: str, msg: dict):
|
||||
b = self._board(board)
|
||||
b["online"] = bool(msg.get("online"))
|
||||
if b["online"]:
|
||||
b["fw"], b["ip"] = msg.get("fw", b["fw"]), msg.get("ip", b["ip"])
|
||||
self.db.set_board_state(board, b["online"], msg.get("fw"), msg.get("ip"))
|
||||
self.broadcast({"t": "board", "board": self.snapshot(board)})
|
||||
|
||||
def _on_tele(self, board: str, st: dict):
|
||||
ts = now_ms()
|
||||
b = self._board(board)
|
||||
b["status"], b["last_tele"] = st, ts
|
||||
if not b["online"]: # 遥测来了但 retained 的 state 还没到(或丢了):以遥测为准
|
||||
b["online"] = True
|
||||
self.db.set_board_state(board, True, st.get("fw"))
|
||||
for r in st.get("rails", []):
|
||||
self._rows.append((ts, board, r.get("r"), r.get("mv"), r.get("ua"), r.get("en"),
|
||||
r.get("vb"), r.get("pr"), r.get("pg"), r.get("flt")))
|
||||
temp = st.get("temp") or [None, None]
|
||||
self._brows.append((ts, board, (st.get("in") or {}).get("mv"), (st.get("in") or {}).get("ua"),
|
||||
temp[0], temp[1] if len(temp) > 1 else None, (st.get("fan") or {}).get("duty")))
|
||||
for fn in self.tele_observers:
|
||||
try:
|
||||
fn(board, ts, st)
|
||||
except Exception: # 观察者的 bug 不能拖垮遥测
|
||||
log.exception("遥测观察者出错")
|
||||
self.broadcast({"t": "tele", "board": board, "ts": ts, "status": st})
|
||||
ev_next = st.get("ev_next")
|
||||
if isinstance(ev_next, int) and board not in self._syncing:
|
||||
cur = self.db.q("SELECT ev_cursor FROM boards WHERE id=?", (board,))[0]["ev_cursor"]
|
||||
if ev_next != cur:
|
||||
self._syncing.add(board)
|
||||
asyncio.ensure_future(self._sync_events(board, ev_next < cur))
|
||||
|
||||
# ---- 发 ----------------------------------------------------------------
|
||||
async def call(self, board: str, req: dict, user: str, audit: bool = True) -> dict:
|
||||
"""发一条命令并等回应。`req` 已经过 protocol.build_command 校验(或是内部命令)。
|
||||
超时按同一个 seq 重发 `attempts` 次;全部超时抛 CmdTimeout(审计里照样记一条)。"""
|
||||
seq = self.seq.next()
|
||||
msg = dict(req, seq=seq)
|
||||
payload = json.dumps(msg, separators=(",", ":"))
|
||||
fut = self.loop.create_future()
|
||||
self._pending[(board, seq)] = fut
|
||||
rsp = None
|
||||
try:
|
||||
for _ in range(self.attempts):
|
||||
self.bus.publish(protocol.topic(board, "cmd"), payload, qos=1)
|
||||
try:
|
||||
rsp = await asyncio.wait_for(asyncio.shield(fut), self.attempt_timeout)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
finally:
|
||||
self._pending.pop((board, seq), None)
|
||||
if audit and protocol.is_mutating(req.get("cmd", "")):
|
||||
self._audit(user, board, msg, rsp)
|
||||
if rsp is None:
|
||||
raise CmdTimeout("板子 %s 没有回应(seq %d,重发 %d 次)" % (board, seq, self.attempts))
|
||||
return rsp
|
||||
|
||||
def _audit(self, user: str, board: str, req: dict, rsp: Optional[dict]):
|
||||
ok = bool(rsp and rsp.get("ok"))
|
||||
body = rsp if rsp is not None else {"ok": False, "err": "timeout"}
|
||||
rid = self.db.x("INSERT INTO audit (ts, user, board_id, rail, request, response, ok) "
|
||||
"VALUES (?,?,?,?,?,?,?)", (now_ms(), user, board, req.get("rail"),
|
||||
json.dumps(req), json.dumps(body), 1 if ok else 0))
|
||||
self.broadcast({"t": "audit", "row": {"id": rid, "ts": now_ms(), "user": user, "board_id": board,
|
||||
"rail": req.get("rail"), "request": req,
|
||||
"response": body, "ok": ok}})
|
||||
|
||||
async def _sync_events(self, board: str, reset: bool):
|
||||
try:
|
||||
if reset:
|
||||
log.warning("板子 %s 的事件序号倒退了(flash 被清过?):游标归零", board)
|
||||
self.db.x("UPDATE boards SET ev_cursor=0 WHERE id=?", (board,))
|
||||
for _ in range(50): # 一次最多追 400 条,剩下的下一条遥测再追
|
||||
cur = self.db.q("SELECT ev_cursor FROM boards WHERE id=?", (board,))[0]["ev_cursor"]
|
||||
rsp = await self.call(board, {"cmd": "events", "from": cur}, "system", audit=False)
|
||||
ev = rsp.get("ev") or []
|
||||
if not rsp.get("ok"):
|
||||
return
|
||||
now = now_ms()
|
||||
self.db.many("INSERT OR IGNORE INTO events (board_id, seq, t_ms, rail, type, arg, mv, ua, "
|
||||
"received) VALUES (?,?,?,?,?,?,?,?,?)", [(board, *e[:7], now) for e in ev])
|
||||
nxt = max([cur] + [e[0] + 1 for e in ev])
|
||||
if not ev and rsp.get("oldest", 0) > cur:
|
||||
nxt = rsp["oldest"] # 中间那段被板子的环形缓冲盖掉了
|
||||
self.db.x("UPDATE boards SET ev_cursor=? WHERE id=?", (nxt, board))
|
||||
if ev:
|
||||
self.broadcast({"t": "events", "board": board, "ev": ev, "received": now})
|
||||
if nxt >= rsp.get("next", nxt) or not ev:
|
||||
return
|
||||
except CmdTimeout:
|
||||
log.warning("拉 %s 的事件超时,等下一条遥测再试", board)
|
||||
finally:
|
||||
self._syncing.discard(board)
|
||||
|
||||
# ---- 查 ----------------------------------------------------------------
|
||||
def snapshot(self, board: str) -> dict:
|
||||
b = self._board(board)
|
||||
meta = self.db.q("SELECT name, location FROM boards WHERE id=?", (board,))
|
||||
phones = self.db.q("SELECT rail, label, adb_addr, auto_recover, notes FROM phones "
|
||||
"WHERE board_id=? ORDER BY rail", (board,))
|
||||
stale = b["last_tele"] == 0 or now_ms() - b["last_tele"] > STALE_MS
|
||||
return {"id": board, "name": meta[0]["name"] if meta else "", "location": meta[0]["location"] if meta else "",
|
||||
"online": b["online"], "stale": stale, "fw": b["fw"], "ip": b["ip"],
|
||||
"last_tele": b["last_tele"], "status": b["status"],
|
||||
"phones": [dict(p) for p in phones]}
|
||||
|
||||
def boards(self) -> List[dict]:
|
||||
return [self.snapshot(b) for b in sorted(self.latest)]
|
||||
|
||||
# ---- 推送 ----------------------------------------------------------------
|
||||
def subscribe_client(self) -> asyncio.Queue:
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=200)
|
||||
self._clients.add(q)
|
||||
return q
|
||||
|
||||
def unsubscribe_client(self, q: asyncio.Queue):
|
||||
self._clients.discard(q)
|
||||
|
||||
def broadcast(self, msg: dict):
|
||||
for q in list(self._clients):
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except asyncio.QueueFull:
|
||||
# 慢客户端:丢掉它,让它重连后重新拉全量 —— 别让一个卡住的浏览器撑爆内存。
|
||||
# WS 协程发现自己不在集合里了就断开(is_subscribed)。
|
||||
self._clients.discard(q)
|
||||
|
||||
def is_subscribed(self, q: asyncio.Queue) -> bool:
|
||||
return q in self._clients
|
||||
|
||||
# ---- 后台 ----------------------------------------------------------------
|
||||
def flush(self):
|
||||
rows, brows = self._rows, self._brows
|
||||
self._rows, self._brows = [], []
|
||||
if rows:
|
||||
self.db.many("INSERT INTO tele VALUES (?,?,?,?,?,?,?,?,?,?)", rows)
|
||||
if brows:
|
||||
self.db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)", brows)
|
||||
|
||||
async def _flusher(self):
|
||||
while True:
|
||||
await asyncio.sleep(FLUSH_S)
|
||||
try:
|
||||
self.flush()
|
||||
except Exception:
|
||||
log.exception("遥测写库失败")
|
||||
|
||||
async def _maint(self):
|
||||
while True:
|
||||
await asyncio.sleep(MAINT_S)
|
||||
try:
|
||||
self.db.downsample()
|
||||
self.db.prune()
|
||||
except Exception:
|
||||
log.exception("降采样 / 清理失败")
|
||||
@@ -0,0 +1,111 @@
|
||||
"""入口。
|
||||
|
||||
生产(服务器上,systemd 起):
|
||||
python -m prrack.main --db /root/phonerail-rack/data/rack.db \\
|
||||
--mqtt-env /root/phonerail/mqtt.env --static /root/phonerail-rack/frontend/dist
|
||||
本机开发(不连 broker,起 N 块模拟板子,每秒一条遥测):
|
||||
python -m prrack.main --sim 2 --db dev.db --static ../frontend/dist
|
||||
|
||||
⛔ 只许绑回环地址:api.py 按 L2 信任门户注入的 X-Auth-User,这个信任只在「外面够不着本进程」时成立。
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
|
||||
from .api import create_app
|
||||
from .bus import MemBroker, MemBus, PahoBus
|
||||
from .db import Db, now_ms
|
||||
from .hub import Hub
|
||||
from .recovery import RecoveryManager
|
||||
from .simboard import HUNG, SimBoard
|
||||
|
||||
log = logging.getLogger("prrack")
|
||||
|
||||
|
||||
def read_env(path: str) -> dict:
|
||||
out = {}
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
out[k.strip()] = v.strip().strip('"')
|
||||
return out
|
||||
|
||||
|
||||
def build(args):
|
||||
db = Db(args.db)
|
||||
sims = []
|
||||
if args.sim:
|
||||
broker = MemBroker()
|
||||
bus = MemBus(broker)
|
||||
for i in range(args.sim):
|
||||
present = (True, True, True, False, True) if i == 0 else (True,) * 5
|
||||
sb = SimBoard(MemBus(broker), "5157ab%06x" % i, present=present, seed=i + 1)
|
||||
if i == 0:
|
||||
sb.set_phone(2, HUNG) # 留一台「卡死」的,给自动恢复看
|
||||
sims.append(sb)
|
||||
else:
|
||||
env = read_env(args.mqtt_env)
|
||||
bus = PahoBus(args.mqtt_host, args.mqtt_port, env.get("BACKEND_USER", "phonerail-backend"),
|
||||
env["BACKEND_PASS"], "phonerail-rack-%d" % os.getpid())
|
||||
hub = Hub(db, bus)
|
||||
rec = RecoveryManager(hub, now_ms)
|
||||
|
||||
async def up():
|
||||
await hub.start()
|
||||
for sb in sims:
|
||||
sb.start()
|
||||
if sims:
|
||||
async def ticker():
|
||||
while True:
|
||||
for sb in sims:
|
||||
sb.tick(now_ms())
|
||||
await asyncio.sleep(1)
|
||||
asyncio.ensure_future(ticker())
|
||||
|
||||
async def down():
|
||||
await hub.stop()
|
||||
db.close()
|
||||
|
||||
static = args.static if args.static and os.path.isdir(args.static) else None
|
||||
app = create_app(hub, rec, static, startup=[up], shutdown=[down])
|
||||
return app
|
||||
|
||||
|
||||
def ws_library_ok() -> bool:
|
||||
for mod in ("websockets", "wsproto"):
|
||||
try:
|
||||
__import__(mod)
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description="PhoneRail 机架管理后端")
|
||||
ap.add_argument("--db", default="rack.db")
|
||||
ap.add_argument("--static", default=None)
|
||||
ap.add_argument("--bind", default="127.0.0.1")
|
||||
ap.add_argument("--port", type=int, default=7764)
|
||||
ap.add_argument("--mqtt-host", default="127.0.0.1")
|
||||
ap.add_argument("--mqtt-port", type=int, default=1884)
|
||||
ap.add_argument("--mqtt-env", default="/root/phonerail/mqtt.env")
|
||||
ap.add_argument("--sim", type=int, default=0, help="不连 broker,起 N 块模拟板子")
|
||||
args = ap.parse_args(argv)
|
||||
if not ipaddress.ip_address(args.bind).is_loopback:
|
||||
sys.exit("拒绝绑 %s:只许回环地址(见文件头)" % args.bind)
|
||||
if not ws_library_ok():
|
||||
sys.exit("没装 websockets / wsproto:uvicorn 会把 /api/ws 当普通 GET,界面能开但永远不刷新")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
uvicorn.run(build(args), host=args.bind, port=args.port, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""板侧协议在服务器这一侧的镜像(需求文档 §10.8「A12 / A14 定稿」)。
|
||||
|
||||
改这里之前先看固件:
|
||||
· 命令与错误码 —— firmware/main/cmd.h(数字冻结,只许追加)
|
||||
· 事件类型 —— firmware/main/evlog.h 的 log_ev_t(数字就是 flash 格式,冻结)
|
||||
· topic —— firmware/main/mlink.h
|
||||
两边对不上时**不会报错**:事件会被显示成别的类型、命令会被板子回 unknown_cmd。
|
||||
"""
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
TOPIC_ROOT = "phonerail"
|
||||
TOPIC_RE = re.compile(r"^phonerail/([0-9a-f]{12})/(cmd|rsp|tele|state)$")
|
||||
|
||||
RAILS = 5
|
||||
|
||||
# log_ev_t(firmware/main/evlog.h)。⚠️ 数字冻结。
|
||||
EVENT_TYPES: Dict[int, Tuple[str, str]] = {
|
||||
0: ("NONE", "(空槽位)"),
|
||||
1: ("BOOT", "板子上电"),
|
||||
2: ("LOST", "事件丢失"),
|
||||
3: ("OVER_VOLT", "过压关路"),
|
||||
4: ("UNDER_VOLT", "欠压关路"),
|
||||
5: ("REVERSE", "反灌断 VBUS"),
|
||||
6: ("PUSHED_UP", "顶高断 VBUS"),
|
||||
7: ("EFUSE_FAULT", "eFuse 故障"),
|
||||
8: ("PG_CHANGE", "PG 变化"),
|
||||
9: ("I2C_LOST", "I²C 失联"),
|
||||
10: ("THERMAL_DERATE", "过温降载"),
|
||||
11: ("FAN_STALL", "风扇停转"),
|
||||
12: ("REBOOT", "重启序列"),
|
||||
13: ("CMD_REJECT", "命令被拒"),
|
||||
14: ("MUX_LOST", "mux 失联全断"),
|
||||
15: ("SLOT_EMPTY", "刀片不在位"),
|
||||
16: ("PD_LIMIT", "PD 限功率"),
|
||||
}
|
||||
|
||||
# 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。
|
||||
EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14}
|
||||
|
||||
# cmd_id_t(firmware/main/cmd.h),CMD_REJECT 事件的 arg 就是它。
|
||||
CMD_NAMES = {1: "status", 2: "set_voltage", 3: "power_cycle", 4: "key", 5: "edl", 6: "vbus",
|
||||
7: "fan", 8: "events", 9: "power", 10: "wifi", 11: "mqtt", 12: "net"}
|
||||
|
||||
KEYS = ("pwr", "vup", "vdn")
|
||||
KEY_MAX_MS = 15000
|
||||
MV_MIN, MV_MAX = 3524, 4010
|
||||
|
||||
|
||||
class CmdError(ValueError):
|
||||
"""请求在发出去之前就被服务器拒了(参数不对)。"""
|
||||
|
||||
|
||||
def _rail(v) -> int:
|
||||
if not isinstance(v, int) or isinstance(v, bool) or not 1 <= v <= RAILS:
|
||||
raise CmdError("rail 必须是 1~%d" % RAILS)
|
||||
return v
|
||||
|
||||
|
||||
def _bool(v, name: str) -> bool:
|
||||
if not isinstance(v, bool):
|
||||
raise CmdError("%s 必须是 true/false" % name)
|
||||
return v
|
||||
|
||||
|
||||
def build_command(req: dict) -> dict:
|
||||
"""网页 / 自动恢复发来的请求 → 发给板子的命令(不含 seq)。
|
||||
|
||||
**白名单**:只放行管理界面该有的命令。`wifi` / `mqtt` 板子本来就只收 USB,
|
||||
这里再拦一道,免得界面上出现一个永远失败的按钮;`events` 由服务器自己同步。
|
||||
服务器这一侧先校验一遍,是为了让操作者立刻看到中文的错误,而不是等板子回 `range`;
|
||||
**板子的校验仍然是唯一的权威**(SR-13 在固件里)。
|
||||
"""
|
||||
cmd = req.get("cmd")
|
||||
if cmd == "status" or cmd == "net":
|
||||
return {"cmd": cmd}
|
||||
if cmd == "set_voltage":
|
||||
mv = req.get("mv")
|
||||
if not isinstance(mv, int) or isinstance(mv, bool) or not MV_MIN <= mv <= MV_MAX:
|
||||
raise CmdError("电压必须是 %d~%d mV" % (MV_MIN, MV_MAX))
|
||||
return {"cmd": cmd, "rail": _rail(req.get("rail")), "mv": mv}
|
||||
if cmd in ("power_cycle", "edl"):
|
||||
return {"cmd": cmd, "rail": _rail(req.get("rail"))}
|
||||
if cmd == "key":
|
||||
keys = req.get("keys")
|
||||
if not isinstance(keys, str) or not keys or any(k not in KEYS for k in keys.split("+")):
|
||||
raise CmdError("keys 只能是 pwr / vup / vdn 用 + 连起来")
|
||||
ms = req.get("ms")
|
||||
if not isinstance(ms, int) or isinstance(ms, bool) or not 1 <= ms <= KEY_MAX_MS:
|
||||
raise CmdError("按键时长必须是 1~%d ms" % KEY_MAX_MS)
|
||||
return {"cmd": cmd, "rail": _rail(req.get("rail")), "keys": keys, "ms": ms}
|
||||
if cmd in ("vbus", "power"):
|
||||
return {"cmd": cmd, "rail": _rail(req.get("rail")), "on": _bool(req.get("on"), "on")}
|
||||
if cmd == "fan":
|
||||
duty = req.get("duty")
|
||||
if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000:
|
||||
raise CmdError("风扇下限必须是 0~1000‰(0 = 自动)")
|
||||
return {"cmd": cmd, "duty": duty}
|
||||
raise CmdError("不支持的命令:%r" % (cmd,))
|
||||
|
||||
|
||||
def is_mutating(cmd: str) -> bool:
|
||||
return cmd not in ("status", "events", "net")
|
||||
|
||||
|
||||
class SeqGen:
|
||||
"""seq 生成器:以毫秒时间戳为底、进程内严格递增,落在 1~2^32−1。
|
||||
|
||||
板子的判重窗口按「值相等」判(cmd.h),不要求单调,只要求**不撞**:
|
||||
服务重启之后从当前时间重新起步,不会撞上重启前刚用过的那几十个号。
|
||||
"""
|
||||
|
||||
def __init__(self, clock=time.time):
|
||||
self._clock = clock
|
||||
self._last = 0
|
||||
|
||||
def next(self) -> int:
|
||||
now = int(self._clock() * 1000)
|
||||
v = max(self._last + 1, now)
|
||||
self._last = v
|
||||
return (v - 1) % 0xFFFFFFFF + 1
|
||||
|
||||
|
||||
def parse_topic(topic: str) -> Optional[Tuple[str, str]]:
|
||||
m = TOPIC_RE.match(topic)
|
||||
return (m.group(1), m.group(2)) if m else None
|
||||
|
||||
|
||||
def topic(board: str, kind: str) -> str:
|
||||
return "%s/%s/%s" % (TOPIC_ROOT, board, kind)
|
||||
@@ -0,0 +1,221 @@
|
||||
"""自动崩溃恢复(需求 §4.1 两级重启:先软后硬),2026-09-27 定为「先只看电流」。
|
||||
|
||||
服务器够不到手机的 adb(手机在机架那边的 NAT 后面),所以这一版的判据只来自板子遥测:
|
||||
· FLAT:窗口内电流几乎不变(max − min < flat_ua)且不低 —— 卡死的手机常见的样子;
|
||||
· LOW :窗口内电流一直低于 low_ua —— 关机 / 掉到待机,却本该在跑。
|
||||
adb 探活留了接口(`Prober`),手机能从服务器够到(比如经 frp)时再接上。
|
||||
|
||||
⚠️ 这两条阈值**还没有实测依据**(刀片还没插、手机还没上架),默认值是按常识填的。
|
||||
所以自动恢复**默认关闭、逐台打开**,而且:
|
||||
· 只在「这台手机开了自动恢复 + 板子在线 + 刀片在位 + 这一路有电 + 执行器空闲」时才判定,
|
||||
任何一条不满足就把积累的历史清掉 —— 失联不动作(§10.2);
|
||||
· 每小时最多 `max_per_hour` 次,超了就 GAVE_UP 停手、告警,要人来复位 ——
|
||||
一个误判的阈值最坏的后果是「把一台正常的手机每 5 分钟重启一次」,这条闸就是防它的。
|
||||
|
||||
状态机:IDLE ──判定──▶ SOFT(长按电源 10 s)──等 settle──▶ 观察一个窗口
|
||||
▲ │ 仍然判定 │ 没判定
|
||||
└──── 恢复 ◀──────────┼──────────────────────────────┘
|
||||
▼
|
||||
HARD(断电重启)──仍然判定──▶ GAVE_UP(停手,等人)
|
||||
"""
|
||||
import asyncio
|
||||
import collections
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Callable, Deque, Dict, List, Optional, Tuple
|
||||
|
||||
log = logging.getLogger("prrack.recovery")
|
||||
|
||||
IDLE, SOFT, HARD, GAVE_UP = "idle", "soft", "hard", "gave_up"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Params:
|
||||
window_s: int = 300 # 判定窗口
|
||||
flat_ua: int = 5000 # 窗口内电流摆幅小于它 = 平线(5 mA)
|
||||
low_ua: int = 30000 # 窗口内电流一直低于它 = 像关机(30 mA)
|
||||
settle_s: int = 120 # 动作之后先等它重启完,这段不看
|
||||
max_per_hour: int = 3 # 每台每小时最多动作几次
|
||||
soft_ms: int = 10000 # 一级:长按电源键时长(需求 §4.1 ≈10 s)
|
||||
|
||||
@staticmethod
|
||||
def load(text: str) -> "Params":
|
||||
p = Params()
|
||||
try:
|
||||
for k, v in json.loads(text or "{}").items():
|
||||
if hasattr(p, k) and isinstance(v, int) and v > 0:
|
||||
setattr(p, k, v)
|
||||
except ValueError:
|
||||
pass
|
||||
return p
|
||||
|
||||
|
||||
class Detector:
|
||||
"""一台手机的电流窗口。样本 1 条/s;窗口没攒满(覆盖不到 window_s 的 90 %)一律不下结论。"""
|
||||
|
||||
def __init__(self, p: Params):
|
||||
self.p = p
|
||||
self.s: Deque[Tuple[int, int]] = collections.deque()
|
||||
|
||||
def reset(self):
|
||||
self.s.clear()
|
||||
|
||||
def add(self, ts: int, ua: int):
|
||||
if self.s and ts <= self.s[-1][0]:
|
||||
return # 时间倒退 / 重复:丢掉(一条迟到的遥测不能把窗口搅乱)
|
||||
self.s.append((ts, ua))
|
||||
horizon = ts - self.p.window_s * 1000
|
||||
while self.s and self.s[0][0] < horizon:
|
||||
self.s.popleft()
|
||||
|
||||
def full(self, now: int) -> bool:
|
||||
if not self.s:
|
||||
return False
|
||||
covered = self.s[-1][0] - self.s[0][0]
|
||||
return covered >= self.p.window_s * 900 and len(self.s) >= self.p.window_s * 0.8
|
||||
|
||||
def verdict(self, now: int) -> Optional[str]:
|
||||
if not self.full(now):
|
||||
return None
|
||||
vals = [u for _, u in self.s]
|
||||
if max(vals) < self.p.low_ua:
|
||||
return "low"
|
||||
if max(vals) - min(vals) < self.p.flat_ua:
|
||||
return "flat"
|
||||
return None
|
||||
|
||||
|
||||
Action = Tuple[str, dict] # (原因, 要发的命令)
|
||||
|
||||
|
||||
class PhoneRecovery:
|
||||
"""一台手机的状态机。纯逻辑:喂样本、吐动作,时钟由调用方给。"""
|
||||
|
||||
def __init__(self, p: Params):
|
||||
self.p = p
|
||||
self.det = Detector(p)
|
||||
self.state = IDLE
|
||||
self.since = 0 # 进入当前状态的时刻
|
||||
self.reason = ""
|
||||
self.attempts: List[int] = []
|
||||
|
||||
def eligible_reset(self):
|
||||
self.det.reset()
|
||||
|
||||
def manual_reset(self):
|
||||
self.state, self.reason, self.attempts = IDLE, "", []
|
||||
self.det.reset()
|
||||
|
||||
def feed(self, now: int, ua: int, rail: int) -> Tuple[Optional[str], Optional[Action]]:
|
||||
"""返回 (新状态或 None, 动作或 None)。"""
|
||||
if self.state == GAVE_UP:
|
||||
return None, None
|
||||
if self.state in (SOFT, HARD) and now - self.since < self.p.settle_s * 1000:
|
||||
return None, None # 还在重启过程中,这段电流不代表任何东西
|
||||
self.det.add(now, ua)
|
||||
v = self.det.verdict(now)
|
||||
if v is None:
|
||||
if self.state in (SOFT, HARD) and self.det.full(now):
|
||||
self.state, self.reason = IDLE, "recovered"
|
||||
return IDLE, None
|
||||
return None, None
|
||||
self.attempts = [t for t in self.attempts if now - t < 3600 * 1000]
|
||||
if len(self.attempts) >= self.p.max_per_hour or self.state == HARD:
|
||||
self.state, self.reason = GAVE_UP, ("rate_limit" if self.state != HARD else v)
|
||||
return GAVE_UP, None
|
||||
nxt = SOFT if self.state == IDLE else HARD
|
||||
self.state, self.since, self.reason = nxt, now, v
|
||||
self.attempts.append(now)
|
||||
self.det.reset()
|
||||
if nxt == SOFT:
|
||||
return SOFT, (v, {"cmd": "key", "rail": rail, "keys": "pwr", "ms": self.p.soft_ms})
|
||||
return HARD, (v, {"cmd": "power_cycle", "rail": rail})
|
||||
|
||||
|
||||
class Prober:
|
||||
"""adb 探活的接口(预留)。返回 True 活着 / False 死了 / None 不知道(没配、够不着)。"""
|
||||
|
||||
async def probe(self, addr: str) -> Optional[bool]:
|
||||
return None
|
||||
|
||||
|
||||
class RecoveryManager:
|
||||
"""把状态机接到 Hub 上:订阅遥测,判定,经 hub.call 下发动作,记录并推送。"""
|
||||
|
||||
def __init__(self, hub, clock: Callable[[], int]):
|
||||
self.hub = hub
|
||||
self.db = hub.db
|
||||
self.clock = clock
|
||||
self.params = Params.load(self.db.setting("recovery", "{}"))
|
||||
self.phones: Dict[Tuple[str, int], PhoneRecovery] = {}
|
||||
self.enabled: Dict[Tuple[str, int], bool] = {}
|
||||
self.reload()
|
||||
hub.tele_observers.append(self.on_tele)
|
||||
|
||||
def reload(self):
|
||||
for r in self.db.q("SELECT board_id, rail, auto_recover FROM phones"):
|
||||
key = (r["board_id"], r["rail"])
|
||||
was = self.enabled.get(key)
|
||||
self.enabled[key] = bool(r["auto_recover"])
|
||||
if was and not self.enabled[key] and key in self.phones:
|
||||
self.phones[key].manual_reset() # 关掉 = 清状态(含 GAVE_UP)
|
||||
|
||||
def set_params(self, p: Params):
|
||||
self.params = p
|
||||
self.db.set_setting("recovery", json.dumps(asdict(p)))
|
||||
for ph in self.phones.values():
|
||||
ph.p = p
|
||||
ph.det.p = p
|
||||
|
||||
def phone(self, board: str, rail: int) -> PhoneRecovery:
|
||||
key = (board, rail)
|
||||
if key not in self.phones:
|
||||
self.phones[key] = PhoneRecovery(self.params)
|
||||
return self.phones[key]
|
||||
|
||||
def states(self) -> List[dict]:
|
||||
return [{"board": b, "rail": r, "state": ph.state, "reason": ph.reason, "since": ph.since,
|
||||
"attempts_1h": len([t for t in ph.attempts if self.clock() - t < 3600 * 1000]),
|
||||
"enabled": self.enabled.get((b, r), False)}
|
||||
for (b, r), ph in sorted(self.phones.items())]
|
||||
|
||||
def reset(self, board: str, rail: int):
|
||||
self.phone(board, rail).manual_reset()
|
||||
self._log(board, rail, IDLE, "manual_reset")
|
||||
|
||||
def on_tele(self, board: str, ts: int, st: dict):
|
||||
now = self.clock()
|
||||
for r in st.get("rails", []):
|
||||
rail = r.get("r")
|
||||
key = (board, rail)
|
||||
if not self.enabled.get(key):
|
||||
continue
|
||||
ph = self.phone(board, rail)
|
||||
ok = r.get("pr") and r.get("en") and r.get("st") == "idle" and isinstance(r.get("ua"), int)
|
||||
if not ok:
|
||||
ph.eligible_reset()
|
||||
continue
|
||||
state, action = ph.feed(now, r["ua"], rail)
|
||||
if state is not None:
|
||||
self._log(board, rail, state, ph.reason, action[1] if action else None)
|
||||
if action is not None:
|
||||
asyncio.ensure_future(self._act(board, rail, action[1]))
|
||||
|
||||
async def _act(self, board: str, rail: int, cmd: dict):
|
||||
try:
|
||||
rsp = await self.hub.call(board, cmd, "auto-recovery")
|
||||
if not rsp.get("ok"):
|
||||
log.warning("自动恢复 %s#%d 的 %s 被板子拒了:%s", board, rail, cmd["cmd"], rsp)
|
||||
except Exception as e: # 超时也只记下来:失联不再加码
|
||||
log.warning("自动恢复 %s#%d 下发失败:%s", board, rail, e)
|
||||
|
||||
def _log(self, board: str, rail: int, state: str, reason: str, cmd: Optional[dict] = None):
|
||||
detail = json.dumps(cmd) if cmd else ""
|
||||
now = self.clock()
|
||||
rid = self.db.x("INSERT INTO recovery_log (ts, board_id, rail, state, reason, detail) "
|
||||
"VALUES (?,?,?,?,?,?)", (now, board, rail, state, reason, detail))
|
||||
self.hub.broadcast({"t": "recovery", "row": {"id": rid, "ts": now, "board_id": board,
|
||||
"rail": rail, "state": state, "reason": reason,
|
||||
"detail": detail}})
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""模拟板子:说和真板一样的 MQTT 协议(firmware/main/mlink.h + cmd.h),给测试与本机开发用。
|
||||
|
||||
它**不是**固件的第二份实现,只模拟服务器看得见的那部分行为:
|
||||
· 回应格式、seq 判重(按值、窗口 32)、空槽拒绝、越界拒绝;
|
||||
· 遥测(status 的形状)、retained 上线消息;
|
||||
· 事件日志(带序号,`events` 分页 8 条);
|
||||
· 手机的电流:正常 = 在 300~900 mA 间游走;卡死 = 一条平线;关机 = ≈0。
|
||||
断电重启 / 长按电源键会让「卡死」的手机恢复正常 —— 自动恢复的测试靠这个。
|
||||
时间由调用方推进(`tick(now_ms)`),测试因此是确定的。
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .bus import Bus
|
||||
from .protocol import topic
|
||||
|
||||
NORMAL, HUNG, OFF = "normal", "hung", "off"
|
||||
|
||||
|
||||
class SimRail:
|
||||
def __init__(self, rail: int, present: bool = True):
|
||||
self.rail = rail
|
||||
self.present = present
|
||||
self.en = present
|
||||
self.vb = False
|
||||
self.set_mv = 3900
|
||||
self.phone = NORMAL if present else OFF
|
||||
self.ua = 500000
|
||||
self.busy_until = 0
|
||||
self.act = "idle"
|
||||
self.pending: Optional[str] = None # 序列结束时要做的事
|
||||
|
||||
|
||||
class SimBoard:
|
||||
def __init__(self, bus: Bus, board_id: str, present=(True,) * 5, seed: int = 1,
|
||||
fw: str = "0.1.0-sim"):
|
||||
self.bus = bus
|
||||
self.id = board_id
|
||||
self.fw = fw
|
||||
self.rails = [SimRail(i + 1, present[i]) for i in range(5)]
|
||||
self.rng = random.Random(seed)
|
||||
self.seen: List[tuple] = [] # (seq, cmd, rsp)
|
||||
self.events: List[list] = [] # [seq, t, rail, type, arg, mv, ua]
|
||||
self.now = 0
|
||||
self.up_ms = 0
|
||||
self.fan_floor = 0
|
||||
self.received: List[dict] = []
|
||||
self.drop_next_replies = 0 # 模拟网络丢回应(测试重传)
|
||||
bus.on_message = self._on_message
|
||||
self._ev(0, 1, 0) # BOOT
|
||||
|
||||
# ---- 对外 --------------------------------------------------------------
|
||||
def start(self):
|
||||
self.bus.start()
|
||||
self.bus.subscribe(topic(self.id, "cmd"))
|
||||
self.bus.publish(topic(self.id, "state"),
|
||||
json.dumps({"online": True, "fw": self.fw, "ip": "192.168.8.200"}),
|
||||
retain=True)
|
||||
|
||||
def go_offline(self):
|
||||
self.bus.publish(topic(self.id, "state"), '{"online":false}', retain=True)
|
||||
self.bus.stop()
|
||||
|
||||
def set_phone(self, rail: int, state: str):
|
||||
self.rails[rail - 1].phone = state
|
||||
|
||||
def tick(self, now_ms: int, publish: bool = True):
|
||||
dt = now_ms - self.now if self.now else 0
|
||||
self.now = now_ms
|
||||
self.up_ms += dt
|
||||
for r in self.rails:
|
||||
if r.act != "idle" and now_ms >= r.busy_until:
|
||||
self._finish(r)
|
||||
r.ua = self._current(r)
|
||||
if publish:
|
||||
self.bus.publish(topic(self.id, "tele"), json.dumps(self.status_body()), qos=0)
|
||||
|
||||
# ---- 行为 --------------------------------------------------------------
|
||||
def _current(self, r: SimRail) -> int:
|
||||
if not r.present or not r.en or r.act == "hr-off":
|
||||
return 0
|
||||
if r.phone == OFF:
|
||||
return 800 # 关机漏电流
|
||||
if r.phone == HUNG:
|
||||
return 412000 # 一条平线
|
||||
return max(250000, min(950000, r.ua + self.rng.randint(-60000, 60000)))
|
||||
|
||||
def _finish(self, r: SimRail):
|
||||
if r.pending == "restart":
|
||||
r.phone = NORMAL
|
||||
r.ua = 500000
|
||||
r.pending = None
|
||||
r.en = True if r.act == "hr-off" else r.en
|
||||
r.act = "idle"
|
||||
|
||||
def _ev(self, rail: int, typ: int, arg: int, mv: int = 0, ua: int = 0):
|
||||
self.events.append([len(self.events), self.up_ms, rail, typ, arg, mv, ua])
|
||||
|
||||
def status_body(self) -> dict:
|
||||
rails = []
|
||||
for r in self.rails:
|
||||
ok = r.present
|
||||
rails.append({"r": r.rail, "pr": r.present, "en": r.en, "vb": r.vb, "set": r.set_mv,
|
||||
"mv": (r.set_mv if r.en else 0) if ok else None,
|
||||
"ua": r.ua if ok else None, "pg": r.en if ok else None,
|
||||
"flt": False if ok else None, "st": r.act, "res": "-"})
|
||||
return {"ok": True, "fw": self.fw, "up": self.up_ms, "in": {"mv": 12030, "ua": 1500000},
|
||||
"rails": rails, "temp": [35000, 36500],
|
||||
"fan": {"duty": max(300, self.fan_floor), "floor": self.fan_floor, "en": True,
|
||||
"rpm": [3300, 3280], "stall": [False, False]}, "derate": False,
|
||||
"safety": {"cycles": self.up_ms // 20, "skipped": 0, "i2c_err": 0, "events": 0},
|
||||
"ev_next": len(self.events), "ev_pending": 0,
|
||||
"cmd": {"lines": len(self.received), "dups": 0, "rej": 0}}
|
||||
|
||||
def _on_message(self, t: str, payload: bytes):
|
||||
try:
|
||||
req = json.loads(payload)
|
||||
except ValueError:
|
||||
return self._reply({"ok": False, "err": "parse"})
|
||||
self.received.append(req)
|
||||
seq = req.get("seq")
|
||||
for s, c, rsp in self.seen:
|
||||
if s == seq:
|
||||
out = dict(rsp) if c == req.get("cmd") else {"seq": seq, "ok": False, "err": "arg"}
|
||||
out["dup"] = True
|
||||
return self._reply(out)
|
||||
rsp = self._exec(req)
|
||||
if seq is not None:
|
||||
rsp = dict({"seq": seq}, **rsp)
|
||||
if req.get("cmd") not in ("status", "events", "net") and seq is not None:
|
||||
self.seen.append((seq, req.get("cmd"), rsp))
|
||||
self.seen = self.seen[-32:]
|
||||
self._reply(rsp)
|
||||
|
||||
def _reply(self, rsp: dict):
|
||||
if self.drop_next_replies > 0:
|
||||
self.drop_next_replies -= 1
|
||||
return
|
||||
self.bus.publish(topic(self.id, "rsp"), json.dumps(rsp))
|
||||
|
||||
def _exec(self, req: dict) -> dict:
|
||||
cmd = req.get("cmd")
|
||||
if cmd == "status":
|
||||
return self.status_body()
|
||||
if cmd == "events":
|
||||
start = max(0, req.get("from", 0))
|
||||
ev = self.events[start:start + 8]
|
||||
return {"ok": True, "oldest": 0, "next": len(self.events), "ev": ev}
|
||||
if cmd == "fan":
|
||||
self.fan_floor = req["duty"]
|
||||
return {"ok": True, "floor": self.fan_floor}
|
||||
if req.get("seq") is None:
|
||||
return {"ok": False, "err": "no_seq"}
|
||||
rail = req.get("rail")
|
||||
if not isinstance(rail, int) or not 1 <= rail <= 5:
|
||||
return {"ok": False, "err": "range"}
|
||||
r = self.rails[rail - 1]
|
||||
if not r.present:
|
||||
self._ev(rail, 13, 0)
|
||||
return {"ok": False, "err": "empty"}
|
||||
if r.act != "idle" and cmd in ("power_cycle", "key", "edl", "vbus", "power"):
|
||||
return {"ok": False, "err": "busy"}
|
||||
if cmd == "power_cycle":
|
||||
r.en, r.act, r.busy_until, r.pending = False, "hr-off", self.now + 4000, "restart"
|
||||
self._ev(rail, 12, 0x21)
|
||||
return {"ok": True}
|
||||
if cmd == "key":
|
||||
r.act, r.busy_until = "key-hold", self.now + req["ms"]
|
||||
if req["keys"] == "pwr" and req["ms"] >= 10000:
|
||||
r.pending = "restart"
|
||||
self._ev(rail, 12, 0x20)
|
||||
return {"ok": True}
|
||||
if cmd == "edl":
|
||||
r.act, r.busy_until = "edl-enum", self.now + 3000
|
||||
return {"ok": True}
|
||||
if cmd == "vbus":
|
||||
if req["on"] and not r.en:
|
||||
return {"ok": False, "err": "state"}
|
||||
r.vb = req["on"]
|
||||
return {"ok": True}
|
||||
if cmd == "power":
|
||||
r.en = req["on"]
|
||||
if not r.en:
|
||||
r.vb = False
|
||||
return {"ok": True}
|
||||
if cmd == "set_voltage":
|
||||
r.set_mv = req["mv"]
|
||||
return {"ok": True, "set": r.set_mv}
|
||||
return {"ok": False, "err": "unknown_cmd"}
|
||||
@@ -0,0 +1,8 @@
|
||||
# 服务器是 Python 3.8(Ubuntu 20.04 自带),版本按 3.8 还能装的锁死。
|
||||
# 本机开发用更新的版本也行,代码只用两边都有的 API(不用 3.9+ 语法)。
|
||||
fastapi==0.115.14
|
||||
starlette==0.44.0 # 3.8 上能装到的最高版(0.45 起要 3.9)
|
||||
pydantic==2.10.6
|
||||
uvicorn==0.33.0
|
||||
paho-mqtt==2.1.0
|
||||
websockets==13.1 # uvicorn 的 WS 实现;缺了它 /api/ws 会落到 SPA 兜底、回 200 HTML(2026-09-27 线上踩过)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""HTTP / WebSocket 接口:用 TestClient 起完整的应用,Hub 经进程内假 broker 连模拟板子。"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from prrack.api import create_app
|
||||
from prrack.bus import MemBroker, MemBus
|
||||
from prrack.db import Db, now_ms
|
||||
from prrack.hub import Hub
|
||||
from prrack.recovery import RecoveryManager
|
||||
from prrack.simboard import SimBoard
|
||||
|
||||
B = "b81f3fc9c8bc"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
broker = MemBroker()
|
||||
db = Db(str(tmp_path / "a.db"))
|
||||
hub = Hub(db, MemBus(broker), attempt_timeout=0.2)
|
||||
rec = RecoveryManager(hub, now_ms)
|
||||
sim = SimBoard(MemBus(broker), B, present=(True, True, True, False, True))
|
||||
dist = tmp_path / "dist"
|
||||
(dist / "assets").mkdir(parents=True)
|
||||
(dist / "index.html").write_text("<html>rack</html>", encoding="utf-8")
|
||||
async def up():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
sim.tick(now_ms())
|
||||
|
||||
app = create_app(hub, rec, str(dist), startup=[up], shutdown=[hub.stop])
|
||||
with TestClient(app) as c:
|
||||
deadline = time.time() + 2
|
||||
while B not in hub.latest and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
yield c, hub, sim, db
|
||||
|
||||
|
||||
def test_boards_and_snapshot(env):
|
||||
c, hub, sim, db = env
|
||||
boards = c.get("/api/boards").json()
|
||||
assert [b["id"] for b in boards] == [B]
|
||||
assert boards[0]["online"] is True and len(boards[0]["phones"]) == 5
|
||||
assert c.get("/api/boards/nope").status_code == 404
|
||||
|
||||
|
||||
def test_command_records_the_portal_user(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": False},
|
||||
headers={"X-Auth-User": "zhouyang"})
|
||||
assert r.status_code == 200 and r.json()["ok"] is True
|
||||
assert sim.rails[0].en is False
|
||||
a = c.get("/api/audit").json()[0]
|
||||
assert a["user"] == "zhouyang" and a["request"]["cmd"] == "power" and a["ok"] == 1
|
||||
# 没有门户头(服务器本机直接 curl)记作 local
|
||||
c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": True})
|
||||
assert c.get("/api/audit").json()[0]["user"] == "local"
|
||||
|
||||
|
||||
def test_bad_commands_never_reach_the_board(env):
|
||||
c, hub, sim, db = env
|
||||
n = len(sim.received)
|
||||
for body in ({"cmd": "wifi", "ssid": "x"}, {"cmd": "set_voltage", "rail": 1, "mv": 4300},
|
||||
{"cmd": "key", "rail": 1, "keys": "pwr", "ms": 60000}, {"cmd": "rm -rf"}):
|
||||
r = c.post("/api/boards/%s/cmd" % B, json=body)
|
||||
assert r.status_code == 400, body
|
||||
assert len(sim.received) == n
|
||||
|
||||
|
||||
def test_board_rejection_is_passed_through(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power_cycle", "rail": 4})
|
||||
assert r.status_code == 200 and r.json()["err"] == "empty"
|
||||
|
||||
|
||||
def test_timeout_is_504(env):
|
||||
c, hub, sim, db = env
|
||||
sim.drop_next_replies = 99
|
||||
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 2, "on": True})
|
||||
assert r.status_code == 504
|
||||
|
||||
|
||||
def test_phone_settings_toggle_auto_recovery(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.patch("/api/boards/%s/rails/3" % B, json={"label": "A-03", "auto_recover": True})
|
||||
assert r.status_code == 200
|
||||
ph = [p for p in r.json()["phones"] if p["rail"] == 3][0]
|
||||
assert ph["label"] == "A-03" and ph["auto_recover"] == 1
|
||||
rec = c.get("/api/recovery").json()
|
||||
assert rec["params"]["window_s"] == 300
|
||||
r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1})
|
||||
assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 5000 # 负数不收
|
||||
|
||||
|
||||
def test_history_after_telemetry(env):
|
||||
c, hub, sim, db = env
|
||||
for _ in range(3):
|
||||
sim.tick(now_ms())
|
||||
time.sleep(0.1)
|
||||
hub.flush()
|
||||
h = c.get("/api/boards/%s/rails/1/history" % B, params={"start": now_ms() - 60000}).json()
|
||||
assert h["resolution"] == "raw" and len(h["points"]) >= 1
|
||||
assert h["points"][0][2] is not None
|
||||
|
||||
|
||||
def test_websocket_hello_and_live_tele(env):
|
||||
c, hub, sim, db = env
|
||||
with c.websocket_connect("/api/ws", headers={"X-Auth-User": "zhouyang"}) as ws:
|
||||
hello = ws.receive_json()
|
||||
assert hello["t"] == "hello" and hello["user"] == "zhouyang" and hello["boards"][0]["id"] == B
|
||||
sim.tick(now_ms())
|
||||
msg = ws.receive_json()
|
||||
while msg["t"] != "tele":
|
||||
msg = ws.receive_json()
|
||||
assert msg["board"] == B and len(msg["status"]["rails"]) == 5
|
||||
|
||||
|
||||
def test_spa_fallback_is_not_cached(env):
|
||||
c, hub, sim, db = env
|
||||
r = c.get("/some/deep/route")
|
||||
assert r.status_code == 200 and "rack" in r.text
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
|
||||
|
||||
def test_a_real_websocket_library_is_installed():
|
||||
"""TestClient 自带 WS 实现,上面那条 WS 用例在没装 websockets 的机器上也是绿的;
|
||||
而真 uvicorn 缺了它会把 /api/ws 当普通 GET(落到 SPA 兜底,回 200 HTML)。
|
||||
deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。"""
|
||||
from prrack.main import ws_library_ok
|
||||
assert ws_library_ok()
|
||||
@@ -0,0 +1,302 @@
|
||||
"""协议镜像 + Hub(经进程内假 broker 对接模拟板子)+ 自动恢复状态机。
|
||||
|
||||
和固件测试同一个口径:断言打在「板子那头真的收到 / 执行了什么」上(SimBoard.received、
|
||||
它的 rails 状态),不看 Hub 自己回了什么 —— 重传换了 seq 的现象恰恰是 Hub 这边一切正常、
|
||||
手机被按了两遍。"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from prrack import protocol
|
||||
from prrack.bus import MemBroker, MemBus, topic_matches
|
||||
from prrack.db import Db
|
||||
from prrack.hub import CmdTimeout, Hub
|
||||
from prrack.recovery import GAVE_UP, HARD, IDLE, SOFT, Params, PhoneRecovery, RecoveryManager
|
||||
from prrack.simboard import HUNG, NORMAL, OFF, SimBoard
|
||||
|
||||
B = "b81f3fc9c8bc"
|
||||
|
||||
|
||||
def run(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally: # 收掉 Hub 的后台任务(flusher / maint),免得退出时刷一屏 pending 警告
|
||||
tasks = asyncio.all_tasks(loop)
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
|
||||
loop.close()
|
||||
|
||||
|
||||
async def settle():
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
def rig(tmp_path, present=(True,) * 5, timeout=0.05):
|
||||
broker = MemBroker()
|
||||
db = Db(str(tmp_path / "t.db"))
|
||||
hub = Hub(db, MemBus(broker), attempt_timeout=timeout)
|
||||
sim = SimBoard(MemBus(broker), B, present=present)
|
||||
return hub, sim, db
|
||||
|
||||
|
||||
# ---- 协议镜像 -----------------------------------------------------------------
|
||||
|
||||
def test_build_command_whitelist_and_ranges():
|
||||
assert protocol.build_command({"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}) == \
|
||||
{"cmd": "key", "rail": 3, "keys": "vdn+pwr", "ms": 2000}
|
||||
bad = [
|
||||
{"cmd": "wifi", "ssid": "x"}, # 只收 USB 的命令不给界面
|
||||
{"cmd": "events"}, # 服务器自己同步
|
||||
{"cmd": "set_voltage", "rail": 1, "mv": 4011}, # SR-13 之上
|
||||
{"cmd": "set_voltage", "rail": 1, "mv": "3900"},
|
||||
{"cmd": "key", "rail": 1, "keys": "pwr+home", "ms": 100},
|
||||
{"cmd": "key", "rail": 1, "keys": "pwr", "ms": 15001},
|
||||
{"cmd": "power", "rail": 6, "on": True},
|
||||
{"cmd": "power", "rail": True, "on": True}, # bool 不是 int
|
||||
{"cmd": "vbus", "rail": 1, "on": 1},
|
||||
{"cmd": "fan", "duty": 1001},
|
||||
]
|
||||
for b in bad:
|
||||
with pytest.raises(protocol.CmdError):
|
||||
protocol.build_command(b)
|
||||
|
||||
|
||||
def test_seq_is_strictly_increasing_and_in_range():
|
||||
t = [1700000000.0]
|
||||
g = protocol.SeqGen(clock=lambda: t[0])
|
||||
a = [g.next() for _ in range(5)]
|
||||
assert a == sorted(set(a)) and all(1 <= x <= 0xFFFFFFFF for x in a)
|
||||
t[0] -= 100 # 时钟往回跳也不许重复
|
||||
assert g.next() > a[-1] or g.next() == 1
|
||||
|
||||
|
||||
def test_event_table_matches_firmware_numbers():
|
||||
# firmware/main/evlog.h 冻结的数字(抽几个钉住)
|
||||
assert protocol.EVENT_TYPES[3][0] == "OVER_VOLT"
|
||||
assert protocol.EVENT_TYPES[12][0] == "REBOOT"
|
||||
assert protocol.EVENT_TYPES[13][0] == "CMD_REJECT"
|
||||
assert protocol.EVENT_TYPES[16][0] == "PD_LIMIT"
|
||||
assert protocol.CMD_NAMES[9] == "power" and protocol.CMD_NAMES[12] == "net"
|
||||
|
||||
|
||||
def test_topic_matching():
|
||||
assert topic_matches("phonerail/+/rsp", "phonerail/%s/rsp" % B)
|
||||
assert not topic_matches("phonerail/+/rsp", "phonerail/%s/tele" % B)
|
||||
assert topic_matches("phonerail/#", "phonerail/x/y")
|
||||
assert protocol.parse_topic("phonerail/%s/tele" % B) == (B, "tele")
|
||||
assert protocol.parse_topic("evse/123/state") is None
|
||||
|
||||
|
||||
# ---- Hub ---------------------------------------------------------------------
|
||||
|
||||
def test_tele_is_ingested_and_board_discovered(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
sim.tick(1000)
|
||||
await settle()
|
||||
hub.flush()
|
||||
|
||||
run(go())
|
||||
snap = hub.snapshot(B)
|
||||
assert snap["online"] and snap["fw"] == "0.1.0-sim"
|
||||
assert snap["status"]["rails"][0]["en"] is True
|
||||
assert db.q("SELECT COUNT(*) c FROM tele")[0]["c"] == 5
|
||||
assert len(snap["phones"]) == 5 and snap["phones"][0]["auto_recover"] == 0
|
||||
|
||||
|
||||
def test_command_round_trip_and_audit(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
return await hub.call(B, {"cmd": "power", "rail": 2, "on": False}, "alice")
|
||||
|
||||
rsp = run(go())
|
||||
assert rsp["ok"] is True
|
||||
assert sim.rails[1].en is False
|
||||
row = db.q("SELECT * FROM audit")[0]
|
||||
assert row["user"] == "alice" and row["rail"] == 2 and row["ok"] == 1
|
||||
|
||||
|
||||
def test_retransmit_reuses_the_same_seq_and_executes_once(tmp_path):
|
||||
"""⭐ 回应丢了一次:Hub 用**同一个 seq** 重发,板子判重,只执行一次。"""
|
||||
hub, sim, db = rig(tmp_path)
|
||||
sim.drop_next_replies = 1
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
return await hub.call(B, {"cmd": "power_cycle", "rail": 1}, "bob")
|
||||
|
||||
rsp = run(go())
|
||||
assert rsp["ok"] is True and rsp.get("dup") is True
|
||||
sent = [r for r in sim.received if r.get("cmd") == "power_cycle"]
|
||||
assert len(sent) == 2 and sent[0]["seq"] == sent[1]["seq"]
|
||||
assert [e[3] for e in sim.events].count(12) == 1 # 重启事件只记了一次
|
||||
|
||||
|
||||
def test_timeout_is_audited_and_raised(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
sim.drop_next_replies = 99
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
with pytest.raises(CmdTimeout):
|
||||
await hub.call(B, {"cmd": "power", "rail": 1, "on": True}, "carol")
|
||||
|
||||
run(go())
|
||||
row = db.q("SELECT * FROM audit")[0]
|
||||
assert row["ok"] == 0 and json.loads(row["response"])["err"] == "timeout"
|
||||
assert len({r["seq"] for r in sim.received}) == 1 # 三次都是同一个 seq
|
||||
|
||||
|
||||
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 页
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
sim.tick(1000)
|
||||
for _ in range(20):
|
||||
await settle()
|
||||
sim._ev(2, 3, 0, 4200, 1) # 又来一条过压
|
||||
sim.tick(2000)
|
||||
for _ in range(20):
|
||||
await settle()
|
||||
|
||||
run(go())
|
||||
seqs = [r["seq"] for r in db.q("SELECT seq FROM events ORDER BY seq")]
|
||||
assert seqs == list(range(21))
|
||||
assert db.q("SELECT ev_cursor FROM boards")[0]["ev_cursor"] == 21
|
||||
|
||||
|
||||
def test_empty_slot_reply_passes_through(tmp_path):
|
||||
hub, sim, db = rig(tmp_path, present=(True, True, True, False, True))
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
return await hub.call(B, {"cmd": "power", "rail": 4, "on": True}, "dave")
|
||||
|
||||
assert run(go()) == {"seq": sim.received[-1]["seq"], "ok": False, "err": "empty"}
|
||||
|
||||
|
||||
# ---- 自动恢复:状态机 ------------------------------------------------------------
|
||||
|
||||
P = Params(window_s=60, flat_ua=5000, low_ua=30000, settle_s=30, max_per_hour=3)
|
||||
|
||||
|
||||
def feed(ph, t0, secs, ua_fn):
|
||||
out = []
|
||||
for s in range(secs):
|
||||
st, act = ph.feed(t0 + s * 1000, ua_fn(s), 3)
|
||||
if st or act:
|
||||
out.append((t0 + s * 1000, st, act))
|
||||
return out
|
||||
|
||||
|
||||
def test_flat_current_escalates_soft_then_hard_then_gives_up():
|
||||
ph = PhoneRecovery(P)
|
||||
r = feed(ph, 0, 61, lambda s: 412000)
|
||||
assert r[-1][1] == SOFT and r[-1][2][1] == {"cmd": "key", "rail": 3, "keys": "pwr", "ms": 10000}
|
||||
t = r[-1][0]
|
||||
# settle 期间的样本一概不看(哪怕是平线)
|
||||
assert feed(ph, t + 1000, 29, lambda s: 412000) == []
|
||||
r = feed(ph, t + 30000, 62, lambda s: 412000)
|
||||
assert r[-1][1] == HARD and r[-1][2][1] == {"cmd": "power_cycle", "rail": 3}
|
||||
t = r[-1][0]
|
||||
r = feed(ph, t + 30000, 62, lambda s: 412000)
|
||||
assert r[-1][1] == GAVE_UP and r[-1][2] is None
|
||||
assert feed(ph, t + 200000, 100, lambda s: 412000) == [] # 放弃之后再也不动
|
||||
|
||||
|
||||
def test_recovers_after_soft_when_current_moves_again():
|
||||
ph = PhoneRecovery(P)
|
||||
t = feed(ph, 0, 61, lambda s: 412000)[-1][0]
|
||||
r = feed(ph, t + 30000, 70, lambda s: 300000 + (s % 7) * 40000)
|
||||
assert r[-1][1] == IDLE and ph.reason == "recovered"
|
||||
|
||||
|
||||
def test_low_current_is_detected_and_normal_is_not():
|
||||
ph = PhoneRecovery(P)
|
||||
assert feed(ph, 0, 200, lambda s: 300000 + (s % 5) * 50000) == []
|
||||
ph2 = PhoneRecovery(P)
|
||||
assert feed(ph2, 0, 61, lambda s: 800 + s % 3)[-1][2][0] == "low"
|
||||
|
||||
|
||||
def test_rate_limit_gives_up():
|
||||
ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2))
|
||||
t = 0
|
||||
for _ in range(2): # 两次软重启都「恢复」了,但每次很快又卡
|
||||
assert feed(ph, t, 65, lambda s: 412000)[-1][1] == SOFT
|
||||
t += 65000 # 下一段接着上一段喂完的时刻往后走(时间不倒退)
|
||||
# 前 30 s 还在 settle 里不算数,之后要攒满一个 60 s 窗口才判「恢复」
|
||||
assert feed(ph, t, 100, lambda s: 300000 + (s % 7) * 40000)[-1][1] == IDLE
|
||||
t += 100000
|
||||
r = feed(ph, t, 65, lambda s: 412000)
|
||||
assert r[-1][1] == GAVE_UP and ph.reason == "rate_limit"
|
||||
|
||||
|
||||
# ---- 自动恢复:接到 Hub + 模拟板上 -------------------------------------------------
|
||||
|
||||
def test_auto_recovery_end_to_end_with_sim(tmp_path):
|
||||
"""⭐ 模拟板上第 3 台卡死:开了自动恢复 → 5 分钟后下长按电源 → 板子执行 → 恢复。
|
||||
没开自动恢复的第 2 台(也卡死)一个命令都不收到。"""
|
||||
hub, sim, db = rig(tmp_path)
|
||||
clock = [1_000_000]
|
||||
rec = RecoveryManager(hub, lambda: clock[0])
|
||||
sim.set_phone(3, HUNG)
|
||||
sim.set_phone(2, HUNG)
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
await settle() # 等 Hub 处理完上线消息、建好这块板的 phones 行
|
||||
db.x("UPDATE phones SET auto_recover=1 WHERE rail=3")
|
||||
rec.reload()
|
||||
for s in range(300 + 130 + 320):
|
||||
clock[0] += 1000
|
||||
sim.tick(clock[0])
|
||||
await settle()
|
||||
|
||||
run(go())
|
||||
keys = [r for r in sim.received if r.get("cmd") == "key"]
|
||||
assert len(keys) == 1 and keys[0]["rail"] == 3 and keys[0]["ms"] == 10000
|
||||
assert not [r for r in sim.received if r.get("rail") == 2]
|
||||
assert sim.rails[2].phone == NORMAL
|
||||
states = [r["state"] for r in db.q("SELECT state FROM recovery_log ORDER BY id")]
|
||||
assert states == [SOFT, IDLE]
|
||||
assert db.q("SELECT user FROM audit")[0]["user"] == "auto-recovery"
|
||||
|
||||
|
||||
def test_auto_recovery_ignores_rails_that_are_off(tmp_path):
|
||||
hub, sim, db = rig(tmp_path)
|
||||
clock = [0]
|
||||
rec = RecoveryManager(hub, lambda: clock[0])
|
||||
sim.set_phone(1, OFF)
|
||||
sim.rails[0].en = False # 这一路本来就关着:电流 0 不是故障
|
||||
|
||||
async def go():
|
||||
await hub.start()
|
||||
sim.start()
|
||||
await settle()
|
||||
db.x("UPDATE phones SET auto_recover=1")
|
||||
rec.reload()
|
||||
for s in range(400):
|
||||
clock[0] += 1000
|
||||
sim.tick(clock[0])
|
||||
await settle()
|
||||
|
||||
run(go())
|
||||
assert not [r for r in sim.received if r.get("cmd") in ("key", "power_cycle")]
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# 每日 03:40 备份 rack.db(/etc/cron.d/phonerail-rack-backup),留 14 份。
|
||||
# 用 sqlite3 .backup 而不是 cp:WAL 模式下直接拷文件可能拷到半个事务。
|
||||
set -e
|
||||
D=/root/phonerail-rack
|
||||
mkdir -p $D/backup
|
||||
sqlite3 $D/data/rack.db ".backup $D/backup/rack-$(date +%Y%m%d).db"
|
||||
ls -1t $D/backup/rack-*.db | tail -n +15 | xargs -r rm -f
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本机 Git Bash 里跑:构建前端 → 传后端与 dist → 装依赖 → 服务器上跑测试 → 重启服务。
|
||||
# bash rack/deploy/deploy.sh (默认走 home.example;内网通时 HOST=10.5.5.13)
|
||||
set -euo pipefail
|
||||
HOST=${HOST:-home.example}
|
||||
R=/root/phonerail-rack
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
(cd frontend && pnpm build)
|
||||
|
||||
ssh "$HOST" "mkdir -p $R/backend $R/frontend $R/data $R/deploy"
|
||||
tar --exclude='__pycache__' --exclude='.pytest_cache' --exclude='*.db*' -C backend -cf - . \
|
||||
| ssh "$HOST" "rm -rf $R/backend.new && mkdir $R/backend.new && tar -xf - -C $R/backend.new"
|
||||
tar -C frontend -cf - dist | ssh "$HOST" "rm -rf $R/frontend/dist.new && mkdir -p $R/frontend/dist.new && tar -xf - -C $R/frontend/dist.new --strip-components=1"
|
||||
tar -C deploy -cf - . | ssh "$HOST" "tar -xf - -C $R/deploy"
|
||||
|
||||
ssh "$HOST" bash -s <<REMOTE
|
||||
set -euo pipefail
|
||||
cd $R
|
||||
[ -x venv/bin/python ] || python3 -m venv venv
|
||||
venv/bin/pip install -q -U pip 2>/dev/null
|
||||
venv/bin/pip install -q -r backend.new/requirements.txt pytest httpx 2>&1 | grep -v InsecureRequestWarning || true
|
||||
# 先在新代码上跑测试(Python 3.8 + 锁定版本),不过就不换上去
|
||||
(cd backend.new && ../venv/bin/python -m pytest -q tests 2>&1 | tail -3)
|
||||
rm -rf backend.old && [ -d backend ] && mv backend backend.old || true
|
||||
mv backend.new backend
|
||||
rm -rf frontend/dist.old && [ -d frontend/dist ] && mv frontend/dist frontend/dist.old || true
|
||||
mv frontend/dist.new frontend/dist
|
||||
chmod +x deploy/backup.sh
|
||||
cp deploy/phonerail-rack.service /etc/systemd/system/phonerail-rack.service
|
||||
echo '40 3 * * * root /root/phonerail-rack/deploy/backup.sh' > /etc/cron.d/phonerail-rack-backup
|
||||
touch /var/log/phonerail-rack.log
|
||||
systemctl daemon-reload
|
||||
systemctl enable -q phonerail-rack
|
||||
systemctl restart phonerail-rack
|
||||
sleep 3
|
||||
systemctl is-active phonerail-rack
|
||||
curl -s http://127.0.0.1:7764/api/health; echo
|
||||
ss -tlnp | grep 7764
|
||||
REMOTE
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=PhoneRail 机架管理(FastAPI,只绑 127.0.0.1:7764,经统一门户 phonerail.home.example:7755 访问)
|
||||
After=network-online.target mosquitto.service
|
||||
Wants=mosquitto.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/root/phonerail-rack/backend
|
||||
ExecStart=/root/phonerail-rack/venv/bin/python -m prrack.main \
|
||||
--db /root/phonerail-rack/data/rack.db \
|
||||
--static /root/phonerail-rack/frontend/dist \
|
||||
--mqtt-env /root/phonerail/mqtt.env \
|
||||
--bind 127.0.0.1 --port 7764
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
StandardOutput=append:/var/log/phonerail-rack.log
|
||||
StandardError=append:/var/log/phonerail-rack.log
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PhoneRail 机架</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "phonerail-rack-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.9.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
+1044
文件差异内容过多而无法显示
加载差异
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="shell">
|
||||
<header class="top">
|
||||
<router-link to="/" class="brand">PhoneRail 机架</router-link>
|
||||
<nav>
|
||||
<router-link to="/">总览</router-link>
|
||||
<router-link to="/events">事件</router-link>
|
||||
<router-link to="/audit">操作记录</router-link>
|
||||
<router-link to="/recovery">自动恢复</router-link>
|
||||
</nav>
|
||||
<div class="conn">
|
||||
<span :class="['dot', state.ws ? 'up' : 'down']" :title="state.ws ? '已连上服务器' : '与服务器断开,重连中'" />
|
||||
<span v-if="!state.ws" class="warn">断开</span>
|
||||
<span v-else-if="!state.mqtt" class="warn" title="后端连不上 MQTT broker:所有板子都会显示通信中断">broker 断开</span>
|
||||
<span class="user">{{ state.user }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main><router-view /></main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { state } from './store'
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root { --bg: #f1f5f9; --card: #fff; --line: #e2e8f0; --muted: #64748b; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; color: #0f172a; }
|
||||
.shell { min-height: 100vh; }
|
||||
.top { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 16px;
|
||||
padding: 0 16px; height: 52px; background: #0f172a; color: #fff; }
|
||||
.top a { color: #cbd5e1; text-decoration: none; }
|
||||
.top .brand { color: #fff; font-weight: 600; white-space: nowrap; }
|
||||
.top nav { display: flex; gap: 14px; overflow-x: auto; flex: 1; min-width: 0; }
|
||||
.top nav a { white-space: nowrap; padding: 4px 0; }
|
||||
.top nav a.router-link-exact-active { color: #fff; border-bottom: 2px solid #38bdf8; }
|
||||
.conn { display: flex; align-items: center; gap: 8px; font-size: 13px; white-space: nowrap; }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
|
||||
.dot.up { background: #22c55e; } .dot.down { background: #ef4444; }
|
||||
.conn .warn { color: #fca5a5; }
|
||||
.conn .user { color: #94a3b8; }
|
||||
main { padding: 16px; max-width: 1400px; margin: 0 auto; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
@media (max-width: 640px) {
|
||||
.top { gap: 10px; padding: 0 10px; }
|
||||
.top .brand { display: none; }
|
||||
.conn .user { display: none; }
|
||||
main { padding: 10px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
// REST 调用。错误统一变成带中文说明的 Error(后端的 HTTPException detail)。
|
||||
async function req(method, url, body) {
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const text = await r.text()
|
||||
let data = null
|
||||
try { data = text ? JSON.parse(text) : null } catch { data = text }
|
||||
if (!r.ok) {
|
||||
const msg = (data && data.detail) || `${r.status} ${r.statusText}`
|
||||
// 401/403 是门户挡的(会话过期):整页刷新让门户带去登录
|
||||
if (r.status === 401 || r.status === 403) location.reload()
|
||||
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg))
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
boards: () => req('GET', '/api/boards'),
|
||||
cmd: (board, body) => req('POST', `/api/boards/${board}/cmd`, body),
|
||||
patchBoard: (board, body) => req('PATCH', `/api/boards/${board}`, body),
|
||||
patchPhone: (board, rail, body) => req('PATCH', `/api/boards/${board}/rails/${rail}`, body),
|
||||
history: (board, rail, start, end) =>
|
||||
req('GET', `/api/boards/${board}/rails/${rail}/history?start=${start}${end ? `&end=${end}` : ''}`),
|
||||
events: (q = {}) => req('GET', '/api/events?' + new URLSearchParams(q)),
|
||||
audit: (q = {}) => req('GET', '/api/audit?' + new URLSearchParams(q)),
|
||||
recovery: () => req('GET', '/api/recovery'),
|
||||
setRecoveryParams: p => req('PUT', '/api/recovery/params', p),
|
||||
resetRecovery: (board, rail) => req('POST', `/api/recovery/${board}/${rail}/reset`),
|
||||
}
|
||||
|
||||
// 板子回的错误码 → 人话(firmware/main/cmd.h + ulink/mlink 的链路层错误)
|
||||
export const ERR_ZH = {
|
||||
parse: '板子没看懂这条命令', unknown_cmd: '板子不认识这条命令', no_seq: '缺序号',
|
||||
arg: '参数不对', range: '参数越界', empty: '这个槽没插刀片', busy: '这一路正在执行别的动作',
|
||||
state: '当前状态下不允许(比如没上电时开 VBUS)', io: '下发到器件失败(I²C)',
|
||||
overflow: '回应太长', link: '这条命令只能经 USB 下发', too_long: '命令太长',
|
||||
bad_crc: '校验错', bad_frame: '坏数据', queue_full: '板子命令队列满,稍后再试', timeout: '板子没有回应',
|
||||
}
|
||||
|
||||
export function rspText(rsp) {
|
||||
if (!rsp) return '无回应'
|
||||
if (rsp.ok) return rsp.dup ? '成功(重传,板子已判重)' : '成功'
|
||||
return ERR_ZH[rsp.err] || rsp.err
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="card hc">
|
||||
<div class="hh">
|
||||
<b>第 {{ rail }} 路 · 电流 / 电压</b>
|
||||
<el-radio-group v-model="range" size="small">
|
||||
<el-radio-button v-for="o in RANGES" :key="o.v" :value="o.v">{{ o.t }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div ref="el" class="chart" />
|
||||
<div class="muted">{{ note }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
// 按需引入:整包 echarts ≈340 KB(gzip),手机上打开板子页要等好几秒
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer])
|
||||
import { api } from '../api'
|
||||
import { onMessage } from '../store'
|
||||
|
||||
const props = defineProps({ board: String, rail: Number })
|
||||
const RANGES = [
|
||||
{ v: 600e3, t: '10 分钟' }, { v: 3600e3, t: '1 小时' }, { v: 6 * 3600e3, t: '6 小时' },
|
||||
{ v: 86400e3, t: '1 天' }, { v: 7 * 86400e3, t: '7 天' },
|
||||
]
|
||||
const range = ref(600e3)
|
||||
const el = ref(null)
|
||||
const note = ref('')
|
||||
let chart = null
|
||||
let pts = [] // [t, mv, ua, ua_min, ua_max]
|
||||
let live = false
|
||||
|
||||
function render() {
|
||||
const band = pts.some(p => p[3] != null && p[3] !== p[4])
|
||||
chart.setOption({
|
||||
animation: false,
|
||||
grid: { left: 50, right: 50, top: 30, bottom: 50 },
|
||||
tooltip: { trigger: 'axis', valueFormatter: v => (v == null ? '—' : v.toFixed(1)) },
|
||||
legend: { data: ['电流 mA', '电压 V'], top: 0 },
|
||||
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 8 }],
|
||||
xAxis: { type: 'time' },
|
||||
yAxis: [{ type: 'value', name: 'mA', scale: true }, // 电压几乎恒定:不固定范围的话 echarts 会把 3.9 V 画在 1~6 V 的正中间,一点起伏都看不出
|
||||
{ type: 'value', name: 'V', min: v => Math.min(3.4, Math.floor(v.min * 10) / 10), max: v => Math.max(4.2, Math.ceil(v.max * 10) / 10), splitLine: { show: false } }],
|
||||
series: [
|
||||
// 分钟聚合时把 min/max 画成带状:「这一分钟里电流动没动」比均值更能看出卡死
|
||||
...(band ? [
|
||||
{ name: 'min', type: 'line', data: pts.map(p => [p[0], p[3] / 1000]), lineStyle: { opacity: 0 }, stack: 'b', symbol: 'none', silent: true, tooltip: { show: false } },
|
||||
{ name: 'max', type: 'line', data: pts.map(p => [p[0], (p[4] - p[3]) / 1000]), lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(37,99,235,.15)' }, stack: 'b', symbol: 'none', silent: true, tooltip: { show: false } },
|
||||
] : []),
|
||||
{ name: '电流 mA', type: 'line', data: pts.map(p => [p[0], p[2] == null ? null : p[2] / 1000]), symbol: 'none', color: '#2563eb', connectNulls: false },
|
||||
{ name: '电压 V', type: 'line', yAxisIndex: 1, data: pts.map(p => [p[0], p[1] == null ? null : p[1] / 1000]), symbol: 'none', color: '#16a34a' },
|
||||
],
|
||||
}, true)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const end = Date.now()
|
||||
try {
|
||||
const h = await api.history(props.board, props.rail, end - range.value, end)
|
||||
pts = h.points
|
||||
live = h.resolution === 'raw' && range.value <= 3600e3
|
||||
note.value = `${h.resolution === 'raw' ? '原始数据' : '分钟聚合(带状 = 每分钟最小~最大)'} · ${pts.length} 点` +
|
||||
(live ? ' · 实时追加' : '')
|
||||
render()
|
||||
} catch (e) {
|
||||
note.value = '加载失败:' + e.message
|
||||
}
|
||||
}
|
||||
|
||||
const off = onMessage(m => {
|
||||
if (!live || m.t !== 'tele' || m.board !== props.board) return
|
||||
const r = m.status.rails?.[props.rail - 1]
|
||||
if (!r) return
|
||||
pts.push([m.ts, r.mv, r.ua, r.ua, r.ua])
|
||||
const cut = Date.now() - range.value
|
||||
while (pts.length && pts[0][0] < cut) pts.shift()
|
||||
render()
|
||||
})
|
||||
|
||||
const onResize = () => chart && chart.resize()
|
||||
onMounted(() => { chart = echarts.init(el.value); load(); window.addEventListener('resize', onResize) })
|
||||
onBeforeUnmount(() => { off(); window.removeEventListener('resize', onResize); chart && chart.dispose() })
|
||||
watch(() => [props.board, props.rail, range.value], load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hh { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.chart { height: 320px; margin-top: 8px; }
|
||||
@media (max-width: 520px) { .chart { height: 240px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="card rail" :class="{ sel: selected }" :style="{ borderTopColor: LEVEL_COLOR[h.level] }" @click="$emit('select')">
|
||||
<div class="rh">
|
||||
<span class="no">{{ rail }}</span>
|
||||
<el-input v-model="label" size="small" class="label" placeholder="手机编号" @change="saveLabel" @click.stop />
|
||||
<el-tag :color="LEVEL_COLOR[h.level]" effect="dark" size="small" class="tag">{{ h.text }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="nums" v-if="r?.pr">
|
||||
<div><b>{{ V(r.mv) }}</b><small> V</small><div class="muted">设定 {{ V(r.set) }}</div></div>
|
||||
<div><b>{{ mA(r.ua) }}</b><small> mA</small><div class="muted"> </div></div>
|
||||
</div>
|
||||
<div class="flags" v-if="r?.pr">
|
||||
<span :class="{ on: r.en }">直供{{ r.en ? '开' : '关' }}</span>
|
||||
<span :class="{ on: r.vb }">USB{{ r.vb ? '通' : '断' }}</span>
|
||||
<span :class="{ on: r.pg, bad: r.en && r.pg === false }">PG</span>
|
||||
<span :class="{ bad: r.flt }" v-if="r.flt">FLT</span>
|
||||
</div>
|
||||
|
||||
<div class="ar" @click.stop>
|
||||
<el-switch v-model="auto" size="small" :disabled="!r?.pr" @change="saveAuto" />
|
||||
<span class="muted">自动恢复</span>
|
||||
<el-tag v-if="recState && recState !== 'idle'" type="warning" size="small">{{ RECOVERY_ZH[recState] }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="acts" @click.stop>
|
||||
<el-button size="small" type="danger" plain :disabled="!can" @click="run('断电重启', { cmd: 'power_cycle' }, true)">断电重启</el-button>
|
||||
<el-button size="small" plain :disabled="!can" @click="run('强制重启(长按电源 10 秒)', { cmd: 'key', keys: 'pwr', ms: 10000 }, true)">强制重启</el-button>
|
||||
<el-dropdown trigger="click" :disabled="!r?.pr" @command="more">
|
||||
<el-button size="small" plain>更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="tap">短按电源(亮屏 / 息屏)</el-dropdown-item>
|
||||
<el-dropdown-item command="on">开机(长按电源 2 秒)</el-dropdown-item>
|
||||
<el-dropdown-item command="fastboot" divided>进 Fastboot(音量下 + 电源)</el-dropdown-item>
|
||||
<el-dropdown-item command="recovery">进 Recovery(音量上 + 电源)</el-dropdown-item>
|
||||
<el-dropdown-item command="edl">进 EDL / 9008</el-dropdown-item>
|
||||
<el-dropdown-item command="vbus" divided>{{ r?.vb ? '断开 USB' : '接通 USB' }}</el-dropdown-item>
|
||||
<el-dropdown-item command="power">{{ r?.en ? '断电(保持关闭)' : '上电' }}</el-dropdown-item>
|
||||
<el-dropdown-item command="volt">调电压…</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="voltOpen" title="调整输出电压" width="min(420px, 92vw)" append-to-body>
|
||||
<p class="muted">范围 3.524 ~ 4.010 V(硬件决定,固件再限一次)。只是微调,不是保护手段。</p>
|
||||
<el-slider v-model="mv" :min="3524" :max="4010" :step="1" show-input />
|
||||
<template #footer>
|
||||
<el-button @click="voltOpen = false">取消</el-button>
|
||||
<el-button type="primary" @click="setVolt">下发 {{ (mv / 1000).toFixed(3) }} V</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { api, rspText } from '../api'
|
||||
import { mA, V, railHealth, LEVEL_COLOR, RECOVERY_ZH } from '../util'
|
||||
|
||||
const props = defineProps({ board: Object, rail: Number, selected: Boolean, recState: String })
|
||||
defineEmits(['select'])
|
||||
|
||||
const r = computed(() => props.board?.status?.rails?.[props.rail - 1])
|
||||
const phone = computed(() => props.board?.phones?.find(p => p.rail === props.rail) || {})
|
||||
const h = computed(() => railHealth(props.board, r.value))
|
||||
// 在位、板子在线、执行器空闲才能下动作(忙的时候板子也会回 busy,这里只是少一次无用的往返)
|
||||
const can = computed(() => r.value?.pr && !props.board.stale && props.board.online && r.value.st === 'idle')
|
||||
|
||||
const label = ref('')
|
||||
const auto = ref(false)
|
||||
watch(phone, p => { label.value = p.label || ''; auto.value = !!p.auto_recover }, { immediate: true })
|
||||
|
||||
const who = () => `${props.board.name || props.board.id} #${props.rail}${label.value ? `(${label.value})` : ''}`
|
||||
|
||||
async function run(title, body, confirm) {
|
||||
if (confirm) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`对 ${who()} 执行「${title}」?`, '确认', { type: 'warning', confirmButtonText: '执行' })
|
||||
} catch { return }
|
||||
}
|
||||
try {
|
||||
const rsp = await api.cmd(props.board.id, { rail: props.rail, ...body })
|
||||
ElMessage({ type: rsp.ok ? 'success' : 'error', message: `${title}:${rspText(rsp)}` })
|
||||
} catch (e) {
|
||||
ElMessage.error(`${title}:${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function more(c) {
|
||||
const on = r.value
|
||||
switch (c) {
|
||||
case 'tap': return run('短按电源', { cmd: 'key', keys: 'pwr', ms: 100 })
|
||||
case 'on': return run('开机', { cmd: 'key', keys: 'pwr', ms: 2000 })
|
||||
case 'fastboot': return run('进 Fastboot', { cmd: 'key', keys: 'vdn+pwr', ms: 10000 }, true)
|
||||
case 'recovery': return run('进 Recovery', { cmd: 'key', keys: 'vup+pwr', ms: 10000 }, true)
|
||||
case 'edl': return run('进 EDL', { cmd: 'edl' }, true)
|
||||
case 'vbus': return run(on.vb ? '断开 USB' : '接通 USB', { cmd: 'vbus', on: !on.vb }, on.vb)
|
||||
case 'power': return run(on.en ? '断电' : '上电', { cmd: 'power', on: !on.en }, on.en)
|
||||
case 'volt': mv.value = on.set || 3900; voltOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const voltOpen = ref(false)
|
||||
const mv = ref(3900)
|
||||
async function setVolt() {
|
||||
voltOpen.value = false
|
||||
await run(`电压 ${(mv.value / 1000).toFixed(3)} V`, { cmd: 'set_voltage', mv: mv.value })
|
||||
}
|
||||
|
||||
async function saveLabel() {
|
||||
try { await api.patchPhone(props.board.id, props.rail, { label: label.value }) } catch (e) { ElMessage.error(e.message) }
|
||||
}
|
||||
async function saveAuto(v) {
|
||||
if (v) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`打开 ${who()} 的自动恢复?电流平直或过低持续 5 分钟会自动长按电源,仍不好再断电重启;` +
|
||||
'判据阈值还没有实测依据,建议先在一两台上观察。', '自动恢复', { type: 'warning' })
|
||||
} catch { auto.value = false; return }
|
||||
}
|
||||
try { await api.patchPhone(props.board.id, props.rail, { auto_recover: v }) } catch (e) { ElMessage.error(e.message); auto.value = !v }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rail { border-top: 4px solid; cursor: pointer; display: flex; flex-direction: column; gap: 10px; }
|
||||
.rail.sel { box-shadow: 0 0 0 2px #38bdf8; }
|
||||
.rh { display: flex; align-items: center; gap: 6px; }
|
||||
.no { font-weight: 700; font-size: 18px; width: 18px; }
|
||||
.label { flex: 1; min-width: 0; }
|
||||
.tag { border: none; flex-shrink: 0; }
|
||||
.nums { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.nums b { font-size: 22px; font-variant-numeric: tabular-nums; }
|
||||
.nums small { color: var(--muted); }
|
||||
.flags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.flags span { font-size: 12px; padding: 1px 6px; border-radius: 4px; background: #f1f5f9; color: #94a3b8; }
|
||||
.flags span.on { background: #dcfce7; color: #166534; }
|
||||
.flags span.bad { background: #fee2e2; color: #b91c1c; }
|
||||
.ar { display: flex; align-items: center; gap: 6px; }
|
||||
.acts { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.acts .el-button + .el-button { margin-left: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import Overview from './pages/Overview.vue'
|
||||
import Board from './pages/Board.vue'
|
||||
import Events from './pages/Events.vue'
|
||||
import Audit from './pages/Audit.vue'
|
||||
import Recovery from './pages/Recovery.vue'
|
||||
import { connect } from './store'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Overview },
|
||||
{ path: '/board/:id', component: Board, props: true },
|
||||
{ path: '/events', component: Events },
|
||||
{ path: '/audit', component: Audit },
|
||||
{ path: '/recovery', component: Recovery },
|
||||
],
|
||||
})
|
||||
|
||||
connect()
|
||||
createApp(App).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="bar">
|
||||
<b>操作记录</b>
|
||||
<span class="muted">每一条会动东西的命令:谁(门户账号 / auto-recovery)、对哪块板哪一路、板子怎么回的。</span>
|
||||
</div>
|
||||
<el-table :data="rows" size="small" empty-text="暂无">
|
||||
<el-table-column label="时间" width="140"><template #default="{ row }">{{ fmtTime(row.ts) }}</template></el-table-column>
|
||||
<el-table-column label="谁" prop="user" width="130" />
|
||||
<el-table-column label="板子" width="150"><template #default="{ row }">{{ state.boards[row.board_id]?.name || row.board_id }}</template></el-table-column>
|
||||
<el-table-column label="路" prop="rail" width="50" />
|
||||
<el-table-column label="命令" min-width="200"><template #default="{ row }"><code>{{ show(row.request) }}</code></template></el-table-column>
|
||||
<el-table-column label="结果" width="200">
|
||||
<template #default="{ row }"><span :class="{ bad: !row.ok }">{{ rspText(row.response) }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { api, rspText } from '../api'
|
||||
import { state, onMessage } from '../store'
|
||||
import { fmtTime } from '../util'
|
||||
|
||||
const rows = ref([])
|
||||
const show = r => { const { seq, rail, ...rest } = r; return JSON.stringify(rest) }
|
||||
const load = async () => { rows.value = await api.audit({ limit: 300 }) }
|
||||
const off = onMessage(m => { if (m.t === 'audit') rows.value.unshift(m.row) })
|
||||
onMounted(load)
|
||||
onBeforeUnmount(off)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.bad { color: #dc2626; }
|
||||
code { font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div v-if="!b" class="card"><el-empty description="没有这块板子(或者还没收到它的数据)" /></div>
|
||||
<div v-else class="bd">
|
||||
<div class="card head">
|
||||
<div class="names">
|
||||
<el-input v-model="name" placeholder="板子名称" @change="saveMeta" />
|
||||
<el-input v-model="location" placeholder="位置(如 机架 A 第 3 层)" @change="saveMeta" />
|
||||
</div>
|
||||
<div class="info">
|
||||
<el-tag v-if="!b.online || b.stale" type="info">通信中断 · 最后数据 {{ ago(b.last_tele) }}</el-tag>
|
||||
<el-tag v-else type="success">在线</el-tag>
|
||||
<span class="muted">ID {{ b.id }} · 固件 {{ b.fw }} · IP {{ b.ip }}</span>
|
||||
</div>
|
||||
<div class="stats" v-if="b.status">
|
||||
<div><span class="muted">输入</span><b>{{ V(b.status.in?.mv) }} V</b><b>{{ mA(b.status.in?.ua) }} mA</b></div>
|
||||
<div><span class="muted">温度</span><b v-for="(t, i) in b.status.temp || []" :key="i">{{ t == null ? '—' : C(t) + ' °C' }}</b></div>
|
||||
<div>
|
||||
<span class="muted">风扇</span><b>{{ (b.status.fan?.duty ?? 0) / 10 }}%</b>
|
||||
<b v-for="(r, i) in b.status.fan?.rpm || []" :key="i" :class="{ bad: b.status.fan?.stall?.[i] }">{{ r ?? '—' }} rpm</b>
|
||||
<el-select v-model="fanFloor" size="small" style="width: 130px" @change="setFan">
|
||||
<el-option :value="0" label="自动" />
|
||||
<el-option v-for="d in [500, 700, 850, 1000]" :key="d" :value="d" :label="`不低于 ${d / 10}%`" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div><span class="muted">巡检</span><b>{{ b.status.safety?.cycles }} 圈</b>
|
||||
<b :class="{ bad: b.status.safety?.skipped }">跳过 {{ b.status.safety?.skipped }}</b>
|
||||
<b :class="{ bad: b.status.safety?.i2c_err }">I²C 错 {{ b.status.safety?.i2c_err }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rails">
|
||||
<RailCard v-for="i in 5" :key="i" :board="b" :rail="i" :selected="sel === i"
|
||||
:rec-state="recStates[i]" @select="sel = i" />
|
||||
</div>
|
||||
|
||||
<HistoryChart :board="b.id" :rail="sel" />
|
||||
|
||||
<div class="two">
|
||||
<div class="card">
|
||||
<div class="ttl">最近事件</div>
|
||||
<el-table :data="events" size="small" max-height="360" empty-text="暂无">
|
||||
<el-table-column label="收到" width="130"><template #default="{ row }">{{ fmtTime(row.received) }}</template></el-table-column>
|
||||
<el-table-column label="路" prop="rail" width="44" />
|
||||
<el-table-column label="事件"><template #default="{ row }"><span :class="{ bad: row.severe }">{{ row.name }}</span></template></el-table-column>
|
||||
<el-table-column label="当时" width="140"><template #default="{ row }">{{ V(row.mv) }} V / {{ mA(row.ua) }} mA</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="ttl">操作记录</div>
|
||||
<el-table :data="audit" size="small" max-height="360" empty-text="暂无">
|
||||
<el-table-column label="时间" width="130"><template #default="{ row }">{{ fmtTime(row.ts) }}</template></el-table-column>
|
||||
<el-table-column label="谁" prop="user" width="110" />
|
||||
<el-table-column label="操作"><template #default="{ row }">#{{ row.rail ?? '' }} {{ row.request.cmd }}{{ row.request.keys ? ' ' + row.request.keys : '' }}</template></el-table-column>
|
||||
<el-table-column label="结果" width="120"><template #default="{ row }"><span :class="{ bad: !row.ok }">{{ rspText(row.response) }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import RailCard from '../components/RailCard.vue'
|
||||
import HistoryChart from '../components/HistoryChart.vue'
|
||||
import { api, rspText } from '../api'
|
||||
import { state, onMessage } from '../store'
|
||||
import { mA, V, C, ago, fmtTime } from '../util'
|
||||
|
||||
const props = defineProps({ id: String })
|
||||
const b = computed(() => state.boards[props.id])
|
||||
const sel = ref(1)
|
||||
const name = ref('')
|
||||
const location = ref('')
|
||||
const fanFloor = ref(0)
|
||||
const events = ref([])
|
||||
const audit = ref([])
|
||||
const recStates = ref({})
|
||||
|
||||
watch(b, v => {
|
||||
if (!v) return
|
||||
if (document.activeElement?.tagName !== 'INPUT') { name.value = v.name; location.value = v.location }
|
||||
fanFloor.value = v.status?.fan?.floor ?? 0
|
||||
}, { immediate: true })
|
||||
|
||||
async function saveMeta() {
|
||||
try { await api.patchBoard(props.id, { name: name.value, location: location.value }) } catch (e) { ElMessage.error(e.message) }
|
||||
}
|
||||
async function setFan(d) {
|
||||
try {
|
||||
const rsp = await api.cmd(props.id, { cmd: 'fan', duty: d })
|
||||
ElMessage({ type: rsp.ok ? 'success' : 'error', message: `风扇:${rspText(rsp)}` })
|
||||
} catch (e) { ElMessage.error(e.message) }
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [e, a, r] = await Promise.all([api.events({ board: props.id, limit: 50 }), api.audit({ board: props.id, limit: 50 }), api.recovery()])
|
||||
events.value = e
|
||||
audit.value = a
|
||||
recStates.value = Object.fromEntries(r.states.filter(s => s.board === props.id).map(s => [s.rail, s.state]))
|
||||
} catch (e) { /* 列表刷新失败不打扰:下一条推送会再试 */ }
|
||||
}
|
||||
|
||||
let timer = null
|
||||
const off = onMessage(m => {
|
||||
if (['events', 'audit', 'recovery'].includes(m.t) && (m.board === props.id || m.row?.board_id === props.id)) {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(refresh, 300)
|
||||
}
|
||||
})
|
||||
onMounted(refresh)
|
||||
onBeforeUnmount(off)
|
||||
watch(() => props.id, refresh)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bd { display: flex; flex-direction: column; gap: 16px; }
|
||||
.head { display: flex; flex-direction: column; gap: 10px; }
|
||||
.names { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.info { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; }
|
||||
.stats > div { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.stats b { font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.rails { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.ttl { font-weight: 600; margin-bottom: 8px; }
|
||||
.bad { color: #dc2626; }
|
||||
@media (max-width: 1200px) { .rails { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 800px) { .rails { grid-template-columns: 1fr 1fr; } .two { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 520px) { .rails { grid-template-columns: 1fr; } .names { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="bar">
|
||||
<b>事件</b>
|
||||
<el-select v-model="board" clearable placeholder="全部板子" size="small" style="width: 200px" @change="load">
|
||||
<el-option v-for="b in boards" :key="b.id" :value="b.id" :label="b.name || b.id" />
|
||||
</el-select>
|
||||
<el-checkbox v-model="severeOnly" size="small">只看严重</el-checkbox>
|
||||
<span class="muted">板子的事件日志(断电、反灌、故障、重启、命令被拒……),按序号增量同步,永久保留。</span>
|
||||
</div>
|
||||
<el-table :data="rows" size="small" empty-text="暂无事件" :row-class-name="r => r.row.severe ? 'sev' : ''">
|
||||
<el-table-column label="服务器收到" width="140"><template #default="{ row }">{{ fmtTime(row.received) }}</template></el-table-column>
|
||||
<el-table-column label="板子" width="150"><template #default="{ row }">{{ state.boards[row.board_id]?.name || row.board_id }}</template></el-table-column>
|
||||
<el-table-column label="序号" prop="seq" width="70" />
|
||||
<el-table-column label="路" width="50"><template #default="{ row }">{{ row.rail || '整板' }}</template></el-table-column>
|
||||
<el-table-column label="事件" min-width="180"><template #default="{ row }">{{ row.name }}</template></el-table-column>
|
||||
<el-table-column label="当时 V / I" width="150"><template #default="{ row }">{{ V(row.mv) }} V / {{ mA(row.ua) }} mA</template></el-table-column>
|
||||
<el-table-column label="板子时刻" width="120"><template #default="{ row }">上电后 {{ (row.t_ms / 1000).toFixed(1) }} s</template></el-table-column>
|
||||
</el-table>
|
||||
<div class="more"><el-button size="small" :disabled="!more" @click="load(true)">更早的</el-button></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { state, onMessage } from '../store'
|
||||
import { mA, V, fmtTime } from '../util'
|
||||
|
||||
const boards = computed(() => Object.values(state.boards))
|
||||
const board = ref('')
|
||||
const severeOnly = ref(false)
|
||||
const all = ref([])
|
||||
const more = ref(true)
|
||||
const rows = computed(() => (severeOnly.value ? all.value.filter(r => r.severe) : all.value))
|
||||
|
||||
async function load(older = false) {
|
||||
const q = { limit: 200 }
|
||||
if (board.value) q.board = board.value
|
||||
if (older === true && all.value.length) q.before = all.value[all.value.length - 1].received
|
||||
const r = await api.events(q)
|
||||
all.value = older === true ? all.value.concat(r) : r
|
||||
more.value = r.length === 200
|
||||
}
|
||||
const off = onMessage(m => { if (m.t === 'events') load() })
|
||||
onMounted(load)
|
||||
onBeforeUnmount(off)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.more { margin-top: 10px; text-align: center; }
|
||||
:deep(.sev) { color: #b91c1c; }
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="ov">
|
||||
<section class="boards">
|
||||
<el-empty v-if="!boards.length" description="还没有板子上线。新板子经 USB 配好 wifi / mqtt 之后会自动出现在这里。" />
|
||||
<router-link v-for="b in boards" :key="b.id" :to="`/board/${b.id}`" class="card board">
|
||||
<div class="bh">
|
||||
<div>
|
||||
<div class="bname">{{ b.name || b.id }}</div>
|
||||
<div class="muted">{{ b.location || '未填位置' }} · {{ b.fw }}</div>
|
||||
</div>
|
||||
<el-tag v-if="!b.online || b.stale" type="info" size="small">通信中断 · {{ ago(b.last_tele) }}</el-tag>
|
||||
<el-tag v-else type="success" size="small">在线</el-tag>
|
||||
</div>
|
||||
<div class="slots">
|
||||
<div v-for="i in 5" :key="i" class="slot" :style="{ borderColor: color(b, i) }">
|
||||
<div class="sh">
|
||||
<span class="sn">{{ i }}</span>
|
||||
<span class="lbl">{{ phone(b, i).label }}</span>
|
||||
<el-icon v-if="phone(b, i).auto_recover" title="已开自动恢复"><Refresh /></el-icon>
|
||||
</div>
|
||||
<div class="val" :style="{ color: color(b, i) }">{{ health(b, i).text }}</div>
|
||||
<div class="num" v-if="rail(b, i)?.pr">{{ mA(rail(b, i)?.ua) }}<small> mA</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bf muted" v-if="b.status">
|
||||
输入 {{ V(b.status.in?.mv) }} V / {{ mA(b.status.in?.ua) }} mA ·
|
||||
温度 {{ temps(b) }} ·
|
||||
风扇 {{ b.status.fan?.duty / 10 }}%
|
||||
<span v-if="b.status.fan?.stall?.some(x => x)" class="bad"> · 风扇停转</span>
|
||||
<span v-if="b.status.derate" class="bad"> · 过温</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</section>
|
||||
|
||||
<aside class="card feed">
|
||||
<div class="fh">实时动态</div>
|
||||
<div v-if="!state.feed.length" class="muted">暂无。事件、操作、自动恢复都会出现在这里。</div>
|
||||
<div v-for="(f, k) in state.feed" :key="k" class="fi">
|
||||
<span class="muted">{{ fmtTime(f.ts) }}</span>
|
||||
<span class="who">{{ boardName(f.board) }}</span>
|
||||
<template v-if="f.kind === 'event'">
|
||||
<span :class="{ bad: SEVERE.has(f.ev[3]) }">{{ f.ev[2] ? `#${f.ev[2]} ` : '' }}{{ evName(f.ev[3]) }}</span>
|
||||
</template>
|
||||
<template v-else-if="f.kind === 'audit'">
|
||||
<span>{{ f.row.user }}:#{{ f.row.rail ?? '' }} {{ f.row.request.cmd }} → {{ rspText(f.row.response) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="warn">自动恢复 #{{ f.row.rail }}:{{ RECOVERY_ZH[f.row.state] }}({{ REASON_ZH[f.row.reason] || f.row.reason }})</span>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { state } from '../store'
|
||||
import { rspText } from '../api'
|
||||
import { mA, V, C, ago, fmtTime, railHealth, LEVEL_COLOR, RECOVERY_ZH, REASON_ZH } from '../util'
|
||||
|
||||
// 与 backend/prrack/protocol.py 的 EVENT_TYPES / EVENT_SEVERE 同一张表(数字来自固件 evlog.h)
|
||||
const EV = ['', '板子上电', '事件丢失', '过压关路', '欠压关路', '反灌断 VBUS', '顶高断 VBUS', 'eFuse 故障',
|
||||
'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率']
|
||||
const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14])
|
||||
const evName = t => EV[t] || `事件 ${t}`
|
||||
|
||||
const boards = computed(() => Object.values(state.boards).sort((a, b) =>
|
||||
(a.location + a.name + a.id).localeCompare(b.location + b.name + b.id)))
|
||||
const rail = (b, i) => b.status?.rails?.[i - 1]
|
||||
const phone = (b, i) => b.phones?.find(p => p.rail === i) || {}
|
||||
const health = (b, i) => railHealth(b, rail(b, i))
|
||||
const color = (b, i) => LEVEL_COLOR[health(b, i).level]
|
||||
const temps = b => (b.status.temp || []).map(t => (t == null ? '—' : C(t) + '°')).join(' / ')
|
||||
const boardName = id => state.boards[id]?.name || id
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ov { display: grid; grid-template-columns: 1fr 320px; gap: 16px; align-items: start; }
|
||||
.boards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); gap: 16px; }
|
||||
.board { display: block; color: inherit; text-decoration: none; transition: box-shadow .15s; }
|
||||
.board:hover { box-shadow: 0 4px 16px rgba(15, 23, 42, .08); }
|
||||
.bh { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||||
.bname { font-weight: 600; font-size: 16px; }
|
||||
.slots { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
|
||||
.slot { border: 2px solid; border-radius: 8px; padding: 8px 6px; min-height: 86px; }
|
||||
.sh { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
.sn { font-weight: 700; color: #0f172a; }
|
||||
.lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.val { font-size: 13px; font-weight: 600; margin-top: 6px; }
|
||||
.num { font-size: 18px; font-weight: 600; margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||
.num small { font-size: 11px; color: var(--muted); font-weight: 400; }
|
||||
.bf { margin-top: 10px; }
|
||||
.bad { color: #dc2626; } .warn { color: #d97706; }
|
||||
.feed { position: sticky; top: 68px; max-height: calc(100vh - 90px); overflow: auto; }
|
||||
.fh { font-weight: 600; margin-bottom: 8px; }
|
||||
.fi { font-size: 13px; padding: 6px 0; border-bottom: 1px dashed var(--line); display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.who { color: #334155; font-weight: 500; }
|
||||
@media (max-width: 1000px) {
|
||||
.ov { grid-template-columns: 1fr; }
|
||||
.feed { position: static; max-height: 360px; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.boards { grid-template-columns: 1fr; }
|
||||
.slots { gap: 4px; }
|
||||
.slot { padding: 5px 3px; min-height: 72px; }
|
||||
.val { font-size: 12px; }
|
||||
.num { font-size: 14px; }
|
||||
.num small { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="rc">
|
||||
<el-alert type="warning" :closable="false" show-icon
|
||||
title="判据只看电流(服务器够不着手机的 adb);阈值还没有实测依据。自动恢复默认关,在板子页逐台打开。" />
|
||||
|
||||
<div class="card">
|
||||
<div class="ttl">判据参数(全局)</div>
|
||||
<el-form :model="p" label-width="170px" size="small" class="form">
|
||||
<el-form-item label="判定窗口"><el-input-number v-model="p.window_s" :min="30" :max="3600" /> 秒</el-form-item>
|
||||
<el-form-item label="平线:电流摆幅小于"><el-input-number v-model="flatMa" :min="0.1" :max="200" :step="0.5" /> mA</el-form-item>
|
||||
<el-form-item label="过低:电流一直低于"><el-input-number v-model="lowMa" :min="1" :max="500" /> mA</el-form-item>
|
||||
<el-form-item label="动作后先等"><el-input-number v-model="p.settle_s" :min="10" :max="1800" /> 秒(不看这段电流)</el-form-item>
|
||||
<el-form-item label="每台每小时最多"><el-input-number v-model="p.max_per_hour" :min="1" :max="10" /> 次(超了就停手等人)</el-form-item>
|
||||
<el-form-item label="一级:长按电源"><el-input-number v-model="p.soft_ms" :min="3000" :max="15000" :step="500" /> ms</el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
|
||||
</el-form>
|
||||
<div class="muted">流程:判定 → 长按电源 → 等待 → 再观察一个窗口;仍然判定 → 断电重启;再判定 → 停手(需人工复位)。</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ttl">各台状态(只列开过自动恢复的)</div>
|
||||
<el-table :data="states" size="small" empty-text="还没有手机开自动恢复">
|
||||
<el-table-column label="板子" width="160"><template #default="{ row }">{{ bn(row.board) }}</template></el-table-column>
|
||||
<el-table-column label="路" prop="rail" width="50" />
|
||||
<el-table-column label="开关" width="70"><template #default="{ row }">{{ row.enabled ? '开' : '关' }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><span :class="{ bad: row.state === 'gave_up' }">{{ RECOVERY_ZH[row.state] }}</span></template></el-table-column>
|
||||
<el-table-column label="原因"><template #default="{ row }">{{ REASON_ZH[row.reason] || row.reason }}</template></el-table-column>
|
||||
<el-table-column label="近 1 小时动作" prop="attempts_1h" width="110" />
|
||||
<el-table-column width="90"><template #default="{ row }">
|
||||
<el-button size="small" :disabled="row.state === 'idle'" @click="reset(row)">复位</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ttl">恢复记录</div>
|
||||
<el-table :data="log" size="small" empty-text="暂无">
|
||||
<el-table-column label="时间" width="140"><template #default="{ row }">{{ fmtTime(row.ts) }}</template></el-table-column>
|
||||
<el-table-column label="板子" width="160"><template #default="{ row }">{{ bn(row.board_id) }}</template></el-table-column>
|
||||
<el-table-column label="路" prop="rail" width="50" />
|
||||
<el-table-column label="进入状态" width="150"><template #default="{ row }">{{ RECOVERY_ZH[row.state] }}</template></el-table-column>
|
||||
<el-table-column label="原因"><template #default="{ row }">{{ REASON_ZH[row.reason] || row.reason }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import { state, onMessage } from '../store'
|
||||
import { fmtTime, RECOVERY_ZH, REASON_ZH } from '../util'
|
||||
|
||||
const p = reactive({ window_s: 300, flat_ua: 5000, low_ua: 30000, settle_s: 120, max_per_hour: 3, soft_ms: 10000 })
|
||||
const flatMa = computed({ get: () => p.flat_ua / 1000, set: v => { p.flat_ua = Math.round(v * 1000) } })
|
||||
const lowMa = computed({ get: () => p.low_ua / 1000, set: v => { p.low_ua = Math.round(v * 1000) } })
|
||||
const states = ref([])
|
||||
const log = ref([])
|
||||
const bn = id => state.boards[id]?.name || id
|
||||
|
||||
async function load() {
|
||||
const r = await api.recovery()
|
||||
Object.assign(p, r.params)
|
||||
states.value = r.states
|
||||
log.value = r.log
|
||||
}
|
||||
async function save() {
|
||||
try { Object.assign(p, await api.setRecoveryParams({ ...p })); ElMessage.success('已保存') } catch (e) { ElMessage.error(e.message) }
|
||||
}
|
||||
async function reset(row) {
|
||||
await api.resetRecovery(row.board, row.rail)
|
||||
load()
|
||||
}
|
||||
const off = onMessage(m => { if (m.t === 'recovery') load() })
|
||||
onMounted(load)
|
||||
onBeforeUnmount(off)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rc { display: flex; flex-direction: column; gap: 16px; }
|
||||
.ttl { font-weight: 600; margin-bottom: 10px; }
|
||||
.form { max-width: 560px; }
|
||||
.bad { color: #dc2626; }
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
// 全站唯一的实时状态:WebSocket 推什么,这里就改什么;页面只读它。
|
||||
// 断线自动重连(指数退避到 15 s);重连后服务器会先发一条 hello 带全量,不用补拉。
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const state = reactive({
|
||||
boards: {}, // id → 快照(含 status / phones)
|
||||
user: '',
|
||||
mqtt: false, // 后端 ↔ broker
|
||||
ws: false, // 浏览器 ↔ 后端
|
||||
lastMsg: 0,
|
||||
feed: [], // 最近的事件 / 审计 / 恢复,给总览页右侧的动态流
|
||||
})
|
||||
|
||||
const listeners = new Set()
|
||||
export function onMessage(fn) { listeners.add(fn); return () => listeners.delete(fn) }
|
||||
|
||||
function pushFeed(item) {
|
||||
state.feed.unshift(item)
|
||||
if (state.feed.length > 100) state.feed.length = 100
|
||||
}
|
||||
|
||||
function handle(m) {
|
||||
state.lastMsg = Date.now()
|
||||
switch (m.t) {
|
||||
case 'hello':
|
||||
state.user = m.user
|
||||
state.mqtt = m.mqtt
|
||||
state.boards = Object.fromEntries(m.boards.map(b => [b.id, b]))
|
||||
break
|
||||
case 'mqtt':
|
||||
state.mqtt = m.up
|
||||
break
|
||||
case 'board':
|
||||
state.boards[m.board.id] = m.board
|
||||
break
|
||||
case 'tele': {
|
||||
const b = state.boards[m.board]
|
||||
if (b) { b.status = m.status; b.last_tele = m.ts; b.stale = false; b.online = true }
|
||||
break
|
||||
}
|
||||
case 'events':
|
||||
for (const e of m.ev) pushFeed({ kind: 'event', board: m.board, ts: m.received, ev: e })
|
||||
break
|
||||
case 'audit':
|
||||
pushFeed({ kind: 'audit', board: m.row.board_id, ts: m.row.ts, row: m.row })
|
||||
break
|
||||
case 'recovery':
|
||||
pushFeed({ kind: 'recovery', board: m.row.board_id, ts: m.row.ts, row: m.row })
|
||||
break
|
||||
}
|
||||
for (const fn of listeners) fn(m)
|
||||
}
|
||||
|
||||
let delay = 1000
|
||||
export function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${proto}://${location.host}/api/ws`)
|
||||
ws.onopen = () => { state.ws = true; delay = 1000 }
|
||||
ws.onmessage = ev => handle(JSON.parse(ev.data))
|
||||
ws.onclose = () => {
|
||||
state.ws = false
|
||||
setTimeout(connect, delay)
|
||||
delay = Math.min(delay * 2, 15000)
|
||||
}
|
||||
}
|
||||
|
||||
// 遥测超过 10 s 没来 = 通信中断(与后端 STALE_MS 一致)。每秒重算一次。
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const b of Object.values(state.boards)) {
|
||||
if (b.last_tele && now - b.last_tele > 10000) b.stale = true
|
||||
}
|
||||
}, 1000)
|
||||
@@ -0,0 +1,54 @@
|
||||
// 显示用的小工具:单位换算、每一路的健康状态。
|
||||
|
||||
export const mA = ua => (ua == null ? '—' : (ua / 1000).toFixed(ua >= 100000 || ua <= -100000 ? 0 : 1))
|
||||
export const V = mv => (mv == null ? '—' : (mv / 1000).toFixed(3))
|
||||
export const C = mc => (mc == null ? '—' : (mc / 1000).toFixed(1))
|
||||
|
||||
export function ago(ts) {
|
||||
if (!ts) return '从未'
|
||||
const s = Math.round((Date.now() - ts) / 1000)
|
||||
if (s < 60) return `${s} 秒前`
|
||||
if (s < 3600) return `${Math.round(s / 60)} 分钟前`
|
||||
if (s < 86400) return `${Math.round(s / 3600)} 小时前`
|
||||
return `${Math.round(s / 86400)} 天前`
|
||||
}
|
||||
|
||||
export function fmtTime(ts) {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts)
|
||||
const p = n => String(n).padStart(2, '0')
|
||||
return `${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
// 执行器状态(firmware t_actuator_state_str)
|
||||
export const ACT_ZH = {
|
||||
idle: '空闲', 'hr-off': '断电中', 'hr-ramp': '上电中', 'hr-pg': '等 PG',
|
||||
'key-hold': '按键中', 'edl-short': '进 EDL', 'edl-enum': '等枚举', cleanup: '收尾',
|
||||
}
|
||||
|
||||
// 一路的健康状态 → { level, text }。level 决定颜色:
|
||||
// ok 绿 / busy 蓝 / warn 橙 / bad 红 / off 深灰 / empty 浅灰 / lost 灰(通信中断)
|
||||
export function railHealth(board, r) {
|
||||
if (!board || board.stale || !board.online) return { level: 'lost', text: '通信中断' }
|
||||
if (!r) return { level: 'lost', text: '无数据' }
|
||||
if (!r.pr) return { level: 'empty', text: '空槽' }
|
||||
if (r.st && r.st !== 'idle') return { level: 'busy', text: ACT_ZH[r.st] || r.st }
|
||||
if (!r.en) return { level: 'off', text: '已断电' }
|
||||
if (r.flt) return { level: 'bad', text: 'eFuse 故障' }
|
||||
if (r.pg === false) return { level: 'bad', text: 'PG 丢失' }
|
||||
if (r.mv == null || r.ua == null) return { level: 'warn', text: '读不到' }
|
||||
return { level: 'ok', text: '运行' }
|
||||
}
|
||||
|
||||
export const LEVEL_COLOR = {
|
||||
ok: '#16a34a', busy: '#2563eb', warn: '#d97706', bad: '#dc2626',
|
||||
off: '#475569', empty: '#cbd5e1', lost: '#94a3b8',
|
||||
}
|
||||
|
||||
export const RECOVERY_ZH = {
|
||||
idle: '正常', soft: '已长按电源', hard: '已断电重启', gave_up: '已放弃(需人工)',
|
||||
}
|
||||
export const REASON_ZH = {
|
||||
flat: '电流平直(疑似卡死)', low: '电流过低(疑似关机)', recovered: '已恢复',
|
||||
rate_limit: '一小时内动作过多', manual_reset: '人工复位',
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// 开发:`python -m prrack.main --sim 2` 起后端(127.0.0.1:7764),这里把 /api 与 WS 反代过去。
|
||||
// 生产:dist 由后端自己托管,同源,不需要代理。
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/ws': { target: 'ws://127.0.0.1:7764', ws: true },
|
||||
'/api': 'http://127.0.0.1:7764',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: { element: ['element-plus'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
在新工单中引用
屏蔽一个用户