rack:「固件」页 —— 上传 .bin、选板推送 OTA、实时看进度 / 待验证 / 回滚

- firmware.py:固件库。上传时核 ESP 镜像头(0xE9 + app 描述 + project_name == phonerail + ≤3 MB),
  存成 /root/phonerail/fw/<sha 前 16 位>.bin(nginx 7765 放出去),库里记版本 / 编译时间 / 上传人
- api:GET/POST/DELETE /api/firmware(请求体即 .bin,不引 multipart)、POST /api/boards/{id}/ota(进审计);
  通用 /cmd 不放行 ota —— URL 与哈希只能由固件库生成;OTA 事件按 arg 显示中文、失败 / 回滚标红
- 前端:Firmware.vue + OtaState.vue;推送对话框默认不重启,勾「立刻重启」时列出每块板会断电的路并二次确认;
  单板页显示 OTA 状态
- simboard 模拟 ota(下载进度 / 就绪 / 重启 / 自证);test_firmware.py 13 例,后端共 39 例(本机 + 服务器 3.8)
- service 加 --fw-dir;已部署。板 B 实测:上传 → 推送 → 就绪 → 同镜像带重启 → 不重下直接重启 → 自证健康

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-27 14:14:06 +08:00
共同撰写人 Claude Opus 5.5
父节点 ae1aad3b7c
当前提交 271c649f66
共修改 16 个文件,包含 725 行新增和 11 行删除
+16 -3
查看文件
@@ -11,8 +11,8 @@
| 目录 | 内容 |
|---|---|
| `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)都跑 |
| `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/` | 39 例(pytest),本机与服务器(Python 3.8)都跑 |
| `frontend/` | Vue3 + Vite + Element Plus + ECharts(按需引入) |
| `deploy/` | `deploy.sh` 一键部署 · systemd unit · 每日备份 |
@@ -24,10 +24,23 @@
4. **只绑 127.0.0.1**:`X-Auth-User`(门户覆盖式注入)按 L2 信任,只用于审计「谁按的」;`main.py` 拒绝非回环绑定。
5. **命令白名单在 `protocol.build_command`**:`wifi` / `mqtt`(只收 USB)与 `events`(服务器自己同步)不给界面。板子的校验仍是唯一权威。
## 推固件(OTA,板侧 C2)
界面「固件」页:上传 `firmware/build/phonerail.bin` → 选板子推送。进度、待验证、回滚都直接显示遥测里的 `ota` 一块。
- 上传时后端核镜像头:首字节 0xE9、有 app 描述、`project_name == phonerail`、≤3 MB,不合格直接拒(不用等板子下完 1 MB 再报错)。
存成 `/root/phonerail/fw/<sha256 前 16 位>.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 后端账号)。
## 本机开发
```bash
cd backend && python -m prrack.main --sim 2 --db dev.db --static ../frontend/dist # 两块模拟板,第 1 块第 2 路「卡死」
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
```
+52 -1
查看文件
@@ -17,6 +17,7 @@ from fastapi.staticfiles import StaticFiles
from . import protocol
from .db import now_ms
from .firmware import SLOT_BYTES, FwError, FwStore
from .hub import CmdTimeout, Hub
from .recovery import Params, RecoveryManager
@@ -34,7 +35,8 @@ 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:
startup: Optional[List[Hook]] = None, shutdown: Optional[List[Hook]] = None,
fw: Optional[FwStore] = None) -> FastAPI:
# 用 lifespan 而不是 on_event:后者在新版 Starlette 里已经删了,服务器上的旧版两种都认。
@asynccontextmanager
async def lifespan(app):
@@ -111,6 +113,50 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
_board_or_404(hub, board)
return hub.db.history(board, rail, start, end or now_ms())
# ---- 固件 / OTA(firmware.py)---------------------------------------------
def _fw() -> FwStore:
if fw is None:
raise HTTPException(503, "后端没有配置固件目录(--fw-dir)")
return fw
@app.get("/api/firmware")
def fw_list():
return {"items": _fw().list(), "slot": SLOT_BYTES}
@app.post("/api/firmware")
async def fw_upload(req: Request, filename: str = ""):
# 请求体就是 .bin 原样(application/octet-stream),不走 multipart:省一个依赖(python-multipart)
store = _fw()
data = await req.body()
try:
row = store.add(data, filename, _user(req))
except FwError as e:
raise HTTPException(400, str(e))
hub.broadcast({"t": "firmware"})
return row
@app.delete("/api/firmware/{sha}")
def fw_delete(sha: str):
if not _fw().delete(sha):
raise HTTPException(404, "固件库里没有这个镜像")
hub.broadcast({"t": "firmware"})
return {"ok": True}
@app.post("/api/boards/{board}/ota")
async def ota(board: str, req: Request):
"""推固件。body = {"sha256": ..., "reboot": false}。回应是板子的原话(act = start / reboot / ready),
进度看遥测里的 `ota` 一块。⚠️ reboot=true 会让这块板上的五台手机断电一次。"""
_board_or_404(hub, board)
body = await req.json()
try:
cmd = _fw().command(body.get("sha256"), body.get("reboot", False))
except FwError as e:
raise HTTPException(400, str(e))
try:
return await hub.call(board, cmd, _user(req))
except CmdTimeout as e:
raise HTTPException(504, str(e))
# ---- 事件 / 审计 / 恢复 ----------------------------------------------------
@app.get("/api/events")
def events(board: Optional[str] = None, limit: int = 200, before: Optional[int] = None):
@@ -131,6 +177,11 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
d.update(code=code, name=zh, severe=d["type"] in protocol.EVENT_SEVERE)
if d["type"] == 13:
d["name"] += "(%s)" % protocol.CMD_NAMES.get(d["arg"], d["arg"])
elif d["type"] == 18:
d["name"] += "(%s)" % protocol.OTA_EV_NAMES.get(d["arg"], d["arg"])
if d["arg"] == 3:
d["name"] += ":" + protocol.OTA_ERR_NAMES.get(d["mv"], str(d["mv"]))
d["severe"] = d["arg"] in (3, 5)
out.append(d)
return out
+13
查看文件
@@ -72,6 +72,19 @@ CREATE TABLE IF NOT EXISTS recovery_log (
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, value TEXT NOT NULL
);
-- 固件库(firmware.py):镜像文件本身在 --fw-dir 下,按 sha256 前 16 位命名
CREATE TABLE IF NOT EXISTS firmware (
sha256 TEXT PRIMARY KEY,
version TEXT NOT NULL,
project TEXT NOT NULL,
built TEXT NOT NULL DEFAULT '', -- 镜像头里的编译日期时间
idf TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL,
filename TEXT NOT NULL DEFAULT '',
uploaded_by TEXT NOT NULL,
uploaded INTEGER NOT NULL,
notes TEXT NOT NULL DEFAULT ''
);
"""
+126
查看文件
@@ -0,0 +1,126 @@
"""固件库 + OTA 命令(板侧 C2,firmware/main/ota.h;需求 §10.8「C2 定稿的 OTA」)。
流程:网页上传 .bin → 这里解析 ESP 镜像头、算 sha256、存成 `<fw_dir>/<sha 前 16 位>.bin` →
nginx 7765(rack/deploy/nginx-phonerail-fw.conf,**不过门户**)把它按这个名字放出去 →
`ota` 命令经 MQTT/TLS 带 url + sha256 + size,板子自己下载、哈希对上才切分区。
服务器这一侧先把能拦的拦掉,免得一次推送在板子上下完 1 MB 才报错:
· 不是 ESP 镜像(首字节 0xE9)/ 没有 app 描述(0xABCD5432)→ 拒;
· project_name 不是 `phonerail`(传错了别的项目的 .bin)→ 拒(板子那头也会核一遍);
· 大于一个槽(3 MB)→ 拒。
**板子的校验仍是唯一权威**,这里只是早点告诉操作者。
⚠️ `reboot=True` = 板子校验通过后立刻复位 = **五台手机硬断电一次**(EN 在复位时被下拉)。
界面必须二次确认;这里不替人做这个决定。
"""
import hashlib
import os
import re
import struct
from typing import List, Optional
from .db import Db, now_ms
SLOT_BYTES = 0x300000 # firmware/main/ota.h OTA_SLOT_BYTES
URL_MAX = 64 # OTA_URL_MAX(命令 JSON 字符串上限)
PROJECT = "phonerail"
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
# esp_app_desc_t 紧跟在镜像头(24 B)+ 第一个段头(8 B)之后
_DESC = 32
_DESC_MAGIC = 0xABCD5432
class FwError(ValueError):
"""镜像不能收 / 推送请求不对(中文说明)。"""
def _cstr(b: bytes) -> str:
return b.split(b"\0", 1)[0].decode("ascii", "replace")
def parse_image(data: bytes) -> dict:
"""ESP32 app 镜像 → {version, project, built, idf}。不是本工程的 app 镜像抛 FwError。"""
if not 0 < len(data) <= SLOT_BYTES:
raise FwError("镜像大小 %d 字节,必须在 1~%d 之间(一个 OTA 槽)" % (len(data), SLOT_BYTES))
if len(data) < _DESC + 256 or data[0] != 0xE9:
raise FwError("不是 ESP 固件镜像(首字节应为 0xE9):要传 build/phonerail.bin,不是 .elf / 合并镜像")
magic = struct.unpack_from("<I", data, _DESC)[0]
if magic != _DESC_MAGIC:
raise FwError("镜像里没有 app 描述(可能是 bootloader 或分区表)")
info = {
"version": _cstr(data[_DESC + 16:_DESC + 48]),
"project": _cstr(data[_DESC + 48:_DESC + 80]),
"built": (_cstr(data[_DESC + 96:_DESC + 112]) + " " + _cstr(data[_DESC + 80:_DESC + 96])).strip(),
"idf": _cstr(data[_DESC + 112:_DESC + 144]),
}
if info["project"] != PROJECT:
raise FwError("这是「%s」的固件,不是 %s" % (info["project"], PROJECT))
return info
class FwStore:
def __init__(self, db: Db, fw_dir: str, base_url: str):
self.db = db
self.dir = fw_dir
self.base = base_url.rstrip("/")
if len(self.url_for("0" * 64)) > URL_MAX:
raise ValueError("--fw-base 太长:URL 会超过板子的 %d 字节上限" % URL_MAX)
os.makedirs(self.dir, exist_ok=True)
@staticmethod
def name_for(sha: str) -> str:
return sha[:16] + ".bin" # nginx 7765 只放行这种名字
def url_for(self, sha: str) -> str:
return "%s/%s" % (self.base, self.name_for(sha))
def add(self, data: bytes, filename: str, user: str) -> dict:
info = parse_image(data)
sha = hashlib.sha256(data).hexdigest()
path = os.path.join(self.dir, self.name_for(sha))
tmp = os.path.join(self.dir, "." + self.name_for(sha) + ".tmp")
with open(tmp, "wb") as f:
f.write(data)
os.chmod(tmp, 0o644) # nginx(www-data)要读得到
os.replace(tmp, path)
self.db.x("INSERT OR IGNORE INTO firmware (sha256, version, project, built, idf, size, filename, "
"uploaded_by, uploaded) VALUES (?,?,?,?,?,?,?,?,?)",
(sha, info["version"], info["project"], info["built"], info["idf"], len(data),
re.split(r"[\\/]", filename or "")[-1][:128], user, now_ms())) # 两种分隔符都切:服务器是 Linux
return self.get(sha)
def get(self, sha: str) -> Optional[dict]:
rows = self.db.q("SELECT * FROM firmware WHERE sha256=?", (sha,))
if not rows:
return None
d = dict(rows[0])
d["url"] = self.url_for(sha)
d["present"] = os.path.isfile(os.path.join(self.dir, self.name_for(sha)))
return d
def list(self) -> List[dict]:
return [self.get(r["sha256"]) for r in
self.db.q("SELECT sha256 FROM firmware ORDER BY uploaded DESC")]
def delete(self, sha: str) -> bool:
if self.get(sha) is None:
return False
try:
os.remove(os.path.join(self.dir, self.name_for(sha)))
except FileNotFoundError:
pass
self.db.x("DELETE FROM firmware WHERE sha256=?", (sha,))
return True
def command(self, sha: str, reboot) -> dict:
"""推送请求 → 发给板子的 `ota` 命令(不含 seq)。"""
if not isinstance(sha, str) or not SHA_RE.match(sha):
raise FwError("sha256 必须是 64 位小写十六进制")
if not isinstance(reboot, bool):
raise FwError("reboot 必须是 true/false")
fw = self.get(sha)
if fw is None:
raise FwError("固件库里没有这个镜像")
if not fw["present"]:
raise FwError("镜像文件不在下载目录里了(被手动删过?):重新上传")
return {"cmd": "ota", "url": fw["url"], "sha256": sha, "size": fw["size"], "reboot": reboot}
+9 -2
查看文件
@@ -2,7 +2,8 @@
生产(服务器上,systemd 起):
python -m prrack.main --db /root/phonerail-rack/data/rack.db \\
--mqtt-env /root/phonerail/mqtt.env --static /root/phonerail-rack/frontend/dist
--mqtt-env /root/phonerail/mqtt.env --static /root/phonerail-rack/frontend/dist \\
--fw-dir /root/phonerail/fw
本机开发(不连 broker,起 N 块模拟板子,每秒一条遥测):
python -m prrack.main --sim 2 --db dev.db --static ../frontend/dist
@@ -20,6 +21,7 @@ import uvicorn
from .api import create_app
from .bus import MemBroker, MemBus, PahoBus
from .db import Db, now_ms
from .firmware import FwStore
from .hub import Hub
from .recovery import RecoveryManager
from .simboard import HUNG, SimBoard
@@ -74,7 +76,8 @@ def build(args):
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])
fw = FwStore(db, args.fw_dir, args.fw_base) if args.fw_dir else None
app = create_app(hub, rec, static, startup=[up], shutdown=[down], fw=fw)
return app
@@ -97,6 +100,10 @@ def main(argv=None):
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("--fw-dir", default=None,
help="固件下载目录(nginx 7765 的 /fw/,生产是 /root/phonerail/fw);不给就没有 OTA")
ap.add_argument("--fw-base", default="http://home.example:7765/fw",
help="板子下载固件用的 URL 前缀(板子只能走公网)")
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:
+6 -1
查看文件
@@ -38,8 +38,13 @@ EVENT_TYPES: Dict[int, Tuple[str, str]] = {
18: ("OTA", "固件升级"), # C2:arg 1 开始 / 2 就绪 / 3 失败(mV 列=原因) / 4 自证健康 / 5 回滚 / 6 为换固件重启
}
# LOG_EV_OTA 的 arg(firmware/main/ota.h 的 ota_ev_t)与失败原因(ota_err_t,失败那条的 mV 列)。⚠️ 冻结。
OTA_EV_NAMES = {1: "开始下载", 2: "已就绪", 3: "失败", 4: "新固件自证健康", 5: "回滚", 6: "为换固件重启"}
OTA_ERR_NAMES = {1: "下载失败", 2: "大小不对", 3: "哈希不对", 4: "写 flash 失败", 5: "镜像校验不过",
6: "切启动分区失败", 7: "没有备用槽 / 新固件未自证"}
# 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。
EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14, 17}
EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14, 17} # OTA 的失败 / 回滚在 api.events 里按 arg 单独标红
# 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",
+49 -1
查看文件
@@ -47,6 +47,9 @@ class SimBoard:
self.fan_floor = 0
self.received: List[dict] = []
self.drop_next_replies = 0 # 模拟网络丢回应(测试重传)
self.ota = {"st": "idle", "got": 0, "size": 0, "err": None, "pend": False, "bad": None}
self.ota_req: Optional[dict] = None
self.ota_rate = 400000 # 每次 tick 下载多少字节(真板 ≈125 KB/s)
bus.on_message = self._on_message
self._ev(0, 1, 0) # BOOT
@@ -69,6 +72,7 @@ class SimBoard:
dt = now_ms - self.now if self.now else 0
self.now = now_ms
self.up_ms += dt
self._ota_step()
for r in self.rails:
if r.act != "idle" and now_ms >= r.busy_until:
self._finish(r)
@@ -94,6 +98,30 @@ class SimBoard:
r.en = True if r.act == "hr-off" else r.en
r.act = "idle"
def _ota_step(self):
"""C2:下载进度;下完就绪,带 reboot 就「重启」到新版本(五路全断,按意图恢复不模拟)。"""
o = self.ota
if o["pend"]:
o["pend"] = False # 上一圈重启起来了,这一圈连上 broker ⇒ 自证健康
self._ev(0, 18, 4)
if o["st"] != "dl":
return
o["got"] = min(o["size"], o["got"] + self.ota_rate)
if o["got"] < o["size"]:
return
o["st"] = "ready"
self._ev(0, 18, 2, 0, o["size"])
if self.ota_req.get("reboot"):
self._ota_reboot()
def _ota_reboot(self):
self._ev(0, 18, 6)
self.fw = "ota-" + self.ota_req["sha256"][:8]
self.up_ms = 0
self.ota = {"st": "idle", "got": 0, "size": 0, "err": None, "pend": True, "bad": None}
self.ota_req = None
self._ev(0, 1, 3)
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])
@@ -110,7 +138,7 @@ class SimBoard:
"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,
"ota": dict(self.ota), "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):
@@ -152,6 +180,26 @@ class SimBoard:
return {"ok": True, "floor": self.fan_floor}
if req.get("seq") is None:
return {"ok": False, "err": "no_seq"}
if cmd == "ota":
if not (isinstance(req.get("url"), str) and req["url"].startswith("http://")
and len(req["url"]) <= 64 and isinstance(req.get("sha256"), str)
and len(req["sha256"]) == 64 and isinstance(req.get("size"), int)
and isinstance(req.get("reboot", False), bool)):
return {"ok": False, "err": "arg"}
if self.ota["st"] == "dl":
return {"ok": False, "err": "busy"}
if self.ota["pend"]:
return {"ok": False, "err": "state"}
if self.ota["st"] == "ready" and self.ota_req and self.ota_req["sha256"] == req["sha256"]:
if req.get("reboot"):
self.ota_req = dict(self.ota_req, reboot=True)
self._ota_reboot()
return {"ok": True, "act": "reboot"}
return {"ok": True, "act": "ready"}
self.ota = {"st": "dl", "got": 0, "size": req["size"], "err": None, "pend": False, "bad": None}
self.ota_req = dict(req)
self._ev(0, 18, 1, 0, req["size"])
return {"ok": True, "act": "start"}
rail = req.get("rail")
if not isinstance(rail, int) or not 1 <= rail <= 5:
return {"ok": False, "err": "range"}
+202
查看文件
@@ -0,0 +1,202 @@
"""固件库 + OTA 推送(firmware.py / api.py 的 /api/firmware、/api/boards/{id}/ota)。
断言打在「板子那头真的收到了什么」上(SimBoard.received),和 test_core 同一个口径。"""
import hashlib
import os
import stat
import struct
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.firmware import SLOT_BYTES, FwError, FwStore, parse_image
from prrack.hub import Hub
from prrack.recovery import RecoveryManager
from prrack.simboard import SimBoard
B = "b81f3fc9c8bc"
BASE = "http://home.example:7765/fw"
def image(version="0.1.0-c2", project="phonerail", size=4096, salt=b"") -> bytes:
"""最小的「像 ESP app 镜像」:镜像头 24 B + 段头 8 B + esp_app_desc_t。"""
hdr = bytes([0xE9, 3, 2, 0x4F]) + b"\0" * 20 + b"\0" * 8
desc = struct.pack("<II8s", 0xABCD5432, 0, b"\0" * 8)
desc += version.encode().ljust(32, b"\0") + project.encode().ljust(32, b"\0")
desc += b"13:31:32".ljust(16, b"\0") + b"Sep 27 2026".ljust(16, b"\0") + b"v5.4".ljust(32, b"\0")
body = hdr + desc + salt
return body + b"\xAA" * (size - len(body))
# ---- 镜像解析 ---------------------------------------------------------------
def test_parse_image_reads_the_app_descriptor():
info = parse_image(image())
assert info == {"version": "0.1.0-c2", "project": "phonerail", "built": "Sep 27 2026 13:31:32",
"idf": "v5.4"}
@pytest.mark.parametrize("data, word", [
(image(project="vtcore"), "vtcore"), # 传错了别的项目的 .bin
(b"\x7fELF" + image()[4:], "0xE9"), # .elf
(image()[:32] + b"\0" * 4000, "app 描述"), # bootloader / 分区表:没有 app 描述
(b"", "大小"),
(image(size=SLOT_BYTES + 1), "大小"), # 超过一个槽
], ids=["other-project", "elf", "no-app-desc", "empty", "too-big"])
def test_parse_image_refuses(data, word):
with pytest.raises(FwError) as e:
parse_image(data)
assert word in str(e.value)
# ---- 存储 -------------------------------------------------------------------
def test_store_names_files_by_sha_prefix_and_is_idempotent(tmp_path):
db = Db(str(tmp_path / "a.db"))
fw = FwStore(db, str(tmp_path / "fw"), BASE)
data = image()
sha = hashlib.sha256(data).hexdigest()
row = fw.add(data, "C:\\build\\phonerail.bin", "zhouyang")
assert row["sha256"] == sha and row["size"] == len(data) and row["filename"] == "phonerail.bin"
assert row["url"] == "%s/%s.bin" % (BASE, sha[:16]) and len(row["url"]) <= 64
p = tmp_path / "fw" / (sha[:16] + ".bin")
assert p.read_bytes() == data
if os.name == "posix":
assert stat.S_IMODE(p.stat().st_mode) == 0o644 # nginx 读得到
assert not [f for f in os.listdir(tmp_path / "fw") if f.endswith(".tmp")]
fw.add(data, "again.bin", "someone") # 同一个镜像再传:不新增、不改上传人
assert len(fw.list()) == 1 and fw.list()[0]["uploaded_by"] == "zhouyang"
cmd = fw.command(sha, True)
assert cmd == {"cmd": "ota", "url": row["url"], "sha256": sha, "size": len(data), "reboot": True}
with pytest.raises(FwError):
fw.command(sha, 1) # reboot 是 bool,不是 0/1(板子也是这么判的)
with pytest.raises(FwError):
fw.command(sha.upper(), False)
with pytest.raises(FwError):
fw.command("0" * 64, False)
os.remove(p) # 文件被手动删了:推不出去(不然板子会拿到 404)
with pytest.raises(FwError) as e:
fw.command(sha, False)
assert "重新上传" in str(e.value)
assert fw.delete(sha) and fw.list() == [] and not fw.delete(sha)
def test_store_refuses_a_base_url_the_board_cannot_take(tmp_path):
with pytest.raises(ValueError):
FwStore(Db(str(tmp_path / "a.db")), str(tmp_path / "fw"), "http://" + "x" * 60 + "/fw")
# ---- 接口 + 模拟板 ------------------------------------------------------------
@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)
fw = FwStore(db, str(tmp_path / "fw"), BASE)
async def up():
await hub.start()
sim.start()
sim.tick(now_ms())
app = create_app(hub, rec, None, startup=[up], shutdown=[hub.stop], fw=fw)
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
def upload(c, data, name="phonerail.bin"):
return c.post("/api/firmware?filename=" + name, content=data,
headers={"Content-Type": "application/octet-stream", "X-Auth-User": "zhouyang"})
def wait_for(fn, t=2.0):
deadline = time.time() + t
while time.time() < deadline:
if fn():
return True
time.sleep(0.01)
return False
def test_upload_and_list(env):
c, hub, sim = env
r = upload(c, image())
assert r.status_code == 200 and r.json()["version"] == "0.1.0-c2"
assert r.json()["uploaded_by"] == "zhouyang"
lst = c.get("/api/firmware").json()
assert lst["slot"] == SLOT_BYTES and [x["sha256"] for x in lst["items"]] == [r.json()["sha256"]]
bad = upload(c, image(project="evse"))
assert bad.status_code == 400 and "evse" in bad.json()["detail"]
def test_push_sends_ota_and_the_board_downloads_it(env):
c, hub, sim = env
data = image(size=900000)
sha = upload(c, data).json()["sha256"]
r = c.post("/api/boards/%s/ota" % B, json={"sha256": sha}, headers={"X-Auth-User": "zhouyang"})
assert r.status_code == 200 and r.json()["ok"] is True and r.json()["act"] == "start"
got = sim.received[-1]
assert got["cmd"] == "ota" and got["sha256"] == sha and got["size"] == len(data)
assert got["reboot"] is False # ⚠️ 默认不重启:重启 = 五台手机断电一次
assert got["url"] == "%s/%s.bin" % (BASE, sha[:16])
a = c.get("/api/audit").json()[0]
assert a["user"] == "zhouyang" and a["request"]["cmd"] == "ota" and a["ok"] == 1
# 下载中再推:板子回 busy
r = c.post("/api/boards/%s/ota" % B, json={"sha256": sha, "reboot": True})
assert r.json()["ok"] is False and r.json()["err"] == "busy"
for _ in range(4):
sim.tick(now_ms())
assert wait_for(lambda: (hub.latest[B]["status"] or {}).get("ota", {}).get("st") == "ready")
# 同一个镜像已就绪 + reboot:不重下,直接「重启」到新版本,然后自证健康
r = c.post("/api/boards/%s/ota" % B, json={"sha256": sha, "reboot": True})
assert r.json()["act"] == "reboot"
sim.tick(now_ms())
assert wait_for(lambda: hub.latest[B]["status"]["fw"] == "ota-" + sha[:8])
sim.tick(now_ms())
assert wait_for(lambda: hub.latest[B]["status"]["ota"]["pend"] is False)
def test_push_is_validated_before_it_reaches_the_board(env):
c, hub, sim = env
n = len(sim.received)
sha = upload(c, image()).json()["sha256"]
assert c.post("/api/boards/%s/ota" % B, json={"sha256": "0" * 64}).status_code == 400
assert c.post("/api/boards/%s/ota" % B, json={"sha256": sha, "reboot": 1}).status_code == 400
assert c.post("/api/boards/nope/ota", json={"sha256": sha}).status_code == 404
# 通用命令口不放行 ota:URL / 哈希只能由固件库生成
assert c.post("/api/boards/%s/cmd" % B, json={"cmd": "ota", "url": "http://x/a.bin"}).status_code == 400
assert [m for m in sim.received[n:] if m.get("cmd") == "ota"] == []
def test_no_fw_dir_means_no_ota(tmp_path):
db = Db(str(tmp_path / "a.db"))
hub = Hub(db, MemBus(MemBroker()))
app = create_app(hub, RecoveryManager(hub, now_ms), None)
with TestClient(app) as c:
assert c.get("/api/firmware").status_code == 503
def test_ota_events_are_named_by_arg(env):
c, hub, sim = env
sim._ev(0, 18, 3, 3, 996064) # 失败:哈希不对
sim._ev(0, 18, 5) # 回滚
sim.tick(now_ms())
assert wait_for(lambda: len([e for e in c.get("/api/events").json() if e["type"] == 18]) == 2)
ev = {e["arg"]: e for e in c.get("/api/events").json() if e["type"] == 18}
assert ev[3]["name"] == "固件升级(失败):哈希不对" and ev[3]["severe"] is True
assert ev[5]["name"] == "固件升级(回滚)" and ev[5]["severe"] is True
+1
查看文件
@@ -10,6 +10,7 @@ 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 \
--fw-dir /root/phonerail/fw \
--bind 127.0.0.1 --port 7764
Restart=on-failure
RestartSec=3
+1
查看文件
@@ -7,6 +7,7 @@
<router-link to="/events">事件</router-link>
<router-link to="/audit">操作记录</router-link>
<router-link to="/recovery">自动恢复</router-link>
<router-link to="/firmware">固件</router-link>
</nav>
<div class="conn">
<span :class="['dot', state.ws ? 'up' : 'down']" :title="state.ws ? '已连上服务器' : '与服务器断开,重连中'" />
+19
查看文件
@@ -29,6 +29,25 @@ export const api = {
recovery: () => req('GET', '/api/recovery'),
setRecoveryParams: p => req('PUT', '/api/recovery/params', p),
resetRecovery: (board, rail) => req('POST', `/api/recovery/${board}/${rail}/reset`),
firmware: () => req('GET', '/api/firmware'),
deleteFirmware: sha => req('DELETE', `/api/firmware/${sha}`),
ota: (board, sha256, reboot) => req('POST', `/api/boards/${board}/ota`, { sha256, reboot }),
// .bin 原样作请求体(后端不收 multipart)
uploadFirmware: async file => {
const r = await fetch('/api/firmware?filename=' + encodeURIComponent(file.name), {
method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file,
})
const data = await r.json().catch(() => null)
if (!r.ok) throw new Error((data && data.detail) || `${r.status} ${r.statusText}`)
return data
},
}
// status 里 ota.err(firmware/main/ota.h 的 ota_err_str)→ 人话
export const OTA_ERR_ZH = {
http: '下载失败(连不上下载口 / HTTP 错)', size: '大小不对(下载不完整)', hash: '哈希不对(文件被改过或传错)',
flash: '写 flash 失败', image: '镜像校验不过(不是本工程 / 芯片不对)', boot: '切启动分区失败',
noslot: '没有备用槽(分区表不对)',
}
// 板子回的错误码 → 人话(firmware/main/cmd.h + ulink/mlink 的链路层错误)
+28
查看文件
@@ -0,0 +1,28 @@
<template>
<span class="ota">
<template v-if="o.st === 'dl'">
<el-progress :percentage="pct" :stroke-width="10" style="width: 140px" />
<span class="muted">下载中</span>
</template>
<el-tag v-else-if="o.st === 'ready'" type="warning" size="small" title="已校验、已切启动分区,下次复位生效">已就绪 · 待重启</el-tag>
<el-tag v-if="o.pend" type="warning" size="small" title="新固件还没连上 broker;120 s 内连不上会自动回滚">待验证</el-tag>
<el-tag v-if="o.err && o.st !== 'dl'" type="danger" size="small">上次失败:{{ OTA_ERR_ZH[o.err] || o.err }}</el-tag>
<el-tag v-if="o.bad" type="danger" size="small" effect="plain" title="这份固件在板子上没通过自证健康,被回滚掉了">回滚过:{{ o.bad }}</el-tag>
<span v-if="!busy && !o.pend && !o.err && !o.bad && o.st !== 'ready'" class="muted">—</span>
</span>
</template>
<script setup>
// 一块板的 OTA 状态,直接读遥测里的 `ota`(firmware/main/cmd_report.c 的 ota_obj)。
import { computed } from 'vue'
import { OTA_ERR_ZH } from '../api'
const props = defineProps({ status: Object })
const o = computed(() => props.status?.ota || {})
const busy = computed(() => o.value.st === 'dl')
const pct = computed(() => (o.value.size ? Math.floor((o.value.got / o.value.size) * 100) : 0))
</script>
<style scoped>
.ota { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
</style>
+2
查看文件
@@ -10,6 +10,7 @@ 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 Firmware from './pages/Firmware.vue'
import { connect } from './store'
const router = createRouter({
@@ -20,6 +21,7 @@ const router = createRouter({
{ path: '/events', component: Events },
{ path: '/audit', component: Audit },
{ path: '/recovery', component: Recovery },
{ path: '/firmware', component: Firmware },
],
})
+4 -1
查看文件
@@ -9,7 +9,9 @@
<div class="info">
<el-tag v-if="!b.online || b.stale" type="info">通信中断 · 最后数据 {{ ago(b.last_tele) }}</el-tag>
<el-tag v-else type="success">在线</el-tag>
<span class="muted">ID {{ b.id }} · 固件 {{ b.fw }} · IP {{ b.ip }}</span>
<span class="muted">ID {{ b.id }} · 固件 {{ b.status?.fw || b.fw }} · IP {{ b.ip }}</span>
<OtaState :status="b.status" />
<router-link to="/firmware" class="muted">推固件 →</router-link>
</div>
<div class="stats" v-if="b.status">
<div><span class="muted">输入</span><b>{{ V(b.status.in?.mv) }} V</b><b>{{ mA(b.status.in?.ua) }} mA</b></div>
@@ -63,6 +65,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import RailCard from '../components/RailCard.vue'
import HistoryChart from '../components/HistoryChart.vue'
import OtaState from '../components/OtaState.vue'
import { api, rspText } from '../api'
import { state, onMessage } from '../store'
import { mA, V, C, ago, fmtTime } from '../util'
+194
查看文件
@@ -0,0 +1,194 @@
<template>
<div class="fw">
<div class="card">
<div class="ttl">
固件库
<el-button type="primary" size="small" :loading="uploading" @click="pick">上传 .bin</el-button>
<input ref="file" type="file" accept=".bin" style="display: none" @change="upload" />
</div>
<div class="muted hint">
传 <code>firmware/build/phonerail.bin</code>(不是 .elf,也不是合并镜像)。服务器会核镜像头:不是 PhoneRail 的固件、或大于一个 OTA 槽({{ slotMB }} MB)直接拒。
</div>
<el-table :data="items" size="small" empty-text="还没有上传过固件">
<el-table-column label="版本" min-width="120">
<template #default="{ row }"><b>{{ row.version }}</b></template>
</el-table-column>
<el-table-column label="sha256" width="160">
<template #default="{ row }"><code :title="row.sha256">{{ row.sha256.slice(0, 16) }}</code></template>
</el-table-column>
<el-table-column label="大小" width="90"><template #default="{ row }">{{ kb(row.size) }}</template></el-table-column>
<el-table-column label="编译于" width="170" prop="built" />
<el-table-column label="上传" min-width="150">
<template #default="{ row }">{{ row.uploaded_by }} · {{ fmtTime(row.uploaded) }}</template>
</el-table-column>
<el-table-column label="" width="170" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" :disabled="!row.present" @click="openPush(row)">推送…</el-button>
<el-button size="small" text type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="card">
<div class="ttl">各板状态</div>
<el-table :data="boards" size="small" empty-text="没有板子">
<el-table-column label="板子" min-width="150">
<template #default="{ row }">
<router-link :to="`/board/${row.id}`">{{ row.name || row.id }}</router-link>
<el-tag v-if="!row.online || row.stale" type="info" size="small" style="margin-left: 6px">离线</el-tag>
</template>
</el-table-column>
<el-table-column label="当前固件" min-width="120">
<template #default="{ row }">{{ row.status?.fw || row.fw || '—' }}</template>
</el-table-column>
<el-table-column label="OTA" min-width="260">
<template #default="{ row }"><OtaState :status="row.status" /></template>
</el-table-column>
<el-table-column label="开着的路" width="130">
<template #default="{ row }">{{ railsOn(row).length ? railsOn(row).map(r => '#' + r).join(' ') : '无' }}</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="dlg" :title="`推送 ${target?.version || ''}`" width="min(560px, 94vw)" :close-on-click-modal="false">
<div class="muted" style="margin-bottom: 8px">
板子自己从 <code>{{ target?.url }}</code> 下载,sha256 对得上才切分区。
</div>
<el-checkbox-group v-model="chosen" class="boards">
<el-checkbox v-for="b in boards" :key="b.id" :value="b.id" :disabled="!pushable(b)">
{{ b.name || b.id }}
<span class="muted">{{ b.status?.fw || b.fw }}{{ why(b) ? ' · ' + why(b) : '' }}</span>
</el-checkbox>
</el-checkbox-group>
<div class="reboot">
<el-switch v-model="reboot" />
<span>校验通过后<b>立刻重启</b></span>
</div>
<el-alert v-if="reboot" type="error" :closable="false" show-icon>
<template #title>重启 = 这块板上所有开着的手机<b>硬断电一次</b></template>
ESP 复位时各路 EN 被下拉,起来后按原来的开关状态逐路恢复(约 1 秒)。
<div v-for="b in chosenBoards" :key="b.id">
{{ b.name || b.id }}:{{ railsOn(b).length ? `${railsOn(b).length} 台会断电(${railsOn(b).map(r => '#' + r).join(' ')})` : '没有开着的路' }}
</div>
</el-alert>
<div v-else class="muted">不重启:下次复位(断电重启 / 看门狗 / 再推一次并勾选重启)时生效。已就绪的同一镜像再推一次不会重新下载。</div>
<template #footer>
<el-button @click="dlg = false">取消</el-button>
<el-button type="primary" :disabled="!chosen.length" :loading="pushing" @click="push">
推送到 {{ chosen.length }} 块板{{ reboot ? '并重启' : '' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import OtaState from '../components/OtaState.vue'
import { api, ERR_ZH } from '../api'
import { state, onMessage } from '../store'
import { fmtTime } from '../util'
const items = ref([])
const slot = ref(0x300000)
const slotMB = computed(() => slot.value / 1048576)
const uploading = ref(false)
const file = ref(null)
const dlg = ref(false)
const target = ref(null)
const chosen = ref([])
const reboot = ref(false)
const pushing = ref(false)
const boards = computed(() => Object.values(state.boards).sort((a, b) => a.id.localeCompare(b.id)))
const chosenBoards = computed(() => boards.value.filter(b => chosen.value.includes(b.id)))
const kb = n => (n / 1024).toFixed(0) + ' KB'
const railsOn = b => (b.status?.rails || []).filter(r => r.en).map(r => r.r)
// 板子这一刻能不能推:离线 / 正在下载 / 新固件还没自证(备用槽里是回滚用的旧版)都不行
function why(b) {
if (!b.online || b.stale) return '离线'
const o = b.status?.ota || {}
if (o.st === 'dl') return '正在下载'
if (o.pend) return '新固件待验证'
return ''
}
const pushable = b => !why(b)
async function load() {
try {
const r = await api.firmware()
items.value = r.items
slot.value = r.slot
} catch (e) { ElMessage.error(e.message) }
}
function pick() { file.value.value = ''; file.value.click() }
async function upload() {
const f = file.value.files[0]
if (!f) return
uploading.value = true
try {
const row = await api.uploadFirmware(f)
ElMessage.success(`已上传 ${row.version}`)
await load()
} catch (e) { ElMessage.error(e.message) } finally { uploading.value = false }
}
async function remove(row) {
try {
await ElMessageBox.confirm(`删除 ${row.version}(${row.sha256.slice(0, 16)})?正在下载它的板子会失败。`, '删除固件', { type: 'warning' })
} catch { return }
try { await api.deleteFirmware(row.sha256); await load() } catch (e) { ElMessage.error(e.message) }
}
function openPush(row) {
target.value = row
chosen.value = boards.value.filter(b => pushable(b) && (b.status?.fw || b.fw) !== row.version).map(b => b.id)
reboot.value = false
dlg.value = true
}
async function push() {
if (reboot.value) {
const n = chosenBoards.value.reduce((s, b) => s + railsOn(b).length, 0)
try {
await ElMessageBox.confirm(
n ? `${n} 台手机会断电一次。确定推送并立刻重启?` : '确定推送并立刻重启?(没有开着的路)',
'确认重启', { type: 'warning', confirmButtonText: '推送并重启' })
} catch { return }
}
pushing.value = true
const results = []
for (const id of chosen.value) {
try {
const rsp = await api.ota(id, target.value.sha256, reboot.value)
results.push(rsp.ok ? `${id}:${{ start: '开始下载', reboot: '已就绪,马上重启', ready: '已就绪(没变)' }[rsp.act] || '成功'}`
: `${id}:${rsp.err === 'state' ? '新固件待验证 / 板子不支持 OTA' : ERR_ZH[rsp.err] || rsp.err}`)
} catch (e) { results.push(`${id}:${e.message}`) }
}
pushing.value = false
dlg.value = false
ElMessageBox.alert(results.join('\n'), '推送结果', { customStyle: { whiteSpace: 'pre-line' } })
}
const off = onMessage(m => { if (m.t === 'firmware') load() })
onMounted(load)
onBeforeUnmount(off)
</script>
<style scoped>
.fw { display: flex; flex-direction: column; gap: 16px; }
.fw .card { min-width: 0; overflow: hidden; }
.ttl { font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; }
.hint { margin-bottom: 8px; }
code { font-size: 12px; background: #f1f5f9; padding: 1px 4px; border-radius: 4px; word-break: break-all; }
.boards { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; margin-bottom: 12px; }
.boards .muted { margin-left: 6px; }
.reboot { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
</style>