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]>
这个提交包含在:
@@ -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"}
|
||||
|
||||
在新工单中引用
屏蔽一个用户