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