机架管理前后端(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]>
这个提交包含在:
zhou yang
2026-09-27 12:33:28 +08:00
共同撰写人 Claude Opus 5.5
父节点 a32fa06c43
当前提交 26c04ce878
共修改 34 个文件,包含 4031 行新增和 0 行删除
+6
查看文件
@@ -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),手机首屏偏慢;改成按需引入可以再砍一半。
- 没有细分权限:门户授权了这个应用的人都能操作;审计记着是谁。
查看文件
+194
查看文件
@@ -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
+137
查看文件
@@ -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())
+181
查看文件
@@ -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]}
+262
查看文件
@@ -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("降采样 / 清理失败")
+111
查看文件
@@ -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()
+131
查看文件
@@ -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)
+221
查看文件
@@ -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}})
+190
查看文件
@@ -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"}
+8
查看文件
@@ -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 线上踩过)
+131
查看文件
@@ -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()
+302
查看文件
@@ -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")]
+8
查看文件
@@ -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
+40
查看文件
@@ -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
+23
查看文件
@@ -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
+12
查看文件
@@ -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>
+22
查看文件
@@ -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
查看文件
文件差异内容过多而无法显示 加载差异
+52
查看文件
@@ -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>
+47
查看文件
@@ -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>
+146
查看文件
@@ -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">&nbsp;</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>
+27
查看文件
@@ -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')
+38
查看文件
@@ -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>
+132
查看文件
@@ -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>
+54
查看文件
@@ -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>
+111
查看文件
@@ -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>
+84
查看文件
@@ -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>
+73
查看文件
@@ -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)
+54
查看文件
@@ -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: '人工复位',
}
+22
查看文件
@@ -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'] },
},
},
},
})