From 271c649f664b96e9934c460cd6e7e51b8b1d2e81 Mon Sep 17 00:00:00 2001 From: zhou yang Date: Sun, 27 Sep 2026 14:13:59 +0800 Subject: [PATCH] =?UTF-8?q?rack=EF=BC=9A=E3=80=8C=E5=9B=BA=E4=BB=B6?= =?UTF-8?q?=E3=80=8D=E9=A1=B5=20=E2=80=94=E2=80=94=20=E4=B8=8A=E4=BC=A0=20?= =?UTF-8?q?.bin=E3=80=81=E9=80=89=E6=9D=BF=E6=8E=A8=E9=80=81=20OTA?= =?UTF-8?q?=E3=80=81=E5=AE=9E=E6=97=B6=E7=9C=8B=E8=BF=9B=E5=BA=A6=20/=20?= =?UTF-8?q?=E5=BE=85=E9=AA=8C=E8=AF=81=20/=20=E5=9B=9E=E6=BB=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - firmware.py:固件库。上传时核 ESP 镜像头(0xE9 + app 描述 + project_name == phonerail + ≤3 MB), 存成 /root/phonerail/fw/.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 --- firmware/HANDOFF.md | 5 +- rack/README.md | 19 +- rack/backend/prrack/api.py | 53 +++++- rack/backend/prrack/db.py | 13 ++ rack/backend/prrack/firmware.py | 126 ++++++++++++++ rack/backend/prrack/main.py | 11 +- rack/backend/prrack/protocol.py | 7 +- rack/backend/prrack/simboard.py | 50 +++++- rack/backend/tests/test_firmware.py | 202 ++++++++++++++++++++++ rack/deploy/phonerail-rack.service | 1 + rack/frontend/src/App.vue | 1 + rack/frontend/src/api.js | 19 ++ rack/frontend/src/components/OtaState.vue | 28 +++ rack/frontend/src/main.js | 2 + rack/frontend/src/pages/Board.vue | 5 +- rack/frontend/src/pages/Firmware.vue | 194 +++++++++++++++++++++ 16 files changed, 725 insertions(+), 11 deletions(-) create mode 100644 rack/backend/prrack/firmware.py create mode 100644 rack/backend/tests/test_firmware.py create mode 100644 rack/frontend/src/components/OtaState.vue create mode 100644 rack/frontend/src/pages/Firmware.vue diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index f32483d..2e585eb 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -13,10 +13,11 @@ python tools/ota_push.py b81f3fc9c8bc --reboot # 校验过就立刻重启 — python tools/ota_push.py b81f3fc9c8bc --status ``` 它把镜像传到服务器 `/root/phonerail/fw/.bin`(nginx 7765,只放行这种文件名,不过门户), -再在服务器上用后端账号经 1884 发命令、盯遥测。**rack 管理界面还没有 OTA 按钮**(下一步可以做)。 +再在服务器上用后端账号经 1884 发命令、盯遥测。 +**rack 管理界面「固件」页也能推**(2026-09-27 加,上传 → 选板 → 可选立刻重启并二次确认;见 `rack/README.md`「推固件」)。 ## 下一步(没人定,候选) -- rack 界面加「推固件」:上传 .bin → 后端算哈希、放进 fw 目录、发 `ota`;界面上 reboot 要二次确认(五路全断)。 +- ~~rack 界面加「推固件」~~ ✅ 2026-09-27 已上线。 - 「重启不断电」:ESP 复位时 EN 被下拉 ⇒ OTA / 回滚都会让五台手机掉电一次。要避免得在复位前 `gpio_hold_en` 各路 EN、 并改 bringup 第 1 步「安全态」只在上电复位时做 —— 动的是安全链,要先讨论。 - 服务器 fw 目录不会自动清理旧镜像(每个 ~1 MB,问题不大)。 diff --git a/rack/README.md b/rack/README.md index 7102339..a670b0d 100644 --- a/rack/README.md +++ b/rack/README.md @@ -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/.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 ``` diff --git a/rack/backend/prrack/api.py b/rack/backend/prrack/api.py index 05f9de4..9f1dc2b 100644 --- a/rack/backend/prrack/api.py +++ b/rack/backend/prrack/api.py @@ -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 diff --git a/rack/backend/prrack/db.py b/rack/backend/prrack/db.py index 64379fa..1b324dd 100644 --- a/rack/backend/prrack/db.py +++ b/rack/backend/prrack/db.py @@ -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 '' +); """ diff --git a/rack/backend/prrack/firmware.py b/rack/backend/prrack/firmware.py new file mode 100644 index 0000000..31a5eca --- /dev/null +++ b/rack/backend/prrack/firmware.py @@ -0,0 +1,126 @@ +"""固件库 + OTA 命令(板侧 C2,firmware/main/ota.h;需求 §10.8「C2 定稿的 OTA」)。 + +流程:网页上传 .bin → 这里解析 ESP 镜像头、算 sha256、存成 `/.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(" 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} diff --git a/rack/backend/prrack/main.py b/rack/backend/prrack/main.py index cbfcfda..6769f51 100644 --- a/rack/backend/prrack/main.py +++ b/rack/backend/prrack/main.py @@ -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: diff --git a/rack/backend/prrack/protocol.py b/rack/backend/prrack/protocol.py index eacea0e..543a946 100644 --- a/rack/backend/prrack/protocol.py +++ b/rack/backend/prrack/protocol.py @@ -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", diff --git a/rack/backend/prrack/simboard.py b/rack/backend/prrack/simboard.py index f37aa31..1454d78 100644 --- a/rack/backend/prrack/simboard.py +++ b/rack/backend/prrack/simboard.py @@ -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"} diff --git a/rack/backend/tests/test_firmware.py b/rack/backend/tests/test_firmware.py new file mode 100644 index 0000000..1dfcb7b --- /dev/null +++ b/rack/backend/tests/test_firmware.py @@ -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("事件 操作记录 自动恢复 + 固件
diff --git a/rack/frontend/src/api.js b/rack/frontend/src/api.js index 0c70b9a..0106794 100644 --- a/rack/frontend/src/api.js +++ b/rack/frontend/src/api.js @@ -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 的链路层错误) diff --git a/rack/frontend/src/components/OtaState.vue b/rack/frontend/src/components/OtaState.vue new file mode 100644 index 0000000..20573c8 --- /dev/null +++ b/rack/frontend/src/components/OtaState.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/rack/frontend/src/main.js b/rack/frontend/src/main.js index 003a913..b9472bb 100644 --- a/rack/frontend/src/main.js +++ b/rack/frontend/src/main.js @@ -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 }, ], }) diff --git a/rack/frontend/src/pages/Board.vue b/rack/frontend/src/pages/Board.vue index 2e34caf..6ddf4c8 100644 --- a/rack/frontend/src/pages/Board.vue +++ b/rack/frontend/src/pages/Board.vue @@ -9,7 +9,9 @@
通信中断 · 最后数据 {{ ago(b.last_tele) }} 在线 - ID {{ b.id }} · 固件 {{ b.fw }} · IP {{ b.ip }} + ID {{ b.id }} · 固件 {{ b.status?.fw || b.fw }} · IP {{ b.ip }} + + 推固件 →
输入{{ V(b.status.in?.mv) }} V{{ mA(b.status.in?.ua) }} mA
@@ -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' diff --git a/rack/frontend/src/pages/Firmware.vue b/rack/frontend/src/pages/Firmware.vue new file mode 100644 index 0000000..1643bec --- /dev/null +++ b/rack/frontend/src/pages/Firmware.vue @@ -0,0 +1,194 @@ + + + + +