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 行删除
+52 -1
查看文件
@@ -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
+13
查看文件
@@ -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 ''
);
"""
+126
查看文件
@@ -0,0 +1,126 @@
"""固件库 + OTA 命令(板侧 C2,firmware/main/ota.h;需求 §10.8「C2 定稿的 OTA」)。
流程:网页上传 .bin → 这里解析 ESP 镜像头、算 sha256、存成 `<fw_dir>/<sha 前 16 位>.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("<I", data, _DESC)[0]
if magic != _DESC_MAGIC:
raise FwError("镜像里没有 app 描述(可能是 bootloader 或分区表)")
info = {
"version": _cstr(data[_DESC + 16:_DESC + 48]),
"project": _cstr(data[_DESC + 48:_DESC + 80]),
"built": (_cstr(data[_DESC + 96:_DESC + 112]) + " " + _cstr(data[_DESC + 80:_DESC + 96])).strip(),
"idf": _cstr(data[_DESC + 112:_DESC + 144]),
}
if info["project"] != PROJECT:
raise FwError("这是「%s」的固件,不是 %s" % (info["project"], PROJECT))
return info
class FwStore:
def __init__(self, db: Db, fw_dir: str, base_url: str):
self.db = db
self.dir = fw_dir
self.base = base_url.rstrip("/")
if len(self.url_for("0" * 64)) > 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}
+9 -2
查看文件
@@ -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:
+6 -1
查看文件
@@ -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",
+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"}
+202
查看文件
@@ -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("<II8s", 0xABCD5432, 0, b"\0" * 8)
desc += version.encode().ljust(32, b"\0") + project.encode().ljust(32, b"\0")
desc += b"13:31:32".ljust(16, b"\0") + b"Sep 27 2026".ljust(16, b"\0") + b"v5.4".ljust(32, b"\0")
body = hdr + desc + salt
return body + b"\xAA" * (size - len(body))
# ---- 镜像解析 ---------------------------------------------------------------
def test_parse_image_reads_the_app_descriptor():
info = parse_image(image())
assert info == {"version": "0.1.0-c2", "project": "phonerail", "built": "Sep 27 2026 13:31:32",
"idf": "v5.4"}
@pytest.mark.parametrize("data, word", [
(image(project="vtcore"), "vtcore"), # 传错了别的项目的 .bin
(b"\x7fELF" + image()[4:], "0xE9"), # .elf
(image()[:32] + b"\0" * 4000, "app 描述"), # bootloader / 分区表:没有 app 描述
(b"", "大小"),
(image(size=SLOT_BYTES + 1), "大小"), # 超过一个槽
], ids=["other-project", "elf", "no-app-desc", "empty", "too-big"])
def test_parse_image_refuses(data, word):
with pytest.raises(FwError) as e:
parse_image(data)
assert word in str(e.value)
# ---- 存储 -------------------------------------------------------------------
def test_store_names_files_by_sha_prefix_and_is_idempotent(tmp_path):
db = Db(str(tmp_path / "a.db"))
fw = FwStore(db, str(tmp_path / "fw"), BASE)
data = image()
sha = hashlib.sha256(data).hexdigest()
row = fw.add(data, "C:\\build\\phonerail.bin", "zhouyang")
assert row["sha256"] == sha and row["size"] == len(data) and row["filename"] == "phonerail.bin"
assert row["url"] == "%s/%s.bin" % (BASE, sha[:16]) and len(row["url"]) <= 64
p = tmp_path / "fw" / (sha[:16] + ".bin")
assert p.read_bytes() == data
if os.name == "posix":
assert stat.S_IMODE(p.stat().st_mode) == 0o644 # nginx 读得到
assert not [f for f in os.listdir(tmp_path / "fw") if f.endswith(".tmp")]
fw.add(data, "again.bin", "someone") # 同一个镜像再传:不新增、不改上传人
assert len(fw.list()) == 1 and fw.list()[0]["uploaded_by"] == "zhouyang"
cmd = fw.command(sha, True)
assert cmd == {"cmd": "ota", "url": row["url"], "sha256": sha, "size": len(data), "reboot": True}
with pytest.raises(FwError):
fw.command(sha, 1) # reboot 是 bool,不是 0/1(板子也是这么判的)
with pytest.raises(FwError):
fw.command(sha.upper(), False)
with pytest.raises(FwError):
fw.command("0" * 64, False)
os.remove(p) # 文件被手动删了:推不出去(不然板子会拿到 404)
with pytest.raises(FwError) as e:
fw.command(sha, False)
assert "重新上传" in str(e.value)
assert fw.delete(sha) and fw.list() == [] and not fw.delete(sha)
def test_store_refuses_a_base_url_the_board_cannot_take(tmp_path):
with pytest.raises(ValueError):
FwStore(Db(str(tmp_path / "a.db")), str(tmp_path / "fw"), "http://" + "x" * 60 + "/fw")
# ---- 接口 + 模拟板 ------------------------------------------------------------
@pytest.fixture
def env(tmp_path):
broker = MemBroker()
db = Db(str(tmp_path / "a.db"))
hub = Hub(db, MemBus(broker), attempt_timeout=0.2)
rec = RecoveryManager(hub, now_ms)
sim = SimBoard(MemBus(broker), B)
fw = FwStore(db, str(tmp_path / "fw"), BASE)
async def up():
await hub.start()
sim.start()
sim.tick(now_ms())
app = create_app(hub, rec, None, startup=[up], shutdown=[hub.stop], fw=fw)
with TestClient(app) as c:
deadline = time.time() + 2
while B not in hub.latest and time.time() < deadline:
time.sleep(0.01)
yield c, hub, sim
def upload(c, data, name="phonerail.bin"):
return c.post("/api/firmware?filename=" + name, content=data,
headers={"Content-Type": "application/octet-stream", "X-Auth-User": "zhouyang"})
def wait_for(fn, t=2.0):
deadline = time.time() + t
while time.time() < deadline:
if fn():
return True
time.sleep(0.01)
return False
def test_upload_and_list(env):
c, hub, sim = env
r = upload(c, image())
assert r.status_code == 200 and r.json()["version"] == "0.1.0-c2"
assert r.json()["uploaded_by"] == "zhouyang"
lst = c.get("/api/firmware").json()
assert lst["slot"] == SLOT_BYTES and [x["sha256"] for x in lst["items"]] == [r.json()["sha256"]]
bad = upload(c, image(project="evse"))
assert bad.status_code == 400 and "evse" in bad.json()["detail"]
def test_push_sends_ota_and_the_board_downloads_it(env):
c, hub, sim = env
data = image(size=900000)
sha = upload(c, data).json()["sha256"]
r = c.post("/api/boards/%s/ota" % B, json={"sha256": sha}, headers={"X-Auth-User": "zhouyang"})
assert r.status_code == 200 and r.json()["ok"] is True and r.json()["act"] == "start"
got = sim.received[-1]
assert got["cmd"] == "ota" and got["sha256"] == sha and got["size"] == len(data)
assert got["reboot"] is False # ⚠️ 默认不重启:重启 = 五台手机断电一次
assert got["url"] == "%s/%s.bin" % (BASE, sha[:16])
a = c.get("/api/audit").json()[0]
assert a["user"] == "zhouyang" and a["request"]["cmd"] == "ota" and a["ok"] == 1
# 下载中再推:板子回 busy
r = c.post("/api/boards/%s/ota" % B, json={"sha256": sha, "reboot": True})
assert r.json()["ok"] is False and r.json()["err"] == "busy"
for _ in range(4):
sim.tick(now_ms())
assert wait_for(lambda: (hub.latest[B]["status"] or {}).get("ota", {}).get("st") == "ready")
# 同一个镜像已就绪 + reboot:不重下,直接「重启」到新版本,然后自证健康
r = c.post("/api/boards/%s/ota" % B, json={"sha256": sha, "reboot": True})
assert r.json()["act"] == "reboot"
sim.tick(now_ms())
assert wait_for(lambda: hub.latest[B]["status"]["fw"] == "ota-" + sha[:8])
sim.tick(now_ms())
assert wait_for(lambda: hub.latest[B]["status"]["ota"]["pend"] is False)
def test_push_is_validated_before_it_reaches_the_board(env):
c, hub, sim = env
n = len(sim.received)
sha = upload(c, image()).json()["sha256"]
assert c.post("/api/boards/%s/ota" % B, json={"sha256": "0" * 64}).status_code == 400
assert c.post("/api/boards/%s/ota" % B, json={"sha256": sha, "reboot": 1}).status_code == 400
assert c.post("/api/boards/nope/ota", json={"sha256": sha}).status_code == 404
# 通用命令口不放行 ota:URL / 哈希只能由固件库生成
assert c.post("/api/boards/%s/cmd" % B, json={"cmd": "ota", "url": "http://x/a.bin"}).status_code == 400
assert [m for m in sim.received[n:] if m.get("cmd") == "ota"] == []
def test_no_fw_dir_means_no_ota(tmp_path):
db = Db(str(tmp_path / "a.db"))
hub = Hub(db, MemBus(MemBroker()))
app = create_app(hub, RecoveryManager(hub, now_ms), None)
with TestClient(app) as c:
assert c.get("/api/firmware").status_code == 503
def test_ota_events_are_named_by_arg(env):
c, hub, sim = env
sim._ev(0, 18, 3, 3, 996064) # 失败:哈希不对
sim._ev(0, 18, 5) # 回滚
sim.tick(now_ms())
assert wait_for(lambda: len([e for e in c.get("/api/events").json() if e["type"] == 18]) == 2)
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