rack:固件下载目录自动清理(每小时,FwStore.prune)

- 固件库里登记过的(界面上传)不自动删;没登记的(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 <[email protected]>
这个提交包含在:
zhou yang
2026-09-27 14:48:12 +08:00
共同撰写人 Claude Opus 5.5
父节点 284571de20
当前提交 1cd4fd4082
共修改 5 个文件,包含 105 行新增和 4 行删除
+2 -2
查看文件
@@ -27,7 +27,6 @@ OTA 换固件 / 超时回滚现在走 `warm_restart()`(`main/warm_esp.c`):
## 下一步(没人定,候选) ## 下一步(没人定,候选)
- 示波器验证:插一片刀片,CH 开着,OTA 带重启,抓 EN / 输出电压(`scope_*` 工具)。 - 示波器验证:插一片刀片,CH 开着,OTA 带重启,抓 EN / 输出电压(`scope_*` 工具)。
- 服务器 fw 目录不会自动清理旧镜像(每个 ~1 MB,问题不大)。
## 踩到的坑(c3) ## 踩到的坑(c3)
- `.rodata_custom_desc` 没人引用会被 `--gc-sections` 丢掉 ⇒ `main/CMakeLists.txt` 加 `-u g_phonerail_caps`。 - `.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 --reboot # 校验过就立刻重启 —— ⚠️ 五台手机硬断电一次
python tools/ota_push.py b81f3fc9c8bc --status python tools/ota_push.py b81f3fc9c8bc --status
``` ```
它把镜像传到服务器 `/root/phonerail/fw/<sha 前 16 位>.bin`(nginx 7765,只放行这种文件名,不过门户), 它把镜像传到服务器 `/root/phonerail/fw/<sha 前 16 位>.bin`(nginx 7765,只放行这种文件名,不过门户;
**不进固件库,7 天后 rack 后端自动删** —— 要长期留着就从界面上传),
再在服务器上用后端账号经 1884 发命令、盯遥测。 再在服务器上用后端账号经 1884 发命令、盯遥测。
**rack 管理界面「固件」页也能推**(2026-09-27 加,上传 → 选板 → 可选立刻重启并二次确认;见 `rack/README.md`「推固件」)。 **rack 管理界面「固件」页也能推**(2026-09-27 加,上传 → 选板 → 可选立刻重启并二次确认;见 `rack/README.md`「推固件」)。
+4 -1
查看文件
@@ -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/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(按需引入) | | `frontend/` | Vue3 + Vite + Element Plus + ECharts(按需引入) |
| `deploy/` | `deploy.sh` 一键部署 · systemd unit · 每日备份 | | `deploy/` | `deploy.sh` 一键部署 · systemd unit · 每日备份 |
@@ -36,6 +36,9 @@
不勾就只下载 + 切分区,下次复位生效;同一个镜像已就绪时再推一次并勾重启,不重下。 不勾就只下载 + 切分区,下次复位生效;同一个镜像已就绪时再推一次并勾重启,不重下。
- 新固件 120 s 内连不上 broker 板子自己回滚,界面显示「回滚过:版本号」,事件里有「固件升级(回滚)」。 - 新固件 120 s 内连不上 broker 板子自己回滚,界面显示「回滚过:版本号」,事件里有「固件升级(回滚)」。
- 命令行等价物:`firmware/tools/ota_push.py`(不经过后端,直接用 broker 后端账号)。 - 命令行等价物:`firmware/tools/ota_push.py`(不经过后端,直接用 broker 后端账号)。
- **下载目录自动清理**(后端每小时一次,`FwStore.prune`):固件库里**登记过的不删**(界面上传的,要删用删除按钮);
没登记的(`ota_push.py` 直接传的、台架镜像)放满 **7 天**删;上传中断的 `.tmp` 1 小时删;别的文件名一律不碰。
板子下载一次几秒就完,回滚用的是板子自己另一个槽,所以删掉旧文件不影响任何一块板。
## 本机开发 ## 本机开发
+42
查看文件
@@ -17,6 +17,7 @@ import hashlib
import os import os
import re import re
import struct import struct
import time
from typing import List, Optional from typing import List, Optional
from .db import Db, now_ms from .db import Db, now_ms
@@ -26,6 +27,13 @@ URL_MAX = 64 # OTA_URL_MAX(命令 JSON 字符串上限)
PROJECT = "phonerail" PROJECT = "phonerail"
SHA_RE = re.compile(r"^[0-9a-f]{64}$") 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)之后 # esp_app_desc_t 紧跟在镜像头(24 B)+ 第一个段头(8 B)之后
_DESC = 32 _DESC = 32
_DESC_MAGIC = 0xABCD5432 _DESC_MAGIC = 0xABCD5432
@@ -116,6 +124,40 @@ class FwStore:
return [self.get(r["sha256"]) for r in return [self.get(r["sha256"]) for r in
self.db.q("SELECT sha256 FROM firmware ORDER BY uploaded DESC")] 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: def delete(self, sha: str) -> bool:
if self.get(sha) is None: if self.get(sha) is None:
return False return False
+17 -1
查看文件
@@ -28,6 +28,8 @@ from .simboard import HUNG, SimBoard
log = logging.getLogger("prrack") log = logging.getLogger("prrack")
FW_GC_S = 3600 # 固件目录多久清一次(firmware.FwStore.prune)
def read_env(path: str) -> dict: def read_env(path: str) -> dict:
out = {} out = {}
@@ -77,7 +79,21 @@ def build(args):
static = args.static if args.static and os.path.isdir(args.static) else None 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 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 return app
+40
查看文件
@@ -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} 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[3]["name"] == "固件升级(失败):哈希不对" and ev[3]["severe"] is True
assert ev[5]["name"] == "固件升级(回滚)" and ev[5]["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() == []