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]>
这个提交包含在:
+3
-2
@@ -13,10 +13,11 @@ 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,只放行这种文件名,不过门户),
|
||||||
再在服务器上用后端账号经 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、
|
- 「重启不断电」:ESP 复位时 EN 被下拉 ⇒ OTA / 回滚都会让五台手机掉电一次。要避免得在复位前 `gpio_hold_en` 各路 EN、
|
||||||
并改 bringup 第 1 步「安全态」只在上电复位时做 —— 动的是安全链,要先讨论。
|
并改 bringup 第 1 步「安全态」只在上电复位时做 —— 动的是安全链,要先讨论。
|
||||||
- 服务器 fw 目录不会自动清理旧镜像(每个 ~1 MB,问题不大)。
|
- 服务器 fw 目录不会自动清理旧镜像(每个 ~1 MB,问题不大)。
|
||||||
|
|||||||
+16
-3
@@ -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/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/` | 25 例(pytest),本机与服务器(Python 3.8)都跑 |
|
| `backend/tests/` | 39 例(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 · 每日备份 |
|
||||||
|
|
||||||
@@ -24,10 +24,23 @@
|
|||||||
4. **只绑 127.0.0.1**:`X-Auth-User`(门户覆盖式注入)按 L2 信任,只用于审计「谁按的」;`main.py` 拒绝非回环绑定。
|
4. **只绑 127.0.0.1**:`X-Auth-User`(门户覆盖式注入)按 L2 信任,只用于审计「谁按的」;`main.py` 拒绝非回环绑定。
|
||||||
5. **命令白名单在 `protocol.build_command`**:`wifi` / `mqtt`(只收 USB)与 `events`(服务器自己同步)不给界面。板子的校验仍是唯一权威。
|
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/<sha256 前 16 位>.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
|
```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 frontend && pnpm dev # http://localhost:5173,/api 反代到 7764
|
||||||
cd backend && python -m pytest -q tests
|
cd backend && python -m pytest -q tests
|
||||||
```
|
```
|
||||||
|
|||||||
+52
-1
@@ -17,6 +17,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from . import protocol
|
from . import protocol
|
||||||
from .db import now_ms
|
from .db import now_ms
|
||||||
|
from .firmware import SLOT_BYTES, FwError, FwStore
|
||||||
from .hub import CmdTimeout, Hub
|
from .hub import CmdTimeout, Hub
|
||||||
from .recovery import Params, RecoveryManager
|
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,
|
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 里已经删了,服务器上的旧版两种都认。
|
# 用 lifespan 而不是 on_event:后者在新版 Starlette 里已经删了,服务器上的旧版两种都认。
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app):
|
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)
|
_board_or_404(hub, board)
|
||||||
return hub.db.history(board, rail, start, end or now_ms())
|
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")
|
@app.get("/api/events")
|
||||||
def events(board: Optional[str] = None, limit: int = 200, before: Optional[int] = None):
|
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)
|
d.update(code=code, name=zh, severe=d["type"] in protocol.EVENT_SEVERE)
|
||||||
if d["type"] == 13:
|
if d["type"] == 13:
|
||||||
d["name"] += "(%s)" % protocol.CMD_NAMES.get(d["arg"], d["arg"])
|
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)
|
out.append(d)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ CREATE TABLE IF NOT EXISTS recovery_log (
|
|||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY, value TEXT NOT NULL
|
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 起):
|
生产(服务器上,systemd 起):
|
||||||
python -m prrack.main --db /root/phonerail-rack/data/rack.db \\
|
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 块模拟板子,每秒一条遥测):
|
本机开发(不连 broker,起 N 块模拟板子,每秒一条遥测):
|
||||||
python -m prrack.main --sim 2 --db dev.db --static ../frontend/dist
|
python -m prrack.main --sim 2 --db dev.db --static ../frontend/dist
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ import uvicorn
|
|||||||
from .api import create_app
|
from .api import create_app
|
||||||
from .bus import MemBroker, MemBus, PahoBus
|
from .bus import MemBroker, MemBus, PahoBus
|
||||||
from .db import Db, now_ms
|
from .db import Db, now_ms
|
||||||
|
from .firmware import FwStore
|
||||||
from .hub import Hub
|
from .hub import Hub
|
||||||
from .recovery import RecoveryManager
|
from .recovery import RecoveryManager
|
||||||
from .simboard import HUNG, SimBoard
|
from .simboard import HUNG, SimBoard
|
||||||
@@ -74,7 +76,8 @@ def build(args):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
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
|
||||||
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +100,10 @@ def main(argv=None):
|
|||||||
ap.add_argument("--mqtt-host", default="127.0.0.1")
|
ap.add_argument("--mqtt-host", default="127.0.0.1")
|
||||||
ap.add_argument("--mqtt-port", type=int, default=1884)
|
ap.add_argument("--mqtt-port", type=int, default=1884)
|
||||||
ap.add_argument("--mqtt-env", default="/root/phonerail/mqtt.env")
|
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 块模拟板子")
|
ap.add_argument("--sim", type=int, default=0, help="不连 broker,起 N 块模拟板子")
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
if not ipaddress.ip_address(args.bind).is_loopback:
|
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 为换固件重启
|
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 / 硬件故障)。
|
# 严重程度:界面上标红的那些(会断电 / 断 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_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",
|
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.fan_floor = 0
|
||||||
self.received: List[dict] = []
|
self.received: List[dict] = []
|
||||||
self.drop_next_replies = 0 # 模拟网络丢回应(测试重传)
|
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
|
bus.on_message = self._on_message
|
||||||
self._ev(0, 1, 0) # BOOT
|
self._ev(0, 1, 0) # BOOT
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ class SimBoard:
|
|||||||
dt = now_ms - self.now if self.now else 0
|
dt = now_ms - self.now if self.now else 0
|
||||||
self.now = now_ms
|
self.now = now_ms
|
||||||
self.up_ms += dt
|
self.up_ms += dt
|
||||||
|
self._ota_step()
|
||||||
for r in self.rails:
|
for r in self.rails:
|
||||||
if r.act != "idle" and now_ms >= r.busy_until:
|
if r.act != "idle" and now_ms >= r.busy_until:
|
||||||
self._finish(r)
|
self._finish(r)
|
||||||
@@ -94,6 +98,30 @@ class SimBoard:
|
|||||||
r.en = True if r.act == "hr-off" else r.en
|
r.en = True if r.act == "hr-off" else r.en
|
||||||
r.act = "idle"
|
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):
|
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])
|
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,
|
"fan": {"duty": max(300, self.fan_floor), "floor": self.fan_floor, "en": True,
|
||||||
"rpm": [3300, 3280], "stall": [False, False]}, "derate": False,
|
"rpm": [3300, 3280], "stall": [False, False]}, "derate": False,
|
||||||
"safety": {"cycles": self.up_ms // 20, "skipped": 0, "i2c_err": 0, "events": 0},
|
"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}}
|
"cmd": {"lines": len(self.received), "dups": 0, "rej": 0}}
|
||||||
|
|
||||||
def _on_message(self, t: str, payload: bytes):
|
def _on_message(self, t: str, payload: bytes):
|
||||||
@@ -152,6 +180,26 @@ class SimBoard:
|
|||||||
return {"ok": True, "floor": self.fan_floor}
|
return {"ok": True, "floor": self.fan_floor}
|
||||||
if req.get("seq") is None:
|
if req.get("seq") is None:
|
||||||
return {"ok": False, "err": "no_seq"}
|
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")
|
rail = req.get("rail")
|
||||||
if not isinstance(rail, int) or not 1 <= rail <= 5:
|
if not isinstance(rail, int) or not 1 <= rail <= 5:
|
||||||
return {"ok": False, "err": "range"}
|
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
|
||||||
@@ -10,6 +10,7 @@ ExecStart=/root/phonerail-rack/venv/bin/python -m prrack.main \
|
|||||||
--db /root/phonerail-rack/data/rack.db \
|
--db /root/phonerail-rack/data/rack.db \
|
||||||
--static /root/phonerail-rack/frontend/dist \
|
--static /root/phonerail-rack/frontend/dist \
|
||||||
--mqtt-env /root/phonerail/mqtt.env \
|
--mqtt-env /root/phonerail/mqtt.env \
|
||||||
|
--fw-dir /root/phonerail/fw \
|
||||||
--bind 127.0.0.1 --port 7764
|
--bind 127.0.0.1 --port 7764
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<router-link to="/events">事件</router-link>
|
<router-link to="/events">事件</router-link>
|
||||||
<router-link to="/audit">操作记录</router-link>
|
<router-link to="/audit">操作记录</router-link>
|
||||||
<router-link to="/recovery">自动恢复</router-link>
|
<router-link to="/recovery">自动恢复</router-link>
|
||||||
|
<router-link to="/firmware">固件</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="conn">
|
<div class="conn">
|
||||||
<span :class="['dot', state.ws ? 'up' : 'down']" :title="state.ws ? '已连上服务器' : '与服务器断开,重连中'" />
|
<span :class="['dot', state.ws ? 'up' : 'down']" :title="state.ws ? '已连上服务器' : '与服务器断开,重连中'" />
|
||||||
|
|||||||
@@ -29,6 +29,25 @@ export const api = {
|
|||||||
recovery: () => req('GET', '/api/recovery'),
|
recovery: () => req('GET', '/api/recovery'),
|
||||||
setRecoveryParams: p => req('PUT', '/api/recovery/params', p),
|
setRecoveryParams: p => req('PUT', '/api/recovery/params', p),
|
||||||
resetRecovery: (board, rail) => req('POST', `/api/recovery/${board}/${rail}/reset`),
|
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 的链路层错误)
|
// 板子回的错误码 → 人话(firmware/main/cmd.h + ulink/mlink 的链路层错误)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<span class="ota">
|
||||||
|
<template v-if="o.st === 'dl'">
|
||||||
|
<el-progress :percentage="pct" :stroke-width="10" style="width: 140px" />
|
||||||
|
<span class="muted">下载中</span>
|
||||||
|
</template>
|
||||||
|
<el-tag v-else-if="o.st === 'ready'" type="warning" size="small" title="已校验、已切启动分区,下次复位生效">已就绪 · 待重启</el-tag>
|
||||||
|
<el-tag v-if="o.pend" type="warning" size="small" title="新固件还没连上 broker;120 s 内连不上会自动回滚">待验证</el-tag>
|
||||||
|
<el-tag v-if="o.err && o.st !== 'dl'" type="danger" size="small">上次失败:{{ OTA_ERR_ZH[o.err] || o.err }}</el-tag>
|
||||||
|
<el-tag v-if="o.bad" type="danger" size="small" effect="plain" title="这份固件在板子上没通过自证健康,被回滚掉了">回滚过:{{ o.bad }}</el-tag>
|
||||||
|
<span v-if="!busy && !o.pend && !o.err && !o.bad && o.st !== 'ready'" class="muted">—</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 一块板的 OTA 状态,直接读遥测里的 `ota`(firmware/main/cmd_report.c 的 ota_obj)。
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { OTA_ERR_ZH } from '../api'
|
||||||
|
|
||||||
|
const props = defineProps({ status: Object })
|
||||||
|
const o = computed(() => props.status?.ota || {})
|
||||||
|
const busy = computed(() => o.value.st === 'dl')
|
||||||
|
const pct = computed(() => (o.value.size ? Math.floor((o.value.got / o.value.size) * 100) : 0))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ota { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,7 @@ import Board from './pages/Board.vue'
|
|||||||
import Events from './pages/Events.vue'
|
import Events from './pages/Events.vue'
|
||||||
import Audit from './pages/Audit.vue'
|
import Audit from './pages/Audit.vue'
|
||||||
import Recovery from './pages/Recovery.vue'
|
import Recovery from './pages/Recovery.vue'
|
||||||
|
import Firmware from './pages/Firmware.vue'
|
||||||
import { connect } from './store'
|
import { connect } from './store'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -20,6 +21,7 @@ const router = createRouter({
|
|||||||
{ path: '/events', component: Events },
|
{ path: '/events', component: Events },
|
||||||
{ path: '/audit', component: Audit },
|
{ path: '/audit', component: Audit },
|
||||||
{ path: '/recovery', component: Recovery },
|
{ path: '/recovery', component: Recovery },
|
||||||
|
{ path: '/firmware', component: Firmware },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
<div class="info">
|
<div class="info">
|
||||||
<el-tag v-if="!b.online || b.stale" type="info">通信中断 · 最后数据 {{ ago(b.last_tele) }}</el-tag>
|
<el-tag v-if="!b.online || b.stale" type="info">通信中断 · 最后数据 {{ ago(b.last_tele) }}</el-tag>
|
||||||
<el-tag v-else type="success">在线</el-tag>
|
<el-tag v-else type="success">在线</el-tag>
|
||||||
<span class="muted">ID {{ b.id }} · 固件 {{ b.fw }} · IP {{ b.ip }}</span>
|
<span class="muted">ID {{ b.id }} · 固件 {{ b.status?.fw || b.fw }} · IP {{ b.ip }}</span>
|
||||||
|
<OtaState :status="b.status" />
|
||||||
|
<router-link to="/firmware" class="muted">推固件 →</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats" v-if="b.status">
|
<div class="stats" v-if="b.status">
|
||||||
<div><span class="muted">输入</span><b>{{ V(b.status.in?.mv) }} V</b><b>{{ mA(b.status.in?.ua) }} mA</b></div>
|
<div><span class="muted">输入</span><b>{{ V(b.status.in?.mv) }} V</b><b>{{ mA(b.status.in?.ua) }} mA</b></div>
|
||||||
@@ -63,6 +65,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import RailCard from '../components/RailCard.vue'
|
import RailCard from '../components/RailCard.vue'
|
||||||
import HistoryChart from '../components/HistoryChart.vue'
|
import HistoryChart from '../components/HistoryChart.vue'
|
||||||
|
import OtaState from '../components/OtaState.vue'
|
||||||
import { api, rspText } from '../api'
|
import { api, rspText } from '../api'
|
||||||
import { state, onMessage } from '../store'
|
import { state, onMessage } from '../store'
|
||||||
import { mA, V, C, ago, fmtTime } from '../util'
|
import { mA, V, C, ago, fmtTime } from '../util'
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fw">
|
||||||
|
<div class="card">
|
||||||
|
<div class="ttl">
|
||||||
|
固件库
|
||||||
|
<el-button type="primary" size="small" :loading="uploading" @click="pick">上传 .bin</el-button>
|
||||||
|
<input ref="file" type="file" accept=".bin" style="display: none" @change="upload" />
|
||||||
|
</div>
|
||||||
|
<div class="muted hint">
|
||||||
|
传 <code>firmware/build/phonerail.bin</code>(不是 .elf,也不是合并镜像)。服务器会核镜像头:不是 PhoneRail 的固件、或大于一个 OTA 槽({{ slotMB }} MB)直接拒。
|
||||||
|
</div>
|
||||||
|
<el-table :data="items" size="small" empty-text="还没有上传过固件">
|
||||||
|
<el-table-column label="版本" min-width="120">
|
||||||
|
<template #default="{ row }"><b>{{ row.version }}</b></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="sha256" width="160">
|
||||||
|
<template #default="{ row }"><code :title="row.sha256">{{ row.sha256.slice(0, 16) }}</code></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="大小" width="90"><template #default="{ row }">{{ kb(row.size) }}</template></el-table-column>
|
||||||
|
<el-table-column label="编译于" width="170" prop="built" />
|
||||||
|
<el-table-column label="上传" min-width="150">
|
||||||
|
<template #default="{ row }">{{ row.uploaded_by }} · {{ fmtTime(row.uploaded) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="" width="170" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="primary" :disabled="!row.present" @click="openPush(row)">推送…</el-button>
|
||||||
|
<el-button size="small" text type="danger" @click="remove(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ttl">各板状态</div>
|
||||||
|
<el-table :data="boards" size="small" empty-text="没有板子">
|
||||||
|
<el-table-column label="板子" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<router-link :to="`/board/${row.id}`">{{ row.name || row.id }}</router-link>
|
||||||
|
<el-tag v-if="!row.online || row.stale" type="info" size="small" style="margin-left: 6px">离线</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="当前固件" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.status?.fw || row.fw || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="OTA" min-width="260">
|
||||||
|
<template #default="{ row }"><OtaState :status="row.status" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="开着的路" width="130">
|
||||||
|
<template #default="{ row }">{{ railsOn(row).length ? railsOn(row).map(r => '#' + r).join(' ') : '无' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="dlg" :title="`推送 ${target?.version || ''}`" width="min(560px, 94vw)" :close-on-click-modal="false">
|
||||||
|
<div class="muted" style="margin-bottom: 8px">
|
||||||
|
板子自己从 <code>{{ target?.url }}</code> 下载,sha256 对得上才切分区。
|
||||||
|
</div>
|
||||||
|
<el-checkbox-group v-model="chosen" class="boards">
|
||||||
|
<el-checkbox v-for="b in boards" :key="b.id" :value="b.id" :disabled="!pushable(b)">
|
||||||
|
{{ b.name || b.id }}
|
||||||
|
<span class="muted">{{ b.status?.fw || b.fw }}{{ why(b) ? ' · ' + why(b) : '' }}</span>
|
||||||
|
</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
|
||||||
|
<div class="reboot">
|
||||||
|
<el-switch v-model="reboot" />
|
||||||
|
<span>校验通过后<b>立刻重启</b></span>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="reboot" type="error" :closable="false" show-icon>
|
||||||
|
<template #title>重启 = 这块板上所有开着的手机<b>硬断电一次</b></template>
|
||||||
|
ESP 复位时各路 EN 被下拉,起来后按原来的开关状态逐路恢复(约 1 秒)。
|
||||||
|
<div v-for="b in chosenBoards" :key="b.id">
|
||||||
|
{{ b.name || b.id }}:{{ railsOn(b).length ? `${railsOn(b).length} 台会断电(${railsOn(b).map(r => '#' + r).join(' ')})` : '没有开着的路' }}
|
||||||
|
</div>
|
||||||
|
</el-alert>
|
||||||
|
<div v-else class="muted">不重启:下次复位(断电重启 / 看门狗 / 再推一次并勾选重启)时生效。已就绪的同一镜像再推一次不会重新下载。</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dlg = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!chosen.length" :loading="pushing" @click="push">
|
||||||
|
推送到 {{ chosen.length }} 块板{{ reboot ? '并重启' : '' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import OtaState from '../components/OtaState.vue'
|
||||||
|
import { api, ERR_ZH } from '../api'
|
||||||
|
import { state, onMessage } from '../store'
|
||||||
|
import { fmtTime } from '../util'
|
||||||
|
|
||||||
|
const items = ref([])
|
||||||
|
const slot = ref(0x300000)
|
||||||
|
const slotMB = computed(() => slot.value / 1048576)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const file = ref(null)
|
||||||
|
const dlg = ref(false)
|
||||||
|
const target = ref(null)
|
||||||
|
const chosen = ref([])
|
||||||
|
const reboot = ref(false)
|
||||||
|
const pushing = ref(false)
|
||||||
|
|
||||||
|
const boards = computed(() => Object.values(state.boards).sort((a, b) => a.id.localeCompare(b.id)))
|
||||||
|
const chosenBoards = computed(() => boards.value.filter(b => chosen.value.includes(b.id)))
|
||||||
|
const kb = n => (n / 1024).toFixed(0) + ' KB'
|
||||||
|
const railsOn = b => (b.status?.rails || []).filter(r => r.en).map(r => r.r)
|
||||||
|
|
||||||
|
// 板子这一刻能不能推:离线 / 正在下载 / 新固件还没自证(备用槽里是回滚用的旧版)都不行
|
||||||
|
function why(b) {
|
||||||
|
if (!b.online || b.stale) return '离线'
|
||||||
|
const o = b.status?.ota || {}
|
||||||
|
if (o.st === 'dl') return '正在下载'
|
||||||
|
if (o.pend) return '新固件待验证'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const pushable = b => !why(b)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const r = await api.firmware()
|
||||||
|
items.value = r.items
|
||||||
|
slot.value = r.slot
|
||||||
|
} catch (e) { ElMessage.error(e.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick() { file.value.value = ''; file.value.click() }
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
const f = file.value.files[0]
|
||||||
|
if (!f) return
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const row = await api.uploadFirmware(f)
|
||||||
|
ElMessage.success(`已上传 ${row.version}`)
|
||||||
|
await load()
|
||||||
|
} catch (e) { ElMessage.error(e.message) } finally { uploading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`删除 ${row.version}(${row.sha256.slice(0, 16)})?正在下载它的板子会失败。`, '删除固件', { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
try { await api.deleteFirmware(row.sha256); await load() } catch (e) { ElMessage.error(e.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPush(row) {
|
||||||
|
target.value = row
|
||||||
|
chosen.value = boards.value.filter(b => pushable(b) && (b.status?.fw || b.fw) !== row.version).map(b => b.id)
|
||||||
|
reboot.value = false
|
||||||
|
dlg.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function push() {
|
||||||
|
if (reboot.value) {
|
||||||
|
const n = chosenBoards.value.reduce((s, b) => s + railsOn(b).length, 0)
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
n ? `${n} 台手机会断电一次。确定推送并立刻重启?` : '确定推送并立刻重启?(没有开着的路)',
|
||||||
|
'确认重启', { type: 'warning', confirmButtonText: '推送并重启' })
|
||||||
|
} catch { return }
|
||||||
|
}
|
||||||
|
pushing.value = true
|
||||||
|
const results = []
|
||||||
|
for (const id of chosen.value) {
|
||||||
|
try {
|
||||||
|
const rsp = await api.ota(id, target.value.sha256, reboot.value)
|
||||||
|
results.push(rsp.ok ? `${id}:${{ start: '开始下载', reboot: '已就绪,马上重启', ready: '已就绪(没变)' }[rsp.act] || '成功'}`
|
||||||
|
: `${id}:${rsp.err === 'state' ? '新固件待验证 / 板子不支持 OTA' : ERR_ZH[rsp.err] || rsp.err}`)
|
||||||
|
} catch (e) { results.push(`${id}:${e.message}`) }
|
||||||
|
}
|
||||||
|
pushing.value = false
|
||||||
|
dlg.value = false
|
||||||
|
ElMessageBox.alert(results.join('\n'), '推送结果', { customStyle: { whiteSpace: 'pre-line' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const off = onMessage(m => { if (m.t === 'firmware') load() })
|
||||||
|
onMounted(load)
|
||||||
|
onBeforeUnmount(off)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fw { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.fw .card { min-width: 0; overflow: hidden; }
|
||||||
|
.ttl { font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.hint { margin-bottom: 8px; }
|
||||||
|
code { font-size: 12px; background: #f1f5f9; padding: 1px 4px; border-radius: 4px; word-break: break-all; }
|
||||||
|
.boards { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; margin-bottom: 12px; }
|
||||||
|
.boards .muted { margin-left: 6px; }
|
||||||
|
.reboot { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
|
||||||
|
</style>
|
||||||
在新工单中引用
屏蔽一个用户