From 1cd4fd4082ada2051e4fb890140df0786a6ac1a4 Mon Sep 17 00:00:00 2001 From: zhou yang Date: Sun, 27 Sep 2026 14:48:12 +0800 Subject: [PATCH] =?UTF-8?q?rack=EF=BC=9A=E5=9B=BA=E4=BB=B6=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E7=9B=AE=E5=BD=95=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86?= =?UTF-8?q?=EF=BC=88=E6=AF=8F=E5=B0=8F=E6=97=B6=EF=BC=8CFwStore.prune?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 固件库里登记过的(界面上传)不自动删;没登记的(ota_push.py 直传、台架镜像)放满 7 天删; 上传中断的 .tmp 1 小时删;非 <16 hex>.bin / .tmp 的文件名一律不碰 - 板子下载一次几秒就完、回滚用的是板上另一个槽,删旧文件不影响任何板子 - main.py:有 --fw-dir 时起清理循环,出错只记日志;test_firmware.py 加 2 例,共 41 例(本机 + 服务器) - 已部署;本机模拟板实测放一个 8 天前的孤儿镜像,启动即被清掉 Co-Authored-By: Claude Opus 5.5 --- firmware/HANDOFF.md | 4 +-- rack/README.md | 5 +++- rack/backend/prrack/firmware.py | 42 +++++++++++++++++++++++++++++ rack/backend/prrack/main.py | 18 ++++++++++++- rack/backend/tests/test_firmware.py | 40 +++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 4 deletions(-) diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index 7e950bb..0ad0c82 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -27,7 +27,6 @@ OTA 换固件 / 超时回滚现在走 `warm_restart()`(`main/warm_esp.c`): ## 下一步(没人定,候选) - 示波器验证:插一片刀片,CH 开着,OTA 带重启,抓 EN / 输出电压(`scope_*` 工具)。 -- 服务器 fw 目录不会自动清理旧镜像(每个 ~1 MB,问题不大)。 ## 踩到的坑(c3) - `.rodata_custom_desc` 没人引用会被 `--gc-sections` 丢掉 ⇒ `main/CMakeLists.txt` 加 `-u g_phonerail_caps`。 @@ -41,7 +40,8 @@ python tools/ota_push.py b81f3fc9c8bc # 推 build/phonerail.bin:只 python tools/ota_push.py b81f3fc9c8bc --reboot # 校验过就立刻重启 —— ⚠️ 五台手机硬断电一次 python tools/ota_push.py b81f3fc9c8bc --status ``` -它把镜像传到服务器 `/root/phonerail/fw/.bin`(nginx 7765,只放行这种文件名,不过门户), +它把镜像传到服务器 `/root/phonerail/fw/.bin`(nginx 7765,只放行这种文件名,不过门户; +**不进固件库,7 天后 rack 后端自动删** —— 要长期留着就从界面上传), 再在服务器上用后端账号经 1884 发命令、盯遥测。 **rack 管理界面「固件」页也能推**(2026-09-27 加,上传 → 选板 → 可选立刻重启并二次确认;见 `rack/README.md`「推固件」)。 diff --git a/rack/README.md b/rack/README.md index a670b0d..de53e25 100644 --- a/rack/README.md +++ b/rack/README.md @@ -12,7 +12,7 @@ | 目录 | 内容 | |---|---| | `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)都跑 | +| `backend/tests/` | 41 例(pytest),本机与服务器(Python 3.8)都跑 | | `frontend/` | Vue3 + Vite + Element Plus + ECharts(按需引入) | | `deploy/` | `deploy.sh` 一键部署 · systemd unit · 每日备份 | @@ -36,6 +36,9 @@ 不勾就只下载 + 切分区,下次复位生效;同一个镜像已就绪时再推一次并勾重启,不重下。 - 新固件 120 s 内连不上 broker 板子自己回滚,界面显示「回滚过:版本号」,事件里有「固件升级(回滚)」。 - 命令行等价物:`firmware/tools/ota_push.py`(不经过后端,直接用 broker 后端账号)。 +- **下载目录自动清理**(后端每小时一次,`FwStore.prune`):固件库里**登记过的不删**(界面上传的,要删用删除按钮); + 没登记的(`ota_push.py` 直接传的、台架镜像)放满 **7 天**删;上传中断的 `.tmp` 1 小时删;别的文件名一律不碰。 + 板子下载一次几秒就完,回滚用的是板子自己另一个槽,所以删掉旧文件不影响任何一块板。 ## 本机开发 diff --git a/rack/backend/prrack/firmware.py b/rack/backend/prrack/firmware.py index bc12da5..8b55e78 100644 --- a/rack/backend/prrack/firmware.py +++ b/rack/backend/prrack/firmware.py @@ -17,6 +17,7 @@ import hashlib import os import re import struct +import time from typing import List, Optional from .db import Db, now_ms @@ -26,6 +27,13 @@ URL_MAX = 64 # OTA_URL_MAX(命令 JSON 字符串上限) PROJECT = "phonerail" SHA_RE = re.compile(r"^[0-9a-f]{64}$") +# 自动清理(prune)。下载目录里只有两种文件归本模块管:`<16 hex>.bin` 与上传中途的 `.<16 hex>.bin.tmp`; +# 别的名字一律不碰(目录是给 nginx 的,谁往里放了别的东西,不是这里该删的)。 +FILE_RE = re.compile(r"^[0-9a-f]{16}\.bin$") +TMP_RE = re.compile(r"^\.[0-9a-f]{16}\.bin\.tmp$") +ORPHAN_KEEP_S = 7 * 86400 # 库里没登记的镜像(ota_push.py 直接传的、台架镜像)留 7 天 +TMP_KEEP_S = 3600 # 上传中断留下的半截文件 + # esp_app_desc_t 紧跟在镜像头(24 B)+ 第一个段头(8 B)之后 _DESC = 32 _DESC_MAGIC = 0xABCD5432 @@ -116,6 +124,40 @@ class FwStore: return [self.get(r["sha256"]) for r in self.db.q("SELECT sha256 FROM firmware ORDER BY uploaded DESC")] + def prune(self, now_s: Optional[float] = None) -> List[str]: + """清掉**库里没登记**、而且放了 ORPHAN_KEEP_S 以上的镜像,以及 TMP_KEEP_S 以上的半截上传。 + 返回删掉的文件名。 + + ⚠️ 固件库里登记过的(界面上传的)**不自动删** —— 那是人挑出来的版本,界面上有删除按钮。 + 没登记的删了也不影响板子:板子下载一次几秒就完,之后不再需要服务器上的文件 + (回滚用的是板子自己另一个槽里的旧版,不是这里的)。7 天是给「推了一半、第二天接着推」留的余地。 + """ + now = time.time() if now_s is None else now_s + known = {self.name_for(r["sha256"]) for r in self.db.q("SELECT sha256 FROM firmware")} + gone = [] + try: + names = os.listdir(self.dir) + except FileNotFoundError: + return gone + for name in names: + if FILE_RE.match(name): + if name in known: + continue + keep = ORPHAN_KEEP_S + elif TMP_RE.match(name): + keep = TMP_KEEP_S + else: + continue + path = os.path.join(self.dir, name) + try: + if now - os.path.getmtime(path) < keep: + continue + os.remove(path) + gone.append(name) + except OSError: + pass # 刚被别人删了 / 权限:下一轮再说 + return gone + def delete(self, sha: str) -> bool: if self.get(sha) is None: return False diff --git a/rack/backend/prrack/main.py b/rack/backend/prrack/main.py index 6769f51..b8a5c2c 100644 --- a/rack/backend/prrack/main.py +++ b/rack/backend/prrack/main.py @@ -28,6 +28,8 @@ from .simboard import HUNG, SimBoard log = logging.getLogger("prrack") +FW_GC_S = 3600 # 固件目录多久清一次(firmware.FwStore.prune) + def read_env(path: str) -> dict: out = {} @@ -77,7 +79,21 @@ def build(args): static = args.static if args.static and os.path.isdir(args.static) else None 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) + startup = [up] + if fw is not None: + async def fw_gc(): + async def loop(): + while True: + try: + gone = fw.prune() + if gone: + log.info("固件目录清理:删了 %d 个(%s)", len(gone), " ".join(gone)) + except Exception: # 清理出错不能拖垮后端 + log.exception("固件目录清理失败") + await asyncio.sleep(FW_GC_S) + asyncio.ensure_future(loop()) + startup.append(fw_gc) + app = create_app(hub, rec, static, startup=startup, shutdown=[down], fw=fw) return app diff --git a/rack/backend/tests/test_firmware.py b/rack/backend/tests/test_firmware.py index 68a6891..1ebcd6a 100644 --- a/rack/backend/tests/test_firmware.py +++ b/rack/backend/tests/test_firmware.py @@ -204,3 +204,43 @@ def test_ota_events_are_named_by_arg(env): 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 + + +# ---- 自动清理 ------------------------------------------------------------------- + +def test_prune_removes_old_orphans_and_stale_tmp_only(tmp_path): + from prrack.firmware import ORPHAN_KEEP_S, TMP_KEEP_S + db = Db(str(tmp_path / "a.db")) + d = tmp_path / "fw" + fw = FwStore(db, str(d), BASE) + lib = fw.add(image(), "phonerail.bin", "zhouyang") # 界面上传的:登记在库里 + now = time.time() + + def put(name, age_s): + f = d / name + f.write_bytes(b"x") + os.utime(f, (now - age_s, now - age_s)) + return name + + old_orphan = put("0123456789abcdef.bin", ORPHAN_KEEP_S + 60) # ota_push.py 传的,放了 7 天多 + new_orphan = put("fedcba9876543210.bin", ORPHAN_KEEP_S - 60) # 还没到 7 天 + old_tmp = put(".0123456789abcdef.bin.tmp", TMP_KEEP_S + 60) # 上传中断留下的 + new_tmp = put(".fedcba9876543210.bin.tmp", 60) # 可能正在传 + other = put("README.txt", ORPHAN_KEEP_S * 10) # 不是本模块的名字:不碰 + os.utime(d / (lib["sha256"][:16] + ".bin"), (now - ORPHAN_KEEP_S * 10,) * 2) # 库里的再老也不删 + + gone = fw.prune(now) + assert sorted(gone) == sorted([old_orphan, old_tmp]) + left = set(os.listdir(d)) + assert {new_orphan, new_tmp, other, lib["sha256"][:16] + ".bin"} <= left + assert old_orphan not in left and old_tmp not in left + assert fw.get(lib["sha256"])["present"] is True + assert fw.prune(now) == [] # 幂等 + + +def test_prune_after_library_delete_is_a_no_op(tmp_path): + db = Db(str(tmp_path / "a.db")) + fw = FwStore(db, str(tmp_path / "fw"), BASE) + sha = fw.add(image(), "a.bin", "u")["sha256"] + fw.delete(sha) # 界面删除:文件与登记一起没了 + assert fw.prune() == []