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 行删除
+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
+13
查看文件
@@ -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 ''
);
""" """
+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 起): 生产(服务器上,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:
+6 -1
查看文件
@@ -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",
+49 -1
查看文件
@@ -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"}
+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
+1
查看文件
@@ -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
+1
查看文件
@@ -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 ? '已连上服务器' : '与服务器断开,重连中'" />
+19
查看文件
@@ -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 的链路层错误)
+28
查看文件
@@ -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>
+2
查看文件
@@ -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 },
], ],
}) })
+4 -1
查看文件
@@ -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'
+194
查看文件
@@ -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>