From 26c04ce8788f7ad5a4598f7ba7813e767a7650d5 Mon Sep 17 00:00:00 2001 From: zhou yang Date: Sun, 27 Sep 2026 12:33:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=BA=E6=9E=B6=E7=AE=A1=E7=90=86=E5=89=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=88rack/=EF=BC=89=EF=BC=9AFastAPI=20+?= =?UTF-8?q?=20SQLite=20+=20Vue3=EF=BC=8C=E7=BB=8F=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=97=A8=E6=88=B7=E4=B8=8A=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 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 --- PROGRESS.md | 8 + rack/.gitignore | 6 + rack/README.md | 66 ++ rack/backend/prrack/__init__.py | 0 rack/backend/prrack/api.py | 194 +++ rack/backend/prrack/bus.py | 137 +++ rack/backend/prrack/db.py | 181 +++ rack/backend/prrack/hub.py | 262 +++++ rack/backend/prrack/main.py | 111 ++ rack/backend/prrack/protocol.py | 131 +++ rack/backend/prrack/recovery.py | 221 ++++ rack/backend/prrack/simboard.py | 190 +++ rack/backend/requirements.txt | 8 + rack/backend/tests/test_api.py | 131 +++ rack/backend/tests/test_core.py | 302 +++++ rack/deploy/backup.sh | 8 + rack/deploy/deploy.sh | 40 + rack/deploy/phonerail-rack.service | 23 + rack/frontend/index.html | 12 + rack/frontend/package.json | 22 + rack/frontend/pnpm-lock.yaml | 1044 +++++++++++++++++ rack/frontend/src/App.vue | 52 + rack/frontend/src/api.js | 47 + rack/frontend/src/components/HistoryChart.vue | 94 ++ rack/frontend/src/components/RailCard.vue | 146 +++ rack/frontend/src/main.js | 27 + rack/frontend/src/pages/Audit.vue | 38 + rack/frontend/src/pages/Board.vue | 132 +++ rack/frontend/src/pages/Events.vue | 54 + rack/frontend/src/pages/Overview.vue | 111 ++ rack/frontend/src/pages/Recovery.vue | 84 ++ rack/frontend/src/store.js | 73 ++ rack/frontend/src/util.js | 54 + rack/frontend/vite.config.js | 22 + 34 files changed, 4031 insertions(+) create mode 100644 rack/.gitignore create mode 100644 rack/README.md create mode 100644 rack/backend/prrack/__init__.py create mode 100644 rack/backend/prrack/api.py create mode 100644 rack/backend/prrack/bus.py create mode 100644 rack/backend/prrack/db.py create mode 100644 rack/backend/prrack/hub.py create mode 100644 rack/backend/prrack/main.py create mode 100644 rack/backend/prrack/protocol.py create mode 100644 rack/backend/prrack/recovery.py create mode 100644 rack/backend/prrack/simboard.py create mode 100644 rack/backend/requirements.txt create mode 100644 rack/backend/tests/test_api.py create mode 100644 rack/backend/tests/test_core.py create mode 100644 rack/deploy/backup.sh create mode 100644 rack/deploy/deploy.sh create mode 100644 rack/deploy/phonerail-rack.service create mode 100644 rack/frontend/index.html create mode 100644 rack/frontend/package.json create mode 100644 rack/frontend/pnpm-lock.yaml create mode 100644 rack/frontend/src/App.vue create mode 100644 rack/frontend/src/api.js create mode 100644 rack/frontend/src/components/HistoryChart.vue create mode 100644 rack/frontend/src/components/RailCard.vue create mode 100644 rack/frontend/src/main.js create mode 100644 rack/frontend/src/pages/Audit.vue create mode 100644 rack/frontend/src/pages/Board.vue create mode 100644 rack/frontend/src/pages/Events.vue create mode 100644 rack/frontend/src/pages/Overview.vue create mode 100644 rack/frontend/src/pages/Recovery.vue create mode 100644 rack/frontend/src/store.js create mode 100644 rack/frontend/src/util.js create mode 100644 rack/frontend/vite.config.js diff --git a/PROGRESS.md b/PROGRESS.md index ab636c8..02849d6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,5 +1,13 @@ # 进度与交接 +## ⭐ 2026-09-27 下午:机架管理全链路打通(固件 A12~A14 + 服务器前后端) + +- **固件**(`firmware/HANDOFF.md`):A12 命令处理器、A13 USB 分帧、A14 WiFi/MQTT + C1 配网,板 B(`b81f3fc9c8bc`,COM5)已烧 0.1.0-a14、真连网。 +- **服务器**(`rack/README.md`):FastAPI + SQLite + Vue3,systemd `phonerail-rack` 只绑 127.0.0.1:7764,经门户 key `rack` 访问; + 板 B 已自动出现、遥测入库、事件 27 条补齐。部署 `bash rack/deploy/deploy.sh`(服务器上 Python 3.8 跑完 25 例才换上去)。 +- **broker**:复用 evse 的 mosquitto 8883 TLS;顺手修了 evse 8883 缺 cafile ⇒ 公网明文的漏洞(备份 `.bak-20260927-*`)。 +- 还没做:固件 A15 / A16;自动恢复阈值待刀片 + 手机上架后实测;固件分区只剩 12%(C2 前重排)。 + ## 固定执行约束(2026-09-10 用户明确要求) - **以后所有 PCB 布线运算都用 `10.5.5.13` 服务器跑。** 本机仅做快照、结果校验、 diff --git a/rack/.gitignore b/rack/.gitignore new file mode 100644 index 0000000..113b1ac --- /dev/null +++ b/rack/.gitignore @@ -0,0 +1,6 @@ +frontend/node_modules/ +frontend/dist/ +backend/.pytest_cache/ +*.db +*.db-wal +*.db-shm diff --git a/rack/README.md b/rack/README.md new file mode 100644 index 0000000..7102339 --- /dev/null +++ b/rack/README.md @@ -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),手机首屏偏慢;改成按需引入可以再砍一半。 +- 没有细分权限:门户授权了这个应用的人都能操作;审计记着是谁。 diff --git a/rack/backend/prrack/__init__.py b/rack/backend/prrack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rack/backend/prrack/api.py b/rack/backend/prrack/api.py new file mode 100644 index 0000000..05f9de4 --- /dev/null +++ b/rack/backend/prrack/api.py @@ -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 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()) diff --git a/rack/backend/prrack/db.py b/rack/backend/prrack/db.py new file mode 100644 index 0000000..64379fa --- /dev/null +++ b/rack/backend/prrack/db.py @@ -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=? AND ts 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("降采样 / 清理失败") diff --git a/rack/backend/prrack/main.py b/rack/backend/prrack/main.py new file mode 100644 index 0000000..cbfcfda --- /dev/null +++ b/rack/backend/prrack/main.py @@ -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() diff --git a/rack/backend/prrack/protocol.py b/rack/backend/prrack/protocol.py new file mode 100644 index 0000000..1702a97 --- /dev/null +++ b/rack/backend/prrack/protocol.py @@ -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) diff --git a/rack/backend/prrack/recovery.py b/rack/backend/prrack/recovery.py new file mode 100644 index 0000000..1850234 --- /dev/null +++ b/rack/backend/prrack/recovery.py @@ -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}}) + diff --git a/rack/backend/prrack/simboard.py b/rack/backend/prrack/simboard.py new file mode 100644 index 0000000..f37aa31 --- /dev/null +++ b/rack/backend/prrack/simboard.py @@ -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"} diff --git a/rack/backend/requirements.txt b/rack/backend/requirements.txt new file mode 100644 index 0000000..27ddf1c --- /dev/null +++ b/rack/backend/requirements.txt @@ -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 线上踩过) diff --git a/rack/backend/tests/test_api.py b/rack/backend/tests/test_api.py new file mode 100644 index 0000000..d533f20 --- /dev/null +++ b/rack/backend/tests/test_api.py @@ -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("rack", 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() diff --git a/rack/backend/tests/test_core.py b/rack/backend/tests/test_core.py new file mode 100644 index 0000000..89644b1 --- /dev/null +++ b/rack/backend/tests/test_core.py @@ -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")] diff --git a/rack/deploy/backup.sh b/rack/deploy/backup.sh new file mode 100644 index 0000000..986be89 --- /dev/null +++ b/rack/deploy/backup.sh @@ -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 diff --git a/rack/deploy/deploy.sh b/rack/deploy/deploy.sh new file mode 100644 index 0000000..b2be3f2 --- /dev/null +++ b/rack/deploy/deploy.sh @@ -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 </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 diff --git a/rack/deploy/phonerail-rack.service b/rack/deploy/phonerail-rack.service new file mode 100644 index 0000000..4134621 --- /dev/null +++ b/rack/deploy/phonerail-rack.service @@ -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 diff --git a/rack/frontend/index.html b/rack/frontend/index.html new file mode 100644 index 0000000..2cb8689 --- /dev/null +++ b/rack/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + PhoneRail 机架 + + +
+ + + diff --git a/rack/frontend/package.json b/rack/frontend/package.json new file mode 100644 index 0000000..33633d6 --- /dev/null +++ b/rack/frontend/package.json @@ -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" + } +} diff --git a/rack/frontend/pnpm-lock.yaml b/rack/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..e40fe56 --- /dev/null +++ b/rack/frontend/pnpm-lock.yaml @@ -0,0 +1,1044 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@element-plus/icons-vue': + specifier: ^2.3.1 + version: 2.3.2(vue@3.5.43) + echarts: + specifier: ^5.6.0 + version: 5.6.0 + element-plus: + specifier: ^2.9.0 + version: 2.14.6(vue@3.5.43) + vue: + specifier: ^3.5.0 + version: 3.5.43 + vue-router: + specifier: ^4.5.0 + version: 4.6.4(vue@3.5.43) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.2.0 + version: 5.2.4(vite@6.4.3)(vue@3.5.43) + vite: + specifier: ^6.0.0 + version: 6.4.3 + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@ctrl/tinycolor@4.2.1': + resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} + engines: {node: '>=14'} + + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} + peerDependencies: + vue: ^3.2.0 + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.63.5': + resolution: {integrity: sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.5': + resolution: {integrity: sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.5': + resolution: {integrity: sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.5': + resolution: {integrity: sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.5': + resolution: {integrity: sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.5': + resolution: {integrity: sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.5': + resolution: {integrity: sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.5': + resolution: {integrity: sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.5': + resolution: {integrity: sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.5': + resolution: {integrity: sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.5': + resolution: {integrity: sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.5': + resolution: {integrity: sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.5': + resolution: {integrity: sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.5': + resolution: {integrity: sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.5': + resolution: {integrity: sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.5': + resolution: {integrity: sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.5': + resolution: {integrity: sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.5': + resolution: {integrity: sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.5': + resolution: {integrity: sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.5': + resolution: {integrity: sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.5': + resolution: {integrity: sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.5': + resolution: {integrity: sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.5': + resolution: {integrity: sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.5': + resolution: {integrity: sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.5': + resolution: {integrity: sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==} + cpu: [x64] + os: [win32] + + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} + + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} + + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} + + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + + '@vueuse/core@15.0.0': + resolution: {integrity: sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng==} + engines: {node: '>=22'} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@15.0.0': + resolution: {integrity: sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg==} + engines: {node: '>=22'} + + '@vueuse/shared@15.0.0': + resolution: {integrity: sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA==} + engines: {node: '>=22'} + peerDependencies: + vue: ^3.5.0 + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + + element-plus@2.14.6: + resolution: {integrity: sha512-GH0fMoMgVEKkHimoaKvg5B00NmfPbQ+qxX6/JrO7F+dLAC3atrZr2Y2MoccHMxS22M7QE12/s0DG0Zs49iwJ/A==} + peerDependencies: + vue: ^3.3.7 + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.63.5: + resolution: {integrity: sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vue-component-type-helpers@3.3.11: + resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==} + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@ctrl/tinycolor@4.2.1': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.43)': + dependencies: + vue: 3.5.43 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.63.5': + optional: true + + '@rollup/rollup-android-arm64@4.63.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.5': + optional: true + + '@rollup/rollup-darwin-x64@4.63.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.5': + optional: true + + '@sxzz/popperjs-es@2.11.8': {} + + '@types/estree@1.0.9': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.25 + + '@types/lodash@4.17.25': {} + + '@types/web-bluetooth@0.0.21': {} + + '@vitejs/plugin-vue@5.2.4(vite@6.4.3)(vue@3.5.43)': + dependencies: + vite: 6.4.3 + vue: 3.5.43 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/devtools-api@6.6.4': {} + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + '@vueuse/core@15.0.0(vue@3.5.43)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 15.0.0 + '@vueuse/shared': 15.0.0(vue@3.5.43) + vue: 3.5.43 + + '@vueuse/metadata@15.0.0': {} + + '@vueuse/shared@15.0.0(vue@3.5.43)': + dependencies: + vue: 3.5.43 + + async-validator@4.2.5: {} + + csstype@3.2.3: {} + + dayjs@1.11.23: {} + + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + + element-plus@2.14.6(vue@3.5.43): + dependencies: + '@ctrl/tinycolor': 4.2.1 + '@element-plus/icons-vue': 2.3.2(vue@3.5.43) + '@floating-ui/dom': 1.8.0 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.25 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 15.0.0(vue@3.5.43) + async-validator: 4.2.5 + dayjs: 1.11.23 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.43 + vue-component-type-helpers: 3.3.11 + + entities@7.0.1: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + estree-walker@2.0.2: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + lodash-es@4.18.1: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.18.1 + lodash-es: 4.18.1 + + lodash@4.18.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + memoize-one@6.0.0: {} + + nanoid@3.3.19: {} + + normalize-wheel-es@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.63.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.5 + '@rollup/rollup-android-arm64': 4.63.5 + '@rollup/rollup-darwin-arm64': 4.63.5 + '@rollup/rollup-darwin-x64': 4.63.5 + '@rollup/rollup-freebsd-arm64': 4.63.5 + '@rollup/rollup-freebsd-x64': 4.63.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.5 + '@rollup/rollup-linux-arm-musleabihf': 4.63.5 + '@rollup/rollup-linux-arm64-gnu': 4.63.5 + '@rollup/rollup-linux-arm64-musl': 4.63.5 + '@rollup/rollup-linux-loong64-gnu': 4.63.5 + '@rollup/rollup-linux-loong64-musl': 4.63.5 + '@rollup/rollup-linux-ppc64-gnu': 4.63.5 + '@rollup/rollup-linux-ppc64-musl': 4.63.5 + '@rollup/rollup-linux-riscv64-gnu': 4.63.5 + '@rollup/rollup-linux-riscv64-musl': 4.63.5 + '@rollup/rollup-linux-s390x-gnu': 4.63.5 + '@rollup/rollup-linux-x64-gnu': 4.63.5 + '@rollup/rollup-linux-x64-musl': 4.63.5 + '@rollup/rollup-openbsd-x64': 4.63.5 + '@rollup/rollup-openharmony-arm64': 4.63.5 + '@rollup/rollup-win32-arm64-msvc': 4.63.5 + '@rollup/rollup-win32-ia32-msvc': 4.63.5 + '@rollup/rollup-win32-x64-gnu': 4.63.5 + '@rollup/rollup-win32-x64-msvc': 4.63.5 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tslib@2.3.0: {} + + vite@6.4.3: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.5 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vue-component-type-helpers@3.3.11: {} + + vue-router@4.6.4(vue@3.5.43): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.43 + + vue@3.5.43: + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + + zrender@5.6.1: + dependencies: + tslib: 2.3.0 diff --git a/rack/frontend/src/App.vue b/rack/frontend/src/App.vue new file mode 100644 index 0000000..79c4645 --- /dev/null +++ b/rack/frontend/src/App.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/rack/frontend/src/api.js b/rack/frontend/src/api.js new file mode 100644 index 0000000..0c70b9a --- /dev/null +++ b/rack/frontend/src/api.js @@ -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 +} diff --git a/rack/frontend/src/components/HistoryChart.vue b/rack/frontend/src/components/HistoryChart.vue new file mode 100644 index 0000000..488c7c8 --- /dev/null +++ b/rack/frontend/src/components/HistoryChart.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/rack/frontend/src/components/RailCard.vue b/rack/frontend/src/components/RailCard.vue new file mode 100644 index 0000000..13cba7e --- /dev/null +++ b/rack/frontend/src/components/RailCard.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/rack/frontend/src/main.js b/rack/frontend/src/main.js new file mode 100644 index 0000000..003a913 --- /dev/null +++ b/rack/frontend/src/main.js @@ -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') diff --git a/rack/frontend/src/pages/Audit.vue b/rack/frontend/src/pages/Audit.vue new file mode 100644 index 0000000..f2738f3 --- /dev/null +++ b/rack/frontend/src/pages/Audit.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/rack/frontend/src/pages/Board.vue b/rack/frontend/src/pages/Board.vue new file mode 100644 index 0000000..2e34caf --- /dev/null +++ b/rack/frontend/src/pages/Board.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/rack/frontend/src/pages/Events.vue b/rack/frontend/src/pages/Events.vue new file mode 100644 index 0000000..3820b22 --- /dev/null +++ b/rack/frontend/src/pages/Events.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/rack/frontend/src/pages/Overview.vue b/rack/frontend/src/pages/Overview.vue new file mode 100644 index 0000000..e095940 --- /dev/null +++ b/rack/frontend/src/pages/Overview.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/rack/frontend/src/pages/Recovery.vue b/rack/frontend/src/pages/Recovery.vue new file mode 100644 index 0000000..224de67 --- /dev/null +++ b/rack/frontend/src/pages/Recovery.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/rack/frontend/src/store.js b/rack/frontend/src/store.js new file mode 100644 index 0000000..7d7d972 --- /dev/null +++ b/rack/frontend/src/store.js @@ -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) diff --git a/rack/frontend/src/util.js b/rack/frontend/src/util.js new file mode 100644 index 0000000..6ef2594 --- /dev/null +++ b/rack/frontend/src/util.js @@ -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: '人工复位', +} diff --git a/rack/frontend/vite.config.js b/rack/frontend/vite.config.js new file mode 100644 index 0000000..a93d63a --- /dev/null +++ b/rack/frontend/vite.config.js @@ -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'] }, + }, + }, + }, +})