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