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 行删除
+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"}