# 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` 存储 · `firmware.py` 固件库 + OTA 命令 · `api.py` HTTP+WS · `simboard.py` 模拟板子 · `main.py` 入口 | | `backend/tests/` | 42 例(pytest),本机与服务器(Python 3.8)都跑 | | `frontend/` | Vue3 + Vite + Element Plus + ECharts(按需引入)。主题在 `src/theme.css`(亮 / 暗两套变量,跟随系统),图表公共部分在 `src/chart.js` | | `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`(服务器自己同步)不给界面。板子的校验仍是唯一权威。 ## 界面(2026-09-27 改版) - 仪器前面板风格:每块板一块面板,五个「刀片槽」各有状态灯、电流读数和**最近两分钟电流小曲线**(平线 = 疑似卡死)。 只有要人看的状态(异常 / 故障 / 正在执行)上色,「运行」「断电」「空槽」保持安静;总览顶上一句话列出需要看的路。 - 字体随前端打包(Fontsource,不走 Google Fonts CDN),各端一致:界面 `Noto Sans SC Variable`(按 unicode-range 切约 100 片,浏览器只下用到的几片), 读数 `Barlow`(DIN 风格,`tnum` 等宽数字,只打拉丁子集)。⚠️ ECharts 画在 canvas 上且 zrender 按字体缓存文字宽度、从不清空 —— 图表 `init` 前必须先 `chartFonts()` 把字形下完(`src/chart.js`),否则时间轴标签会按回退字体宽度排、叠成一团。 - 暗色模式跟随系统(`html.dark`,Element Plus 的暗色变量也认它),图表在切换时重画。 - 曲线**不用双 Y 轴**:一路的电流 / 电压、底板的温度 / 风扇,都是上下两块共用时间轴(dataviz 规矩)。 两路温度用 dataviz 参考调色板第 1/2 槽,按本页亮暗两种面板底色跑过 `validate_palette.js`,全部通过。 - **底板温度曲线**:`GET /api/boards/{id}/history`(`Db.board_history`)。6 小时以内回每秒原始数据,更长回分钟聚合 `board_tele_min`(温度带每分钟 min/max、风扇、输入电压电流;与每路的 `tele_min` 一样留 90 天)。 NTC1 贴 Hub 稳压 U31,NTC2 贴输入降压 U37(R2 规划 §32)。 ## 推固件(OTA,板侧 C2) 界面「固件」页:上传 `firmware/build/phonerail.bin` → 选板子推送。进度、待验证、回滚都直接显示遥测里的 `ota` 一块。 - 上传时后端核镜像头:首字节 0xE9、有 app 描述、`project_name == phonerail`、≤3 MB,不合格直接拒(不用等板子下完 1 MB 再报错)。 存成 `/root/phonerail/fw/.bin`,由 nginx 7765 放出去(`deploy/nginx-phonerail-fw.conf`,**故意不过门户**: 板子过不了门禁;完整性靠命令里的 sha256,镜像里没有机密)。 - 推送 = `POST /api/boards/{id}/ota {"sha256","reboot"}`,进审计。通用 `/cmd` **不放行** `ota`:URL / 哈希只能由固件库生成。 - ⚠️ **勾「立刻重启」= 这块板上开着的手机全部硬断电一次**(ESP 复位时 EN 下拉),界面会列出哪几路并二次确认。 不勾就只下载 + 切分区,下次复位生效;同一个镜像已就绪时再推一次并勾重启,不重下。 - 新固件 120 s 内连不上 broker 板子自己回滚,界面显示「回滚过:版本号」,事件里有「固件升级(回滚)」。 - 命令行等价物:`firmware/tools/ota_push.py`(不经过后端,直接用 broker 后端账号)。 - **下载目录自动清理**(后端每小时一次,`FwStore.prune`):固件库里**登记过的不删**(界面上传的,要删用删除按钮); 没登记的(`ota_push.py` 直接传的、台架镜像)放满 **7 天**删;上传中断的 `.tmp` 1 小时删;别的文件名一律不碰。 板子下载一次几秒就完,回滚用的是板子自己另一个槽,所以删掉旧文件不影响任何一块板。 ## 本机开发 ```bash cd backend && python -m prrack.main --sim 2 --db dev.db --static ../frontend/dist --fw-dir ./fwdev # 两块模拟板,第 1 块第 2 路「卡死」;--fw-dir 打开固件页 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),手机首屏偏慢;改成按需引入可以再砍一半。 - 没有细分权限:门户授权了这个应用的人都能操作;审计记着是谁。