固件 C2:OTA 双槽 + 失败回滚(已上板实测正常 / 哈希错 / 回滚三条路径)

- 分区重排(16 MB,nvs 0x9000 不动):ota_0 / ota_1 各 3 MB、otadata 0x610000、evlog 挪到 0x612000;
  flash 大小改正为 16 MB;开 CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
- ota.{h,c}(宿主机可测):URL / sha256 校验、判定(下载中 busy、待验证拒、同镜像不重下)、健康判据
- ota_esp.{h,c}:t_ota 任务经 HTTP 下载、边下边算 sha256、核 project_name 后切启动分区;
  新固件 120 s 内连不上 broker 就回滚(t_link 判 + esp_timer 兜底);重启前等回应发出、事件落盘
- 新命令 ota = 13(两条链路都收,reboot 默认 false:复位 = 五台手机断电一次);status 加 ota 一块;
  新事件 LOG_EV_OTA = 18;PROJECT_VER 取 PHONERAIL_FW_VERSION
- test_ota.c 13 例,全工程 442 例全绿;版本 0.1.0-c2
- 服务器:nginx 7765 只放行 /fw/<16 hex>.bin(不过门户,路由器转发已加);tools/ota_push.py 推送;
  rack protocol.py 加事件 18 / 命令 13(已部署)
- 板 B:推送到自证健康 19 s;错哈希不切分区;删掉 broker 账号后 120 s 回滚,bad 报出被回滚的版本

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-27 14:03:13 +08:00
共同撰写人 Claude Opus 5.5
父节点 c37c08aa43
当前提交 ae1aad3b7c
共修改 29 个文件,包含 1561 行新增和 28 行删除
+8 -1
查看文件
@@ -1,10 +1,17 @@
# 进度与交接
## ⭐ 2026-09-27 夜:固件 C2 OTA 完成(双槽 + 失败回滚),已上板
- `ota` 命令给 URL + sha256 + size,板子经 HTTP(服务器 nginx 7765,不过门户)下载,哈希对上才切分区;新固件 120 s 内连不上 broker 就回滚。
- 板 B 实测三条路径都对:正常推送 19 s、错哈希不切、断掉 broker 账号后 120 s 回滚。现跑 **0.1.0-c2**(经 OTA 推上去的)。全工程 442 例全绿。
- ⚠️ OTA 重启 / 回滚 = **五台手机硬断电一次**(ESP 复位 EN 下拉),所以 `reboot` 默认 false。「重启不断电」要动安全链,待讨论。
- 推固件:`python firmware/tools/ota_push.py <id> [--reboot]`;rack 界面还没 OTA 按钮。详见 `firmware/HANDOFF.md`。
## ⭐ 2026-09-27 夜:固件 A16(风扇闭环 + NTC/TACH 真实采样)完成 ⇒ A1~A16 齐了
- 新增 `firmware/main/ntc.{h,c}`(R-T 表换算,宿主机 4 例)与 `thermal_esp.{h,c}`(ADC1 + PCNT);任务体接上,status 带温度 / 风扇。全工程 429 例全绿。
- 板 B 已烧 **0.1.0-a16**:两路 NTC 38.2 / 34.0 °C(合理);两路风扇 `rpm 0` 报停转 —— 拉满占空比输入电流不涨,**多半是风扇没插**,待确认。
- 分区只剩 9%(C2 前必须重排)。详见 `firmware/HANDOFF.md`。
- ~~分区只剩 9%~~ ✅ C2 已重排(双槽各 3 MB)。
## ⭐ 2026-09-27 晚:固件 A15(启动顺序 + 降级矩阵)完成,已上板
+11
查看文件
@@ -3,5 +3,16 @@
# & D:\Users\35138\esp\v5.4\esp-idf\export.ps1 ; idf.py build
cmake_minimum_required(VERSION 3.16)
# C2:镜像头里的版本号(esp_app_desc_t.version)取 phonerail.h 的 PHONERAIL_FW_VERSION。
# 不设的话 IDF 用 `git describe`(形如 `79c9f8b-dirty`),OTA 回滚后 status 的 `ota.bad`
# 报出来的就是它 —— 对不上任何一个固件版本。
file(STRINGS "${CMAKE_CURRENT_LIST_DIR}/main/phonerail.h" _fw_ver_line
REGEX "^#define PHONERAIL_FW_VERSION \"[^\"]+\"")
string(REGEX MATCH "\"([^\"]+)\"" _ "${_fw_ver_line}")
if(NOT CMAKE_MATCH_1)
message(FATAL_ERROR "main/phonerail.h 里找不到 PHONERAIL_FW_VERSION")
endif()
set(PROJECT_VER "${CMAKE_MATCH_1}")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(phonerail)
+49 -8
查看文件
@@ -1,16 +1,57 @@
# 交接
更新:2026-09-27 完成 **A16**(风扇闭环接进任务体 + NTC / TACH 的真实采样)。**A1~A16 全部完成 ⇒ M1 的固件部分齐了**
更新:2026-09-27 完成 **C2 OTA**(双槽 + 失败回滚,已上板实测正常 / 哈希错 / 回滚三条路径)
## 一句话现状
`t_thermal` 的判据在 R-6 就写好测过了;A16 补的是它两个回调的真实来源(NTC 走 ADC1、TACH 走 PCNT)和任务体。
**25 个 ctest、429 个用例全绿**,`idf.py build` 通过(**0xE9920,分区只剩 9%**)。板 B(COM5)已烧 **0.1.0-a16**。
`ota` 命令(经 MQTT 或 USB)给 URL + sha256 + size,板子自己经 HTTP 下载、边下边算哈希、对上才切启动分区;
新固件 120 s 内连不上 broker 就回滚。**26 个 ctest、442 个用例全绿**,`idf.py build` 通过(0xF32E0,槽 3 MB,余 68%)。
板 B(COM5)现在跑 **0.1.0-c2**(最后一次就是经 OTA 推上去的)。定稿写在需求 §10.8「C2 定稿的 OTA」。
## 下一步
- ⚠️ **风扇待你确认**:板 B 上两路都报 `rpm 0` + 停转;拉到 100 % 时输入电流**没有上升**(130 → 110 mA,属波动)
⇒ 多半是**风扇没插 / 没转**,而不是 TACH 没数到。插上风扇后看 `status.fan.rpm`;若在转却仍是 0,查 Q9/Q10 与 PCNT。
## 怎么推固件
```
python tools/ota_push.py b81f3fc9c8bc # 推 build/phonerail.bin:只下载 + 校验 + 切分区,下次复位生效
python tools/ota_push.py b81f3fc9c8bc --reboot # 校验过就立刻重启 —— ⚠️ 五台手机硬断电一次
python tools/ota_push.py b81f3fc9c8bc --status
```
它把镜像传到服务器 `/root/phonerail/fw/<sha 前 16 位>.bin`(nginx 7765,只放行这种文件名,不过门户),
再在服务器上用后端账号经 1884 发命令、盯遥测。**rack 管理界面还没有 OTA 按钮**(下一步可以做)。
## 下一步(没人定,候选)
- rack 界面加「推固件」:上传 .bin → 后端算哈希、放进 fw 目录、发 `ota`;界面上 reboot 要二次确认(五路全断)。
- 「重启不断电」:ESP 复位时 EN 被下拉 ⇒ OTA / 回滚都会让五台手机掉电一次。要避免得在复位前 `gpio_hold_en` 各路 EN、
并改 bringup 第 1 步「安全态」只在上电复位时做 —— 动的是安全链,要先讨论。
- 服务器 fw 目录不会自动清理旧镜像(每个 ~1 MB,问题不大)。
## C2 改了什么
- `partitions.csv` 重排(16 MB):**nvs 0x9000 没动**;ota_0 / ota_1 各 3 MB;otadata 0x610000;evlog 挪到 0x612000(历史事件清空了一次)。
- `sdkconfig.defaults` + `sdkconfig`:flash 16 MB(之前误写 8 MB);`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`(bootloader 配置,只有 USB 整片烧才生效)。
- `main/ota.{h,c}` **新**(宿主机可测):URL / 哈希校验、判定(busy / 待验证拒 / 同镜像不重下)、健康判据。
- `main/ota_esp.{h,c}` **新**:`t_ota` 任务(esp_http_client + mbedtls sha256 + `OTA_WITH_SEQUENTIAL_WRITES`,每块让一个 tick)、
核 project_name、切分区;`t_link` 里判健康 / 到点重启(先等 2 s 让回应发出去、事件落盘);esp_timer 150 s 兜底回滚。
- `cmd` 新命令 `ota`=13(`cmd_net.c` 的 `do_ota`);status 多 `ota` 一块;`evlog` 新事件 `LOG_EV_OTA`=18。
- 顶层 `CMakeLists.txt`:`PROJECT_VER` 取 `PHONERAIL_FW_VERSION`(否则镜像头版本是 git describe,回滚后 `ota.bad` 报 `79c9f8b-dirty`)。
- `test/test_ota.c` **新**(13 例);`test_cmd` / `test_evlog` 的冻结数字表各加一行。
- `tools/ota_push.py` **新**;`rack/deploy/nginx-phonerail-fw.conf` **新**(已部署);`rack/backend/prrack/protocol.py` 加事件 18 / 命令 13(已部署)。
## 上板实测(板 B,五槽空)
```
正常:c2 → c2-t1 经 MQTT --reboot:下载 ≈8 s,推送到自证健康 19 s;事件 18/1 → 18/2 → 18/6 → BOOT → 18/4
哈希错:err "hash"、got 996064、固件不变;下载 20 s 里 t_safety skipped 0
回滚:服务器删掉板子 broker 账号 → USB 发同镜像 reboot:true → 起在 c2-rb(pend true)→ 120 075 ms 记 18/5 → 回到 c2-t1、bad 报版本
(账号已原样恢复,备份已删)
收尾:c2-t1 → 正式 0.1.0-c2 经 MQTT --reboot 成功
```
## 踩到的坑(只写会再犯的)
- **路由器不是整机 DMZ**:新开的 7765 公网 TCP 都连不上,用户手动加了转发才通(本机测公网要 `curl --noproxy '*'`,
否则 7890 代理回的 502 会被当成服务器的)。
- 本机 PowerShell `Set-Content -Encoding ascii` 改 `phonerail.h` 把中文注释写成 `??`(已 `git checkout` 还原)—— 改它一律用 sed。
- Git Bash heredoc 里的 Python 写 `'\0'` 会变成真 NUL 字节进源码;含反斜杠的替换写成 .py 文件再跑。
- 宿主机 `bash host/test.sh` 用 `-j 4` 在 cygwin 上偶发「link: Subprocess aborted」,`cmake --build build -j 1` 就过。
## 其它挂着的事
- **风扇还没下单购买**(2026-09-27):板 B 两路报 `rpm 0` + 停转是预期的。到货插上后看 `status.fan.rpm`;在转却仍是 0 再查 Q9/Q10 与 PCNT。
- 风扇闭环四个阈值 + 占空比下限 + PPR 都是「待上板实测」(t_thermal.h),手机上架跑满载之后再定。
- **分区只剩 9%**:C2(OTA 双分区)前必须重排分区表(模组 16 MB,sdkconfig 只按 8 MB 用)。
- 仍欠(A15 留下的):I²C 总线挂死恢复(HAL 层);PD 上限的降载动作(§8.3);CH224A 读到 100 W 与早上记的 20V/3A 不一致,待核。
- A15 留下的:I²C 总线挂死恢复(HAL 层);PD 上限的降载动作(§8.3);CH224A 读到 100 W 与早上记的 20V/3A 不一致,待核。
## 本次新增/修改的文件
- `main/ntc.{h,c}` **新**(宿主机可测)— mV → Ω → 查 Murata NCP18XH103F03RB 的 R-T 表(−40~125 °C、5 °C 一格、格内线性插值);
+1
查看文件
@@ -42,6 +42,7 @@ set(PHONERAIL_CORE_SRCS
${FW_ROOT}/main/mlink.c
${FW_ROOT}/main/bringup.c
${FW_ROOT}/main/ntc.c
${FW_ROOT}/main/ota.c
)
# HAL:接口在 include/,宿主机链 sim/ 的实现(esp/ 那份只有 IDF 目标编)。
+5 -2
查看文件
@@ -1,5 +1,5 @@
# ESP-IDF 侧的 main 组件。
# app_main.c / ulink_esp.c / mlink_esp.c / thermal_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过
# app_main.c / ulink_esp.c / mlink_esp.c / thermal_esp.c / ota_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过
# (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。
idf_component_register(
SRCS "app_main.c"
@@ -27,8 +27,11 @@ idf_component_register(
"bringup.c"
"ntc.c"
"thermal_esp.c"
"ota.c"
"ota_esp.c"
INCLUDE_DIRS "."
EMBED_TXTFILES "certs/evse_ca.pem"
REQUIRES phonerail_hal drv esp_driver_usb_serial_jtag
esp_wifi esp_netif esp_event esp_timer nvs_flash mqtt
esp_adc esp_driver_pcnt)
esp_adc esp_driver_pcnt
app_update esp_http_client mbedtls)
+10
查看文件
@@ -31,6 +31,7 @@
#include "mlink_esp.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "ota_esp.h"
#include "ulink_esp.h"
static const char *TAG = "phonerail";
@@ -226,6 +227,7 @@ static void task_link(void *arg)
for (;;) {
ulink_esp_poll(100); /* 最多等 100 ms 一包;没有 USB 驱动时退化成睡 100 ms */
mlink_esp_poll();
ota_esp_poll(); /* C2:新固件的健康判据 + 到点重启 */
}
}
@@ -369,6 +371,13 @@ void app_main(void)
(int)why);
}
/* C2:OTA。紧跟在事件日志后面:「这是 OTA 后的第一次启动」「有过回滚」要尽早知道,
兜底回滚的定时器也要尽早上弦(后面任何一步卡死都要能回滚)。失败不 abort。 */
err = ota_esp_init();
if (err != HAL_OK) {
ESP_LOGE(TAG, "OTA 不可用:%s(分区表不是双槽?)", hal_err_str(err));
}
nvs_up();
s_want = want_load();
@@ -442,6 +451,7 @@ void app_main(void)
spawn(TASK_ACTUATOR, task_actuator);
spawn(TASK_LINK, task_link);
spawn(TASK_LOG, task_log);
ota_esp_boot_done(); /* 健康判据的前一半:启动序列走完了 */
ESP_LOGI(TAG, "五个任务已启动(t_safety %u ms / t_thermal %u ms),"
"I²C 预算 %u us 每周期",
+2 -2
查看文件
@@ -13,7 +13,7 @@ static const char *const k_names[CMD_COUNT] = {
[CMD_POWER_CYCLE] = "power_cycle", [CMD_KEY] = "key", [CMD_EDL] = "edl",
[CMD_VBUS] = "vbus", [CMD_FAN] = "fan", [CMD_EVENTS] = "events",
[CMD_POWER] = "power", [CMD_WIFI] = "wifi", [CMD_MQTT] = "mqtt",
[CMD_NET] = "net",
[CMD_NET] = "net", [CMD_OTA] = "ota",
};
static const char *const k_errs[CMD_ERR_COUNT] = {
@@ -105,7 +105,7 @@ static const handler_t k_handlers[CMD_COUNT] = {
[CMD_POWER_CYCLE] = do_power_cycle, [CMD_KEY] = do_key, [CMD_EDL] = do_edl,
[CMD_VBUS] = do_vbus, [CMD_FAN] = do_fan, [CMD_EVENTS] = do_events,
[CMD_POWER] = do_power, [CMD_WIFI] = do_wifi, [CMD_MQTT] = do_mqtt,
[CMD_NET] = do_net,
[CMD_NET] = do_net, [CMD_OTA] = do_ota,
};
static cmd_id_t lookup(const char *name)
+1
查看文件
@@ -58,6 +58,7 @@ typedef enum {
CMD_WIFI = 10, /* A14/C1:写 WiFi 凭据(只收 USB) */
CMD_MQTT = 11, /* A14/C1:写 broker 地址 / 账号 / 遥测周期(只收 USB) */
CMD_NET = 12, /* A14:网络状态(只读,不回显密码) */
CMD_OTA = 13, /* C2:从 URL 下载固件、校验 sha256、切启动分区(ota.h) */
CMD_COUNT,
} cmd_id_t;
+44 -1
查看文件
@@ -1,10 +1,12 @@
/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB**),`net` 读状态。
/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB**),`net` 读状态;
外加 C2 的 `ota`(下载固件,两条链路都收)。
`net` 的回应里**永远没有密码**,只有 `pass_set` / `mpass_set`(netcfg.h「密码永不外泄」)。 */
#include <string.h>
#include "cmd_priv.h"
#include "netcfg.h"
#include "ota.h"
/* 配置命令只收 USB(cmd.h 链路那一段)。宿主机测试用的 CMD_LINK_TEST 视同 USB。 */
static bool usb_only_ok(const ctx_t *c) { return c->link != CMD_LINK_WIFI; }
@@ -105,3 +107,44 @@ cmd_err_t do_net(ctx_t *c)
(unsigned long)st.tele_sent, (unsigned long)st.tele_skipped);
return CMD_OK;
}
/* `ota`(C2):`{"seq":9,"cmd":"ota","url":"http://…","sha256":"<64 位小写>","size":N,"reboot":false}`。
两条链路都收 —— 下载本身走 WiFi,但命令从 USB 来也没有「把唯一的远程通道弄断」的风险:
新固件起不来会自己回滚(ota.h)。
回 ok 时带 `act`:start = 开始下载(进度看 status 的 `ota`)/ reboot = 同一镜像已就绪,
马上重启 / ready = 同一镜像已就绪、这次不要求重启。 */
cmd_err_t do_ota(ctx_t *c)
{
const char *url = NULL;
const char *hex = NULL;
int64_t size = 0;
ota_req_t r;
memset(&r, 0, sizeof(r));
if (cj_get_str(c->o, "url", &url) != 1 || cj_get_str(c->o, "sha256", &hex) != 1 ||
cj_get_int(c->o, "size", &size) != 1 || cj_get_bool(c->o, "reboot", &r.reboot) < 0) {
return CMD_ERR_ARG;
}
if (!ota_url_ok(url) || !ota_parse_sha256(hex, r.sha256)) {
return CMD_ERR_ARG;
}
if (size <= 0 || size > (int64_t)OTA_SLOT_BYTES) {
c->bad = size;
return CMD_ERR_RANGE;
}
memcpy(r.url, url, strlen(url) + 1);
r.size = (uint32_t)size;
ota_status_t st;
ota_status_get(&st);
const ota_action_t a = ota_plan(&st, &r);
const int rc = ota_request(&r);
if (rc == OTA_ACT_BUSY) {
return CMD_ERR_BUSY;
}
if (rc != 0) {
return CMD_ERR_STATE; /* 待验证中(PENDING)/ 没有 OTA 能力 */
}
cj_w(c->w, ",\"act\":\"%s\"",
a == OTA_ACT_START ? "start" : (a == OTA_ACT_REBOOT ? "reboot" : "ready"));
return CMD_OK;
}
+3
查看文件
@@ -32,6 +32,9 @@ cmd_err_t do_wifi(ctx_t *c);
cmd_err_t do_mqtt(ctx_t *c);
cmd_err_t do_net(ctx_t *c);
/* cmd_net.c:OTA(C2,两条链路都收)。 */
cmd_err_t do_ota(ctx_t *c);
/* status 的正文(不含外层的 `{"seq":..,"ok":true` 与结尾 `}`,以逗号开头)。 */
void cmd_report_status(cj_writer_t *w, const cmd_env_t *env);
+31
查看文件
@@ -9,6 +9,7 @@
#include "cmd_priv.h"
#include "evlog.h"
#include "hal_time.h"
#include "ota.h"
#include "pca9555.h"
#include "phonerail.h"
#include "rail.h"
@@ -18,6 +19,35 @@
static const char *b(bool v) { return v ? "true" : "false"; }
/* C2:`"ota":{"st":"dl","got":N,"size":N,"err":null,"pend":false,"bad":null}`。
`err` 是上一次失败的原因(成功 / 没做过是 null);`pend` = 正在跑的固件还没自证健康;
`bad` = 被判无效、回滚掉的那份固件的版本号。版本号来自镜像头,按 ASCII 过滤一遍再写。 */
static void ota_obj(cj_writer_t *w)
{
ota_status_t st;
ota_status_get(&st);
cj_w(w, ",\"ota\":{\"st\":\"%s\",\"got\":%lu,\"size\":%lu,\"err\":", ota_phase_str(st.phase),
(unsigned long)st.got, (unsigned long)st.size);
if (st.err == OTA_E_NONE) {
cj_w(w, "null");
} else {
cj_w(w, "\"%s\"", ota_err_str(st.err));
}
cj_w(w, ",\"pend\":%s,\"bad\":", b(st.pending));
if (st.bad[0] == '\0') {
cj_w(w, "null}");
return;
}
char v[sizeof(st.bad)];
size_t n = 0;
for (const char *s = st.bad; *s != '\0' && n < sizeof(v) - 1; s++) {
const unsigned char ch = (unsigned char)*s;
v[n++] = (ch < 0x20 || ch >= 0x7F || ch == '"' || ch == '\\') ? '?' : (char)ch;
}
v[n] = '\0';
cj_w(w, "\"%s\"}", v);
}
static void rail_obj(cj_writer_t *w, int rail, const safety_snap_t *s)
{
const int i = rail - 1;
@@ -95,6 +125,7 @@ void cmd_report_status(cj_writer_t *w, const cmd_env_t *env)
(unsigned long)s->cycles, (unsigned long)s->skipped,
(unsigned long)s->i2c_errors, (unsigned long)s->events);
}
ota_obj(w);
evlog_stats_t es;
evlog_stats(&es);
cmd_stats_t cs;
+1
查看文件
@@ -572,6 +572,7 @@ const char *evlog_ev_str(log_ev_t ev)
case LOG_EV_SLOT_EMPTY: return "刀片不在位";
case LOG_EV_PD_LIMIT: return "PD限功率";
case LOG_EV_PG_TIMEOUT: return "上电等不到PG";
case LOG_EV_OTA: return "固件升级";
case LOG_EV_COUNT: break;
}
return "?";
+4
查看文件
@@ -77,6 +77,10 @@ typedef enum {
arg:bit0 = 超时那一刻 FLT# 在报;bit1 = 这一片 PCA9555 读不到,改按 INA226 电压判的。 */
LOG_EV_PG_TIMEOUT = 17,
/* C2 新增:OTA 的每一步。arg = `ota_ev_t`(ota.h);失败那一条 bus_mv = `ota_err_t`,
current_ua = 镜像字节数。整板级(rail = 0)。 */
LOG_EV_OTA = 18,
LOG_EV_COUNT,
} log_ev_t;
+189
查看文件
@@ -0,0 +1,189 @@
/* OTA 的平台无关部分(C2)。说明在 ota.h。 */
#include "ota.h"
#include <string.h>
#include "hal_lock.h"
static ota_start_fn s_start;
static ota_reboot_fn s_reboot;
static void *s_ctx;
static ota_status_t s_st;
void ota_init(ota_start_fn start, ota_reboot_fn reboot, void *ctx)
{
hal_crit_enter();
memset(&s_st, 0, sizeof(s_st));
s_start = start;
s_reboot = reboot;
s_ctx = ctx;
hal_crit_exit();
}
bool ota_url_ok(const char *url)
{
static const char k_scheme[] = "http://";
if (url == NULL || strncmp(url, k_scheme, sizeof(k_scheme) - 1) != 0) {
return false;
}
const size_t n = strlen(url);
if (n <= sizeof(k_scheme) - 1 || n > OTA_URL_MAX) {
return false;
}
for (size_t i = 0; i < n; i++) {
const unsigned char ch = (unsigned char)url[i];
if (ch <= 0x20 || ch >= 0x7F || ch == '"' || ch == '\\') {
return false;
}
}
return true;
}
static int hexval(char c)
{
if (c >= '0' && c <= '9') {
return c - '0';
}
if (c >= 'a' && c <= 'f') {
return c - 'a' + 10;
}
return -1; /* 大写也算错:同一个哈希只许一种写法(ota.h) */
}
bool ota_parse_sha256(const char *hex, uint8_t out[32])
{
if (hex == NULL || strlen(hex) != 64) {
return false;
}
uint8_t tmp[32];
for (int i = 0; i < 32; i++) {
const int hi = hexval(hex[2 * i]);
const int lo = hexval(hex[2 * i + 1]);
if (hi < 0 || lo < 0) {
return false;
}
tmp[i] = (uint8_t)((hi << 4) | lo);
}
memcpy(out, tmp, sizeof(tmp)); /* 半截错的不留在 out 里 */
return true;
}
void ota_sha256_hex(const uint8_t sha[32], char out[65])
{
static const char k[] = "0123456789abcdef";
for (int i = 0; i < 32; i++) {
out[2 * i] = k[sha[i] >> 4];
out[2 * i + 1] = k[sha[i] & 0x0F];
}
out[64] = '\0';
}
ota_action_t ota_plan(const ota_status_t *st, const ota_req_t *req)
{
if (st->phase == OTA_DL) {
return OTA_ACT_BUSY;
}
if (st->phase == OTA_READY && memcmp(st->sha256, req->sha256, sizeof(st->sha256)) == 0) {
return req->reboot ? OTA_ACT_REBOOT : OTA_ACT_NOTHING;
}
/* READY 了另一个镜像:重新下载会覆盖同一个备用槽,胶水里会先把启动分区切回当前的
(esp_ota_begin 本身就会让那个槽失效)—— 这里照常开工。 */
if (st->pending) {
return OTA_ACT_PENDING;
}
return OTA_ACT_START;
}
int ota_request(const ota_req_t *req)
{
ota_status_t st;
ota_status_get(&st);
const ota_action_t a = ota_plan(&st, req);
switch (a) {
case OTA_ACT_START:
if (s_start == NULL) {
return -1;
}
/* 先把状态置成 DL 再交任务:命令处理器下一条 status 立刻看得到「在下载」,
也挡住了紧跟着的第二条 ota(它会拿到 BUSY)。 */
st.phase = OTA_DL;
st.err = OTA_E_NONE;
st.got = 0;
st.size = req->size;
memcpy(st.sha256, req->sha256, sizeof(st.sha256));
ota_status_set(&st);
if (s_start(s_ctx, req) != HAL_OK) {
st.phase = OTA_IDLE;
ota_status_set(&st);
return -1;
}
return 0;
case OTA_ACT_REBOOT:
if (s_reboot == NULL) {
return -1;
}
s_reboot(s_ctx);
return 0;
case OTA_ACT_NOTHING:
return 0;
default:
return (int)a;
}
}
void ota_status_set(const ota_status_t *st)
{
hal_crit_enter();
s_st = *st;
hal_crit_exit();
}
void ota_status_get(ota_status_t *st)
{
hal_crit_enter();
*st = s_st;
hal_crit_exit();
}
const char *ota_phase_str(ota_phase_t p)
{
switch (p) {
case OTA_IDLE:
return "idle";
case OTA_DL:
return "dl";
case OTA_READY:
return "ready";
default:
return "?";
}
}
const char *ota_err_str(ota_err_t e)
{
static const char *const k[OTA_E_COUNT] = {
[OTA_E_NONE] = "", [OTA_E_HTTP] = "http", [OTA_E_SIZE] = "size",
[OTA_E_HASH] = "hash", [OTA_E_FLASH] = "flash", [OTA_E_IMAGE] = "image",
[OTA_E_BOOT] = "boot", [OTA_E_NOSLOT] = "noslot",
};
return ((unsigned)e < OTA_E_COUNT) ? k[e] : "?";
}
ota_health_t ota_health(const ota_health_in_t *in)
{
if (!in->pending) {
return OTA_H_NONE;
}
bool net_ok;
if (in->mqtt_set) {
net_ok = in->mqtt_up;
} else if (in->wifi_set) {
net_ok = in->wifi_up;
} else {
net_ok = true;
}
if (in->boot_done && net_ok) {
return OTA_H_VALID;
}
return (in->now_ms >= OTA_HEALTH_MS) ? OTA_H_ROLLBACK : OTA_H_WAIT;
}
+170
查看文件
@@ -0,0 +1,170 @@
/* OTA 的平台无关部分(C2,`固件开发计划.md` §7「OTA:双分区 + 失败回滚」)。
下载、写 flash、切启动分区、回滚在 ESP 胶水里(ota_esp.c);这一半宿主机可测。
---- 2026-09-27 用户定的三条 --------------------------------------------
1. **下发通道**:`ota` 命令(经 MQTT/TLS 或 USB)只带 `url` + `sha256` + `size`;
镜像本身走**明文 HTTP** 下载(服务器上一个只读目录,不过门户门禁)。
完整性全靠 sha256:它走的是认证过的通道,明文下载改不了镜像 —— 改了哈希就对不上,
**哈希不对就不切分区**(写进去的那一半留在备用槽里,无害)。
2. **不签名**:拿得到 broker 后端口令的人本来就能关五路电,签名在这里多挡不了什么;
不烧 eFuse,USB 整片烧永远可用。
3. **回滚判据**:新固件启动后 `OTA_HEALTH_MS`(120 s)之内要**自证健康** ——
启动序列走完 + 连上 broker(已发出 state)。做不到 ⇒ 标记无效、重启回旧版。
新固件在自证之前崩了 / 看门狗了,bootloader 自己回滚(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE)。
---- ⚠️ 重启 = 五路全断一次 -------------------------------------------------
ESP 一复位,`EFUSE_EN` 靠下拉关掉(bringup.h),五台手机**硬断电**,
新固件起来后再按意图逐路恢复(§10.9)。所以 `ota` 命令的 `reboot` **默认 0**:
只下载 + 校验 + 切启动分区,**下次复位才生效**;要立刻换就显式带 `"reboot":true`。
已经下好的同一个镜像(sha256 相同)再发一次带 `"reboot":true` 的,不重下,直接重启。
回滚也是一次复位,同样五路全断一次。
---- 状态 ----------------------------------------------------------------
写者是胶水(OTA 任务 / 链路任务),读者是命令处理器(status 的 `ota` 一块),
和 netcfg 的网络状态一样整块拷贝。 */
#ifndef OTA_H
#define OTA_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "hal_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* URL 上限 = 命令 JSON 的字符串上限(cmd_json.h 的 64)。服务器按 sha256 前 16 位起文件名,
`http://home.example:7765/fw/0123456789abcdef.bin` 才 47 个字符。 */
#define OTA_URL_MAX 64
/* 一个 app 槽的大小(partitions.csv 的 ota_0 / ota_1,各 3 MB)。size 超过它直接拒。
⚠️ 改分区表要跟着改这里 —— ESP 上 ota_esp_init() 会拿真分区大小核一遍,对不上只打错误日志。 */
#define OTA_SLOT_BYTES 0x300000u
/* 新固件自证健康的时限。WiFi 冷启动连上 + TLS 握手实测在 5~10 s 量级,
broker 的重连间隔是 10 s,120 s 够 WiFi 退避好几轮。 */
#define OTA_HEALTH_MS 120000u
typedef struct {
char url[OTA_URL_MAX + 1];
uint8_t sha256[32];
uint32_t size;
bool reboot; /* 校验通过后立刻重启(五路全断一次) */
} ota_req_t;
/* 失败原因。⚠️ 数字进事件日志(LOG_EV_OTA 失败那一条的 bus_mv),**只许追加**。 */
typedef enum {
OTA_E_NONE = 0,
OTA_E_HTTP = 1, /* 连不上 / HTTP 状态码不是 200 / 读超时 */
OTA_E_SIZE = 2, /* Content-Length 或实际字节数与 size 不符 */
OTA_E_HASH = 3, /* sha256 对不上 */
OTA_E_FLASH = 4, /* esp_ota_begin / write 失败 */
OTA_E_IMAGE = 5, /* esp_ota_end 验不过(不是 ESP 镜像 / 芯片不对 / 不是本工程) */
OTA_E_BOOT = 6, /* 切启动分区失败 */
OTA_E_NOSLOT = 7, /* 没有备用槽(分区表不是双槽)/ 新固件还没自证、不许覆盖回滚槽 */
OTA_E_COUNT,
} ota_err_t;
/* `LOG_EV_OTA` 的 arg。⚠️ 进 flash,只许追加。 */
typedef enum {
OTA_EV_START = 1, /* 开工下载 */
OTA_EV_READY = 2, /* 下完、校验过、已切启动分区 */
OTA_EV_FAIL = 3, /* 失败,bus_mv = ota_err_t */
OTA_EV_VALID = 4, /* 新固件自证健康,取消回滚 */
OTA_EV_ROLLBACK = 5, /* 新固件 OTA_HEALTH_MS 内没自证 ⇒ 标记无效、重启回旧版 */
OTA_EV_REBOOT = 6, /* 为了换固件而重启(五路全断一次) */
} ota_ev_t;
typedef enum {
OTA_IDLE = 0, /* 没做过,或上一次失败了(看 err) */
OTA_DL, /* 正在下载 + 写 */
OTA_READY, /* 已校验、已切启动分区,等复位 */
} ota_phase_t;
typedef struct {
ota_phase_t phase;
ota_err_t err; /* 上一次的失败原因;OTA_E_NONE = 没失败过 / 成功了 */
uint32_t got; /* 已下载字节 */
uint32_t size; /* 本次(或上一次)的总字节 */
uint8_t sha256[32]; /* 本次(或已就绪的那个)镜像的哈希 */
bool pending; /* 正在跑的这份固件还没自证健康(OTA 后的第一次启动) */
char bad[24]; /* 被判无效的那份固件的版本号(回滚过才有),空 = 没有 */
} ota_status_t;
/* 胶水提供:把请求交给 OTA 任务(拷贝走),立刻返回。
只在 `ota_request()` 判定要开工时被调。返回非 HAL_OK = 任务没起来。 */
typedef hal_err_t (*ota_start_fn)(void *ctx, const ota_req_t *req);
/* 胶水提供:重启(已就绪的镜像,不用重下)。 */
typedef void (*ota_reboot_fn)(void *ctx);
/* 清状态、登记两个回调。宿主机测试传假的;NULL = 没有 OTA(命令回 state)。 */
void ota_init(ota_start_fn start, ota_reboot_fn reboot, void *ctx);
/* ---- 校验(命令处理器用) ------------------------------------------------ */
/* 只收 `http://` 开头、可打印 ASCII、没有空白 / 引号 / 反斜杠,长度 ≤ OTA_URL_MAX。
https 不收:板子上没有给下载服务器的 CA(用户定的就是明文 + 哈希)。 */
bool ota_url_ok(const char *url);
/* 64 位**小写**十六进制 → 32 字节。大写、长度不对、非十六进制都是 false。
只收小写是为了让「同一个镜像」只有一种写法(READY 时按哈希判是不是同一个)。 */
bool ota_parse_sha256(const char *hex, uint8_t out[32]);
/* 32 字节 → 64 位小写十六进制 + 0(out 至少 65)。 */
void ota_sha256_hex(const uint8_t sha[32], char out[65]);
typedef enum {
OTA_ACT_START = 0, /* 开工下载 */
OTA_ACT_REBOOT, /* 同一个镜像已就绪,直接重启 */
OTA_ACT_NOTHING, /* 同一个镜像已就绪、这次也不要求重启:回 ok,什么都不做 */
OTA_ACT_BUSY, /* 正在下载 */
OTA_ACT_PENDING, /* 正在跑的固件还没自证:备用槽里是回滚用的旧版,不许覆盖 */
} ota_action_t;
/* 拿当前状态判一个(已校验过参数的)请求该怎么办。纯函数,宿主机测。 */
ota_action_t ota_plan(const ota_status_t *st, const ota_req_t *req);
/* 命令处理器入口:判 + 执行(调 start / reboot 回调)。
返回 0 = 接下了(开工 / 重启 / 已就绪),其余见 ota_action_t(BUSY / PENDING),
或 -1 = 没有 OTA 能力(没登记回调 / start 失败)。 */
int ota_request(const ota_req_t *req);
/* ---- 状态(胶水写、命令处理器读) ----------------------------------------- */
void ota_status_set(const ota_status_t *st);
void ota_status_get(ota_status_t *st);
const char *ota_phase_str(ota_phase_t p);
const char *ota_err_str(ota_err_t e);
/* ---- 健康判据(新固件第一次启动时,链路任务每圈问一次) ------------------ */
typedef enum {
OTA_H_NONE = 0, /* 不在待验证状态,什么都不用做 */
OTA_H_WAIT, /* 还没自证,也还没超时 */
OTA_H_VALID, /* 自证了 ⇒ 胶水标记有效、取消回滚 */
OTA_H_ROLLBACK, /* 超时 ⇒ 胶水标记无效、重启回旧版 */
} ota_health_t;
typedef struct {
bool pending; /* 正在跑的固件处于待验证 */
uint32_t now_ms; /* 上电起算 */
bool boot_done; /* app_main 走完了(五个任务都起来了) */
bool wifi_set; /* 配了 WiFi */
bool wifi_up;
bool mqtt_set; /* 配了 broker */
bool mqtt_up; /* 连上 broker、已发 state */
} ota_health_in_t;
/* 判据:启动走完 + 「网络该通的那一层」通了。
· 配了 broker ⇒ 要 MQTT 连上(这是 OTA 的通道,它不通这块板就再也收不到下一次 OTA);
· 只配了 WiFi ⇒ 要 WiFi 连上(下载走的是它);
· 都没配 ⇒ 只看启动走完(那块板只能经 USB 管,不会因为「没网」被误回滚)。
超时判的是 `now_ms >= OTA_HEALTH_MS`;**启动走完之前不判健康**,但超时照样算。 */
ota_health_t ota_health(const ota_health_in_t *in);
#ifdef __cplusplus
}
#endif
#endif /* OTA_H */
+376
查看文件
@@ -0,0 +1,376 @@
/* OTA 的 ESP 胶水(C2)。**只进 IDF 目标**;三条规矩(明文 HTTP + sha256、不签名、
120 s 自证健康)与「重启 = 五路全断一次」在 ota.h。
---- 谁在哪个任务里跑 ------------------------------------------------------
| 事 | 任务 |
|---|---|
| `ota` 命令判定、把请求交出去 | 执行器任务(命令处理器 → `ota_request()` → `start()`,只入队) |
| HTTP 下载、算 sha256、写 flash、切启动分区 | **本文件自己的 `t_ota` 任务**,优先级与 `t_link` 同(最低一档) |
| 健康判据、到点重启 | `t_link`(`ota_esp_poll()`) |
| 兜底回滚 | esp_timer(app_main 卡死、`t_link` 永远起不来时也能回滚) |
下载要几秒到几十秒,放进执行器任务就是让按键序列等 TCP;放进 `t_link` 就是让遥测停摆。
---- 写 flash 对巡检的影响 ----------------------------------------------
擦一个 4 KB 扇区要几十 ms,期间 cache 关掉、两个核上跑 flash 代码的任务都停。
`OTA_WITH_SEQUENTIAL_WRITES` 让擦除跟着写走(一次一个扇区),而不是在 esp_ota_begin 里
一口气擦 1 MB(那是秒级的停顿);每写一块再让出一个 tick。代价是 t_safety 偶尔晚一两拍,
上板看 status 的 `safety.skipped` 与巡检日志里的「最迟」。
---- 重启前等一下 --------------------------------------------------------
回应要先发出去(命令处理器调 reboot 回调时回应还没写),事件要先落盘(t_log 攒批最多 1 s)。
所以重启一律是「排到 `REBOOT_DELAY_MS` 之后」,由 `t_link` 执行。 */
#include <string.h>
#include "esp_app_desc.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "evlog.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "hal_time.h"
#include "mbedtls/sha256.h"
#include "netcfg.h"
#include "ota.h"
#include "ota_esp.h"
#include "tasks.h"
static const char *TAG = "ota";
#define REBOOT_DELAY_MS (EVLOG_MAX_AGE_MS + 1000u) /* 回应发出去 + 事件落盘 */
#define HTTP_TIMEOUT_MS 15000
#define CHUNK 4096
#define STATUS_EVERY (64u * 1024u) /* 进度多久更新一次 status(每块都更新也行,只是没必要) */
#define BACKSTOP_EXTRA_MS 30000u /* 兜底定时器比判据晚这么久:正常路径先走 */
static QueueHandle_t s_q;
static volatile bool s_boot_done;
static volatile uint32_t s_reboot_at; /* 0 = 没排;否则 = 上电毫秒 */
static volatile bool s_rollback; /* 排的是回滚(标记无效再重启),不是普通重启 */
static bool s_pending; /* 只在 t_link 里读写(初始化之后) */
static esp_timer_handle_t s_backstop;
static void status_edit(void (*fn)(ota_status_t *, uint32_t), uint32_t v)
{
ota_status_t st;
ota_status_get(&st);
fn(&st, v);
ota_status_set(&st);
}
static void set_got(ota_status_t *st, uint32_t v) { st->got = v; }
static void schedule_reboot(bool rollback)
{
s_rollback = rollback;
uint32_t at = hal_time_now_ms() + REBOOT_DELAY_MS;
s_reboot_at = (at == 0) ? 1 : at;
}
/* ota.h 的 reboot 回调:已就绪的同一镜像再来一条带 reboot 的命令。执行器任务里调。 */
static void cb_reboot(void *ctx)
{
(void)ctx;
evlog_put(0, LOG_EV_OTA, OTA_EV_REBOOT, 0, 0);
schedule_reboot(false);
}
/* ota.h 的 start 回调:只入队。执行器任务里调。 */
static hal_err_t cb_start(void *ctx, const ota_req_t *req)
{
(void)ctx;
return (s_q != NULL && xQueueSend(s_q, req, 0) == pdTRUE) ? HAL_OK : HAL_ERR_STATE;
}
/* 下载 + 校验 + 写 + 切分区。返回 OTA_E_NONE = 已就绪。 */
static ota_err_t run(const ota_req_t *req)
{
static uint8_t buf[CHUNK]; /* 静态:只有本任务用 */
const esp_partition_t *running = esp_ota_get_running_partition();
const esp_partition_t *part = esp_ota_get_next_update_partition(NULL);
if (part == NULL || part == running) {
return OTA_E_NOSLOT;
}
if (req->size > part->size) {
return OTA_E_SIZE;
}
/* 上一次 READY 的是另一个镜像:先把启动分区指回自己,下到一半断电也不会去启动半个镜像。 */
const esp_partition_t *boot = esp_ota_get_boot_partition();
if (boot != NULL && boot != running) {
(void)esp_ota_set_boot_partition(running);
}
const esp_http_client_config_t hc = {
.url = req->url,
.timeout_ms = HTTP_TIMEOUT_MS,
.keep_alive_enable = true,
};
esp_http_client_handle_t cli = esp_http_client_init(&hc);
if (cli == NULL) {
return OTA_E_HTTP;
}
ota_err_t err = OTA_E_NONE;
esp_ota_handle_t h = 0;
bool begun = false;
mbedtls_sha256_context sha;
mbedtls_sha256_init(&sha);
if (esp_http_client_open(cli, 0) != ESP_OK) {
err = OTA_E_HTTP;
goto out;
}
const int64_t len = esp_http_client_fetch_headers(cli);
const int code = esp_http_client_get_status_code(cli);
if (code != 200) {
ESP_LOGE(TAG, "HTTP %d", code);
err = OTA_E_HTTP;
goto out;
}
if (len > 0 && len != (int64_t)req->size) { /* 0 / -1 = 没给 Content-Length(chunked),按字节数核 */
ESP_LOGE(TAG, "Content-Length %lld ≠ size %lu", (long long)len, (unsigned long)req->size);
err = OTA_E_SIZE;
goto out;
}
esp_err_t e = esp_ota_begin(part, OTA_WITH_SEQUENTIAL_WRITES, &h);
if (e != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_begin: %s", esp_err_to_name(e));
/* 待验证的固件去覆盖备用槽,IDF 自己也会拒(ota.c 的 PENDING 已经先挡了一道) */
err = (e == ESP_ERR_OTA_ROLLBACK_INVALID_STATE) ? OTA_E_NOSLOT : OTA_E_FLASH;
goto out;
}
begun = true;
mbedtls_sha256_starts(&sha, 0);
uint32_t got = 0;
uint32_t shown = 0;
while (got < req->size) {
const uint32_t want = (req->size - got < CHUNK) ? (req->size - got) : CHUNK;
const int n = esp_http_client_read(cli, (char *)buf, (int)want);
if (n < 0) {
err = OTA_E_HTTP;
goto out;
}
if (n == 0) {
break; /* 连接收了:字节数不够由下面判 */
}
mbedtls_sha256_update(&sha, buf, (size_t)n);
e = esp_ota_write(h, buf, (size_t)n);
if (e != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_write: %s", esp_err_to_name(e));
err = OTA_E_FLASH;
goto out;
}
got += (uint32_t)n;
if (got - shown >= STATUS_EVERY) {
shown = got;
status_edit(set_got, got);
}
vTaskDelay(1); /* 把 cache 关掉的空档摊开,巡检不至于连着晚好几拍 */
}
status_edit(set_got, got);
if (got != req->size) {
ESP_LOGE(TAG, "只收到 %lu / %lu 字节", (unsigned long)got, (unsigned long)req->size);
err = OTA_E_SIZE;
goto out;
}
uint8_t digest[32];
mbedtls_sha256_finish(&sha, digest);
if (memcmp(digest, req->sha256, sizeof(digest)) != 0) {
char hex[65];
ota_sha256_hex(digest, hex);
ESP_LOGE(TAG, "sha256 对不上:算出来 %s", hex);
err = OTA_E_HASH;
goto out;
}
begun = false; /* esp_ota_end 无论成败都释放句柄 */
e = esp_ota_end(h);
if (e != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_end: %s", esp_err_to_name(e));
err = (e == ESP_ERR_OTA_VALIDATE_FAILED) ? OTA_E_IMAGE : OTA_E_FLASH;
goto out;
}
/* 哈希对得上只说明「是服务器想发的那个文件」;再核一眼是不是本工程的固件 ——
服务器那头传错一个 .bin(别的项目的),ESP 镜像校验照样过。 */
esp_app_desc_t d;
if (esp_ota_get_partition_description(part, &d) != ESP_OK ||
strncmp(d.project_name, esp_app_get_description()->project_name, sizeof(d.project_name)) != 0) {
ESP_LOGE(TAG, "不是本工程的固件(project_name「%.32s」)", d.project_name);
err = OTA_E_IMAGE;
goto out;
}
e = esp_ota_set_boot_partition(part);
if (e != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_set_boot_partition: %s", esp_err_to_name(e));
err = OTA_E_BOOT;
goto out;
}
ESP_LOGI(TAG, "新固件 %.32s 已就绪(%s @0x%lx),%s", d.version, part->label,
(unsigned long)part->address, req->reboot ? "马上重启" : "下次复位生效");
out:
if (begun) {
esp_ota_abort(h);
}
mbedtls_sha256_free(&sha);
esp_http_client_close(cli);
esp_http_client_cleanup(cli);
return err;
}
static void task_ota(void *arg)
{
(void)arg;
ota_req_t req;
for (;;) {
if (xQueueReceive(s_q, &req, portMAX_DELAY) != pdTRUE) {
continue;
}
ESP_LOGI(TAG, "开始下载 %s(%lu 字节)", req.url, (unsigned long)req.size);
evlog_put(0, LOG_EV_OTA, OTA_EV_START, 0, (int32_t)req.size);
const ota_err_t err = run(&req);
ota_status_t st;
ota_status_get(&st);
st.err = err;
if (err == OTA_E_NONE) {
st.phase = OTA_READY;
ota_status_set(&st);
evlog_put(0, LOG_EV_OTA, OTA_EV_READY, 0, (int32_t)req.size);
if (req.reboot) {
evlog_put(0, LOG_EV_OTA, OTA_EV_REBOOT, 0, (int32_t)req.size);
schedule_reboot(false);
}
} else {
st.phase = OTA_IDLE;
ota_status_set(&st);
evlog_put(0, LOG_EV_OTA, OTA_EV_FAIL, 0, (int32_t)err);
ESP_LOGE(TAG, "OTA 失败:%s", ota_err_str(err));
}
}
}
/* 兜底:判据的正常路径在 t_link 里;app_main 卡死 / t_link 起不来时它来回滚。
esp_timer 任务里调,事件来不及落盘 —— 这条路本来就只在出大事时走。 */
static void backstop_cb(void *arg)
{
(void)arg;
esp_ota_img_states_t s;
if (esp_ota_get_state_partition(esp_ota_get_running_partition(), &s) == ESP_OK &&
s == ESP_OTA_IMG_PENDING_VERIFY) {
ESP_LOGE(TAG, "兜底定时器:新固件 %u s 内没自证健康,回滚",
(unsigned)((OTA_HEALTH_MS + BACKSTOP_EXTRA_MS) / 1000u));
esp_ota_mark_app_invalid_rollback_and_reboot();
}
}
hal_err_t ota_esp_init(void)
{
ota_init(NULL, NULL, NULL);
ota_status_t st;
ota_status_get(&st);
const esp_partition_t *running = esp_ota_get_running_partition();
esp_ota_img_states_t state;
if (running != NULL && esp_ota_get_state_partition(running, &state) == ESP_OK) {
s_pending = (state == ESP_OTA_IMG_PENDING_VERIFY);
}
st.pending = s_pending;
const esp_partition_t *bad = esp_ota_get_last_invalid_partition();
esp_app_desc_t d;
if (bad != NULL && esp_ota_get_partition_description(bad, &d) == ESP_OK) {
snprintf(st.bad, sizeof(st.bad), "%.*s", (int)sizeof(st.bad) - 1, d.version);
ESP_LOGW(TAG, "%s 里的固件 %s 被判过无效(回滚过)", bad->label, st.bad);
}
ota_status_set(&st);
const esp_partition_t *next = esp_ota_get_next_update_partition(NULL);
ESP_LOGI(TAG, "运行在 %s @0x%lx%s;备用槽 %s", running ? running->label : "?",
running ? (unsigned long)running->address : 0ul,
s_pending ? "(⚠️ 待验证:" "120 s 内要连上 broker,否则回滚)" : "",
next ? next->label : "没有(分区表不是双槽)");
if (next != NULL && next->size != OTA_SLOT_BYTES) {
ESP_LOGE(TAG, "备用槽 %lu 字节 ≠ OTA_SLOT_BYTES %lu:ota.h 与 partitions.csv 没对上",
(unsigned long)next->size, (unsigned long)OTA_SLOT_BYTES);
}
if (s_pending) {
const esp_timer_create_args_t ta = { .callback = backstop_cb, .name = "ota_backstop" };
if (esp_timer_create(&ta, &s_backstop) == ESP_OK) {
esp_timer_start_once(s_backstop, (uint64_t)(OTA_HEALTH_MS + BACKSTOP_EXTRA_MS) * 1000u);
}
}
if (next == NULL) {
return HAL_ERR_STATE; /* 命令回 state:ota_init 没登记回调 */
}
s_q = xQueueCreate(1, sizeof(ota_req_t));
if (s_q == NULL ||
xTaskCreate(task_ota, "t_ota", 6144, NULL, task_desc(TASK_LINK)->prio, NULL) != pdPASS) {
return HAL_ERR_STATE;
}
ota_init(cb_start, cb_reboot, NULL);
ota_status_set(&st); /* ota_init 清了状态,把 pending / bad 放回去 */
return HAL_OK;
}
void ota_esp_boot_done(void) { s_boot_done = true; }
void ota_esp_poll(void)
{
const uint32_t now = hal_time_now_ms();
if (s_pending) {
netcfg_t cfg;
net_status_t ns;
netcfg_get(&cfg);
net_status_get(&ns);
const ota_health_in_t in = {
.pending = true,
.now_ms = now,
.boot_done = s_boot_done,
.wifi_set = cfg.ssid[0] != '\0',
.wifi_up = ns.wifi == NET_UP,
.mqtt_set = cfg.uri[0] != '\0',
.mqtt_up = ns.mqtt == NET_UP,
};
const ota_health_t h = ota_health(&in);
if (h == OTA_H_VALID) {
const esp_err_t e = esp_ota_mark_app_valid_cancel_rollback();
s_pending = false;
if (s_backstop != NULL) {
esp_timer_stop(s_backstop);
}
ota_status_t st;
ota_status_get(&st);
st.pending = false;
ota_status_set(&st);
evlog_put(0, LOG_EV_OTA, OTA_EV_VALID, 0, (int32_t)e);
ESP_LOGI(TAG, "新固件 %s 自证健康(%lu ms),取消回滚:%s", esp_app_get_description()->version,
(unsigned long)now, esp_err_to_name(e));
} else if (h == OTA_H_ROLLBACK && s_reboot_at == 0) {
s_pending = false; /* 不再判;下面到点回滚 */
evlog_put(0, LOG_EV_OTA, OTA_EV_ROLLBACK, 0, 0);
ESP_LOGE(TAG, "新固件 %u s 内没自证健康(WiFi %s / MQTT %s),回滚",
(unsigned)(OTA_HEALTH_MS / 1000u), net_state_str(ns.wifi), net_state_str(ns.mqtt));
schedule_reboot(true);
}
}
if (s_reboot_at != 0 && (int32_t)(now - s_reboot_at) >= 0) {
if (s_rollback) {
esp_ota_mark_app_invalid_rollback_and_reboot(); /* 成功就不返回 */
ESP_LOGE(TAG, "回滚失败(没有可回的旧版?),就地重启");
}
ESP_LOGW(TAG, "为换固件重启:五路全断一次,起来后按意图恢复");
esp_restart();
}
}
+17
查看文件
@@ -0,0 +1,17 @@
/* OTA 的 ESP 胶水(C2)。只在 IDF 目标里有;规矩在 ota.h,实现说明在 ota_esp.c 文件头。 */
#ifndef OTA_ESP_H
#define OTA_ESP_H
#include "hal_err.h"
/* 查本次启动是不是「OTA 后待验证」、有没有被回滚掉的镜像,起 OTA 任务,登记 ota.h 的两个回调。
要在 evlog 起来之后调(会记事件)。失败不 abort:没有 OTA,别的照常。 */
hal_err_t ota_esp_init(void);
/* app_main 走完(五个任务都起来了)时调一次。健康判据的前一半。 */
void ota_esp_boot_done(void);
/* 链路任务每圈调一次:健康判据 → 标记有效 / 回滚;到点执行排好的重启。 */
void ota_esp_poll(void);
#endif /* OTA_ESP_H */
+1 -1
查看文件
@@ -3,7 +3,7 @@
#define PHONERAIL_H
/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */
#define PHONERAIL_FW_VERSION "0.1.0-a16"
#define PHONERAIL_FW_VERSION "0.1.0-c2"
/* 通道数。五路直供,见设计文档 §2。 */
#define PHONERAIL_RAIL_COUNT 5
+19 -6
查看文件
@@ -1,18 +1,31 @@
# PhoneRail 的分区表。默认的 partitions_singleapp.csv 里没有给事件日志留地方,
# 而 §10.7 要求「记录路径不依赖上位机在线」—— 那就得有一块自己的 flash。
# PhoneRail 的分区表(C2 重排:双槽 OTA,16 MB flash)。
#
# ⚠️ **nvs 的位置与大小一格都不许动**(0x9000 / 0x6000):里面是 WiFi / MQTT 配置
# 和 `rail_want`(每路上电意图)。挪了 = 板子失联 + 重启后一路都不开。
#
# ota_0 / ota_1:各 3 MB。C2 时 app 是 0xE9920(≈0.9 MB),留 3 倍余量;
# 改大小要跟着改 `ota.h` 的 OTA_SLOT_BYTES(ota_esp_init 会核一遍,不一致打错误日志)。
# 没有 factory:回滚的对象是「上一个好的 OTA 槽」,factory 只是第三份永远不更新的旧版。
# otadata:两个扇区,记「从哪个槽启动 + 那个槽的验证状态」。bootloader 的回滚靠它。
# 0x10000 之前放不下(nvs + phy_init 刚好顶到 0x10000,app 又必须 64 KB 对齐),所以放在两个槽后面。
#
# evlog:**裸的 data 分区**,不是 nvs、也不是 spiffs(理由见 hal_store_esp.c 开头)。
# 大小 0x11000 = 17 个扇区。为什么是 17 而不是 §10.7 里写的「4096 条 ≈ 64 KB」:
# 每个扇区要拿一个 16 B 的槽当扇区头(epoch),于是一扇区只装得下 255 条,
# 16 扇区 = 4080 条,**差 16 条不够 4096**。17 扇区 = 4335 条,
# `test_capacity_meets_the_requirement()` 卡的就是这个 ≥4096。
# 8 MB flash 上多这 4 KB 没有任何代价。
# C2 把它从 0x110000 挪到了 0x612000:evlog 按 label 找分区、格式不变,**只是历史事件清空一次**。
#
# ⚠️ 改了这里要跟着改 `sim_hal.h` 的 SIM_STORE_SECTORS —— 两边不一致时
# ⚠️ 改了 evlog 的大小要跟着改 `sim_hal.h` 的 SIM_STORE_SECTORS —— 两边不一致时
# 宿主机测试仍然全绿,板子上却是另一个容量。测试里有一条断言把它们钉在一起。
#
# ⚠️ 换了分区表之后**第一次必须经 USB 整片烧**(bootloader + 分区表 + otadata + app),
# 之后才能走 OTA。
#
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 0x100000,
evlog, data, undefined, 0x110000, 0x11000,
ota_0, app, ota_0, 0x10000, 0x300000,
ota_1, app, ota_1, 0x310000, 0x300000,
otadata, data, ota, 0x610000, 0x2000,
evlog, data, undefined, 0x612000, 0x11000,
1 # PhoneRail 的分区表。默认的 partitions_singleapp.csv 里没有给事件日志留地方, # PhoneRail 的分区表(C2 重排:双槽 OTA,16 MB flash)。
2 # 而 §10.7 要求「记录路径不依赖上位机在线」—— 那就得有一块自己的 flash。 #
3 # ⚠️ **nvs 的位置与大小一格都不许动**(0x9000 / 0x6000):里面是 WiFi / MQTT 配置
4 # 和 `rail_want`(每路上电意图)。挪了 = 板子失联 + 重启后一路都不开。
5 #
6 # ota_0 / ota_1:各 3 MB。C2 时 app 是 0xE9920(≈0.9 MB),留 3 倍余量;
7 # 改大小要跟着改 `ota.h` 的 OTA_SLOT_BYTES(ota_esp_init 会核一遍,不一致打错误日志)。
8 # 没有 factory:回滚的对象是「上一个好的 OTA 槽」,factory 只是第三份永远不更新的旧版。
9 # otadata:两个扇区,记「从哪个槽启动 + 那个槽的验证状态」。bootloader 的回滚靠它。
10 # 0x10000 之前放不下(nvs + phy_init 刚好顶到 0x10000,app 又必须 64 KB 对齐),所以放在两个槽后面。
11 # #
12 # evlog:**裸的 data 分区**,不是 nvs、也不是 spiffs(理由见 hal_store_esp.c 开头)。 # evlog:**裸的 data 分区**,不是 nvs、也不是 spiffs(理由见 hal_store_esp.c 开头)。
13 # 大小 0x11000 = 17 个扇区。为什么是 17 而不是 §10.7 里写的「4096 条 ≈ 64 KB」: # 大小 0x11000 = 17 个扇区。为什么是 17 而不是 §10.7 里写的「4096 条 ≈ 64 KB」:
14 # 每个扇区要拿一个 16 B 的槽当扇区头(epoch),于是一扇区只装得下 255 条, # 每个扇区要拿一个 16 B 的槽当扇区头(epoch),于是一扇区只装得下 255 条,
15 # 16 扇区 = 4080 条,**差 16 条不够 4096**。17 扇区 = 4335 条, # 16 扇区 = 4080 条,**差 16 条不够 4096**。17 扇区 = 4335 条,
16 # `test_capacity_meets_the_requirement()` 卡的就是这个 ≥4096。 # `test_capacity_meets_the_requirement()` 卡的就是这个 ≥4096。
17 # 8 MB flash 上多这 4 KB 没有任何代价。 # C2 把它从 0x110000 挪到了 0x612000:evlog 按 label 找分区、格式不变,**只是历史事件清空一次**。
18 # #
19 # ⚠️ 改了这里要跟着改 `sim_hal.h` 的 SIM_STORE_SECTORS —— 两边不一致时 # ⚠️ 改了 evlog 的大小要跟着改 `sim_hal.h` 的 SIM_STORE_SECTORS —— 两边不一致时
20 # 宿主机测试仍然全绿,板子上却是另一个容量。测试里有一条断言把它们钉在一起。 # 宿主机测试仍然全绿,板子上却是另一个容量。测试里有一条断言把它们钉在一起。
21 # #
22 # ⚠️ 换了分区表之后**第一次必须经 USB 整片烧**(bootloader + 分区表 + otadata + app),
23 # 之后才能走 OTA。
24 #
25 # Name # Name
26 nvs nvs
27 phy_init phy_init
28 factory ota_0
29 evlog ota_1
30 otadata
31 evlog
+6 -2
查看文件
@@ -1,8 +1,12 @@
# 目标芯片:ESP32-S3-WROOM-1(需求 §10)
CONFIG_IDF_TARGET="esp32s3"
# 8 MB flash / 双分区 OTA 留到 C2,骨架期先用默认单 app 表。
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
# 模组是 ESP32-S3-WROOM-1-N16R8:16 MB flash(C2 之前这里误写成 8 MB)。
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
# C2 OTA:新固件在自证健康之前崩了 / 被看门狗复位,bootloader 自己回滚到旧槽(ota.h)。
# ⚠️ 这是 bootloader 的配置 —— 只有经 USB 整片烧(含 bootloader)才生效,OTA 换不了 bootloader。
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
# 日志:默认 INFO。
# A13:关掉 USB-Serial-JTAG 副控制台 —— 它绕开驱动直接写 USB FIFO,会把命令回应拆坏。
+2 -1
查看文件
@@ -185,7 +185,8 @@ static void test_cmd_numbers_are_frozen(void)
TEST_ASSERT_EQUAL_INT(10, CMD_WIFI);
TEST_ASSERT_EQUAL_INT(11, CMD_MQTT);
TEST_ASSERT_EQUAL_INT(12, CMD_NET);
TEST_ASSERT_EQUAL_INT(13, CMD_COUNT);
TEST_ASSERT_EQUAL_INT(13, CMD_OTA); /* C2 */
TEST_ASSERT_EQUAL_INT(14, CMD_COUNT);
TEST_ASSERT_EQUAL_INT(0, CMD_OK);
TEST_ASSERT_EQUAL_INT(1, CMD_ERR_PARSE);
+2 -1
查看文件
@@ -85,7 +85,8 @@ static void test_flash_format_is_frozen(void)
TEST_ASSERT_EQUAL_INT(15, LOG_EV_SLOT_EMPTY);
TEST_ASSERT_EQUAL_INT(16, LOG_EV_PD_LIMIT);
TEST_ASSERT_EQUAL_INT(17, LOG_EV_PG_TIMEOUT); /* A15 */
TEST_ASSERT_EQUAL_INT(18, LOG_EV_COUNT);
TEST_ASSERT_EQUAL_INT(18, LOG_EV_OTA); /* C2 */
TEST_ASSERT_EQUAL_INT(19, LOG_EV_COUNT);
}
/* §10.7 的「必记事件」有八类,A10 只产生其中两类。剩下的号先占住,
+385
查看文件
@@ -0,0 +1,385 @@
/* C2:OTA 的平台无关部分(ota.h)+ `ota` 命令。
下载 / 写 flash / 切分区是 ESP 胶水,这里测不到;测的是会写错而上板不容易查出来的那几件:
· 参数校验:URL 只收 http、哈希只收 64 位小写十六进制、size 不许超过一个槽;
· 状态判定:下载中拒第二条、**待验证的固件不许再 OTA**(备用槽里是回滚用的旧版)、
同一个镜像已就绪时不重下;
· 判重:QoS 1 重投的同一条 ota 不会开工两次;
· 回滚判据:该通的那一层网络没通就不算健康,超时判回滚。 */
#include <stdio.h>
#include <string.h>
#include "cmd.h"
#include "evlog.h"
#include "ota.h"
#include "sim_hal.h"
#include "unity.h"
static char s_out[CMD_OUT_MAX];
#define SHA_A "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
#define SHA_B "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
#define URL_A "http://home.example:7765/fw/0011223344556677.bin"
/* 假胶水:记下收到的请求、可以让开工失败。 */
static int s_starts;
static int s_reboots;
static ota_req_t s_last;
static hal_err_t s_start_err;
static hal_err_t fake_start(void *ctx, const ota_req_t *req)
{
(void)ctx;
if (s_start_err != HAL_OK) {
return s_start_err;
}
s_last = *req;
s_starts++;
return HAL_OK;
}
static void fake_reboot(void *ctx)
{
(void)ctx;
s_reboots++;
}
void setUp(void)
{
sim_hal_reset();
s_starts = 0;
s_reboots = 0;
s_start_err = HAL_OK;
memset(&s_last, 0, sizeof(s_last));
ota_init(fake_start, fake_reboot, NULL);
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
cmd_init(NULL);
}
void tearDown(void) { TEST_ASSERT_EQUAL_INT(0, sim_crit_depth()); }
static const char *send_on(cmd_link_t link, const char *line)
{
cmd_handle_line(link, line, s_out, sizeof(s_out));
return s_out;
}
static const char *send(const char *line) { return send_on(CMD_LINK_WIFI, line); }
static void assert_has_at(const char *resp, const char *needle, int line)
{
UNITY_TEST_ASSERT_NOT_NULL(strstr(resp, needle), line, resp);
}
#define ASSERT_HAS(r, n) assert_has_at((r), (n), __LINE__)
static int count_rejects(void)
{
evlog_rec_t rec[64];
(void)evlog_flush(0);
const uint32_t n = evlog_read(0, rec, 64);
int k = 0;
for (uint32_t i = 0; i < n; i++) {
k += rec[i].type == LOG_EV_CMD_REJECT;
}
return k;
}
static void set_phase(ota_phase_t p, const char *sha_hex, bool pending)
{
ota_status_t st;
ota_status_get(&st);
st.phase = p;
st.pending = pending;
if (sha_hex != NULL) {
TEST_ASSERT_TRUE(ota_parse_sha256(sha_hex, st.sha256));
}
ota_status_set(&st);
}
/* ---- 校验 ---------------------------------------------------------------- */
static void test_url_rules(void)
{
TEST_ASSERT_TRUE(ota_url_ok(URL_A));
TEST_ASSERT_TRUE(ota_url_ok("http://10.5.5.13/a.bin"));
TEST_ASSERT_FALSE(ota_url_ok("https://home.example/a.bin")); /* 板子上没有下载服务器的 CA */
TEST_ASSERT_FALSE(ota_url_ok("ftp://x/a.bin"));
TEST_ASSERT_FALSE(ota_url_ok("http://"));
TEST_ASSERT_FALSE(ota_url_ok("http://a b/c.bin"));
TEST_ASSERT_FALSE(ota_url_ok("http://a/\"x"));
TEST_ASSERT_FALSE(ota_url_ok(NULL));
char long_url[OTA_URL_MAX + 2];
memset(long_url, 'a', sizeof(long_url));
memcpy(long_url, "http://", 7);
long_url[OTA_URL_MAX] = '\0';
TEST_ASSERT_TRUE(ota_url_ok(long_url)); /* 正好 64 */
long_url[OTA_URL_MAX] = 'a';
long_url[OTA_URL_MAX + 1] = '\0';
TEST_ASSERT_FALSE(ota_url_ok(long_url));
}
static void test_sha256_parse_is_strict_and_round_trips(void)
{
uint8_t sha[32];
TEST_ASSERT_TRUE(ota_parse_sha256(SHA_A, sha));
TEST_ASSERT_EQUAL_HEX8(0x00, sha[0]);
TEST_ASSERT_EQUAL_HEX8(0xff, sha[31]);
char hex[65];
ota_sha256_hex(sha, hex);
TEST_ASSERT_EQUAL_STRING(SHA_A, hex);
/* 大写不收:「同一个镜像」只许一种写法(READY 时靠它判是不是同一个) */
TEST_ASSERT_FALSE(ota_parse_sha256(
"00112233445566778899AABBCCDDEEFF00112233445566778899aabbccddeeff", sha));
TEST_ASSERT_FALSE(ota_parse_sha256(SHA_A "0", sha));
TEST_ASSERT_FALSE(ota_parse_sha256("0011", sha));
TEST_ASSERT_FALSE(ota_parse_sha256(
"g0112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", sha));
/* 半截错的不许写进 out */
uint8_t keep[32];
memset(keep, 0x5A, sizeof(keep));
TEST_ASSERT_FALSE(ota_parse_sha256(
"001122334455667788990000000000000000000000000000000000000000000z", keep));
TEST_ASSERT_EACH_EQUAL_HEX8(0x5A, keep, sizeof(keep));
}
/* ---- 判定 ---------------------------------------------------------------- */
static void test_plan(void)
{
ota_status_t st;
memset(&st, 0, sizeof(st));
ota_req_t r;
memset(&r, 0, sizeof(r));
TEST_ASSERT_TRUE(ota_parse_sha256(SHA_A, r.sha256));
TEST_ASSERT_EQUAL_INT(OTA_ACT_START, ota_plan(&st, &r));
st.phase = OTA_DL;
TEST_ASSERT_EQUAL_INT(OTA_ACT_BUSY, ota_plan(&st, &r));
st.phase = OTA_READY;
memcpy(st.sha256, r.sha256, 32);
TEST_ASSERT_EQUAL_INT(OTA_ACT_NOTHING, ota_plan(&st, &r));
r.reboot = true;
TEST_ASSERT_EQUAL_INT(OTA_ACT_REBOOT, ota_plan(&st, &r));
st.sha256[5] ^= 1; /* 已就绪的是另一个镜像 ⇒ 重下 */
TEST_ASSERT_EQUAL_INT(OTA_ACT_START, ota_plan(&st, &r));
/* ⭐ 待验证:备用槽里是回滚用的旧版,覆盖了就没有退路 */
st.phase = OTA_IDLE;
st.pending = true;
TEST_ASSERT_EQUAL_INT(OTA_ACT_PENDING, ota_plan(&st, &r));
}
/* ---- 命令 ---------------------------------------------------------------- */
/* ⭐ 正常开工:请求原样交给胶水,status 立刻看得到「在下载」,默认不重启。 */
static void test_ota_command_starts_a_download(void)
{
const char *r = send("{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":987654}");
ASSERT_HAS(r, "\"ok\":true");
ASSERT_HAS(r, "\"act\":\"start\"");
TEST_ASSERT_EQUAL_INT(1, s_starts);
TEST_ASSERT_EQUAL_STRING(URL_A, s_last.url);
TEST_ASSERT_EQUAL_UINT32(987654, s_last.size);
TEST_ASSERT_FALSE(s_last.reboot); /* ⚠️ 默认不重启:重启 = 五路全断一次 */
char hex[65];
ota_sha256_hex(s_last.sha256, hex);
TEST_ASSERT_EQUAL_STRING(SHA_A, hex);
r = send("{\"cmd\":\"status\"}");
ASSERT_HAS(r, "\"ota\":{\"st\":\"dl\",\"got\":0,\"size\":987654,\"err\":null,\"pend\":false,"
"\"bad\":null}");
/* 下载中再来一条:busy,不开第二个 */
r = send("{\"seq\":2,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_B
"\",\"size\":1000,\"reboot\":true}");
ASSERT_HAS(r, "\"err\":\"busy\"");
TEST_ASSERT_EQUAL_INT(1, s_starts);
}
/* ⭐ QoS 1 重投:同一个 seq 的 ota 只开工一次,回第一次的结论。 */
static void test_redelivered_ota_starts_once(void)
{
const char *line = "{\"seq\":7,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000,\"reboot\":true}";
ASSERT_HAS(send(line), "\"ok\":true");
ASSERT_HAS(send(line), "\"dup\":true");
TEST_ASSERT_EQUAL_INT(1, s_starts);
TEST_ASSERT_TRUE(s_last.reboot);
}
static void test_bad_arguments(void)
{
/* 缺字段 / 类型不对 / 格式不对:arg,不记事件 */
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"size\":1000}"),
"\"err\":\"arg\"");
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"ota\",\"url\":\"https://x/a\",\"sha256\":\"" SHA_A
"\",\"size\":1000}"),
"\"err\":\"arg\"");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":\"1000\"}"),
"\"err\":\"arg\"");
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000,\"reboot\":1}"),
"\"err\":\"arg\""); /* reboot 是 true/false,不是 0/1 */
TEST_ASSERT_EQUAL_INT(0, count_rejects());
/* size 越界:range,记事件 */
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":0}"),
"\"err\":\"range\"");
char line[256];
snprintf(line, sizeof(line),
"{\"seq\":6,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A "\",\"size\":%lu}",
(unsigned long)OTA_SLOT_BYTES + 1u);
ASSERT_HAS(send(line), "\"err\":\"range\"");
TEST_ASSERT_EQUAL_INT(2, count_rejects());
/* 没 seq:会动东西的命令必须带 */
ASSERT_HAS(send("{\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A "\",\"size\":10}"),
"\"err\":\"no_seq\"");
TEST_ASSERT_EQUAL_INT(0, s_starts);
}
/* ⭐ 待验证中的固件不许再 OTA:state + 记事件;胶水一次都不碰。 */
static void test_pending_image_refuses_ota(void)
{
set_phase(OTA_IDLE, NULL, true);
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000}"),
"\"err\":\"state\"");
TEST_ASSERT_EQUAL_INT(0, s_starts);
TEST_ASSERT_EQUAL_INT(1, count_rejects());
ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"pend\":true");
}
/* 同一个镜像已就绪:带 reboot 就直接重启、不重下;不带就什么都不做。 */
static void test_ready_image_is_not_downloaded_again(void)
{
set_phase(OTA_READY, SHA_A, false);
const char *r = send("{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000}");
ASSERT_HAS(r, "\"act\":\"ready\"");
TEST_ASSERT_EQUAL_INT(0, s_reboots);
r = send("{\"seq\":2,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000,\"reboot\":true}");
ASSERT_HAS(r, "\"act\":\"reboot\"");
TEST_ASSERT_EQUAL_INT(1, s_reboots);
TEST_ASSERT_EQUAL_INT(0, s_starts);
/* 换了个镜像:重下 */
r = send("{\"seq\":3,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_B
"\",\"size\":1000}");
ASSERT_HAS(r, "\"act\":\"start\"");
TEST_ASSERT_EQUAL_INT(1, s_starts);
}
/* 没有 OTA 能力(胶水没登记 / 任务起不来):state,状态回到 idle,不卡在 dl。 */
static void test_no_ota_capability(void)
{
s_start_err = HAL_ERR_STATE;
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000}"),
"\"err\":\"state\"");
ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"ota\":{\"st\":\"idle\"");
ota_init(NULL, NULL, NULL);
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000}"),
"\"err\":\"state\"");
}
/* 经 USB 来的 ota 也收(下载走 WiFi,命令从哪来都行)。 */
static void test_ota_over_usb_is_accepted(void)
{
ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A
"\",\"sha256\":\"" SHA_A "\",\"size\":1000}"),
"\"ok\":true");
TEST_ASSERT_EQUAL_INT(1, s_starts);
}
/* status 的 ota 一块:失败原因与回滚掉的版本号;版本号里的怪字符不许破坏 JSON。 */
static void test_status_reports_failure_and_rolled_back_version(void)
{
ota_status_t st;
ota_status_get(&st);
st.err = OTA_E_HASH;
st.size = 1234;
snprintf(st.bad, sizeof(st.bad), "0.1.0-\"x");
ota_status_set(&st);
const char *r = send("{\"cmd\":\"status\"}");
ASSERT_HAS(r, "\"ota\":{\"st\":\"idle\",\"got\":0,\"size\":1234,\"err\":\"hash\",\"pend\":false,"
"\"bad\":\"0.1.0-?x\"}");
}
/* ---- 健康判据 ------------------------------------------------------------ */
static void test_health(void)
{
ota_health_in_t in = { .pending = false };
TEST_ASSERT_EQUAL_INT(OTA_H_NONE, ota_health(&in));
in = (ota_health_in_t){ .pending = true, .now_ms = 5000, .boot_done = true, .wifi_set = true,
.wifi_up = true, .mqtt_set = true, .mqtt_up = false };
/* ⭐ 配了 broker:WiFi 通了不算数,MQTT 才是 OTA 的通道 */
TEST_ASSERT_EQUAL_INT(OTA_H_WAIT, ota_health(&in));
in.mqtt_up = true;
TEST_ASSERT_EQUAL_INT(OTA_H_VALID, ota_health(&in));
/* 启动没走完不算健康 */
in.boot_done = false;
TEST_ASSERT_EQUAL_INT(OTA_H_WAIT, ota_health(&in));
/* 超时:正好 120 s 判回滚,差 1 ms 不判 */
in = (ota_health_in_t){ .pending = true, .now_ms = OTA_HEALTH_MS - 1, .boot_done = true,
.wifi_set = true, .wifi_up = true, .mqtt_set = true };
TEST_ASSERT_EQUAL_INT(OTA_H_WAIT, ota_health(&in));
in.now_ms = OTA_HEALTH_MS;
TEST_ASSERT_EQUAL_INT(OTA_H_ROLLBACK, ota_health(&in));
/* 超时之后才连上:也算健康(胶水那一圈先问的是这个) */
in.mqtt_up = true;
TEST_ASSERT_EQUAL_INT(OTA_H_VALID, ota_health(&in));
/* 只配了 WiFi:WiFi 通就行 */
in = (ota_health_in_t){ .pending = true, .now_ms = 1000, .boot_done = true, .wifi_set = true };
TEST_ASSERT_EQUAL_INT(OTA_H_WAIT, ota_health(&in));
in.wifi_up = true;
TEST_ASSERT_EQUAL_INT(OTA_H_VALID, ota_health(&in));
/* 都没配(只经 USB 管的板子):启动走完就行,不会因为「没网」被误回滚 */
in = (ota_health_in_t){ .pending = true, .now_ms = 1000, .boot_done = true };
TEST_ASSERT_EQUAL_INT(OTA_H_VALID, ota_health(&in));
}
static void test_names(void)
{
TEST_ASSERT_EQUAL_STRING("ota", cmd_name(CMD_OTA));
TEST_ASSERT_EQUAL_STRING("hash", ota_err_str(OTA_E_HASH));
TEST_ASSERT_EQUAL_STRING("noslot", ota_err_str(OTA_E_NOSLOT));
TEST_ASSERT_EQUAL_STRING("?", ota_err_str(OTA_E_COUNT));
TEST_ASSERT_EQUAL_STRING("ready", ota_phase_str(OTA_READY));
/* 进 flash 的数字(LOG_EV_OTA 的 arg / bus_mv)钉死 */
TEST_ASSERT_EQUAL_INT(1, OTA_EV_START);
TEST_ASSERT_EQUAL_INT(6, OTA_EV_REBOOT);
TEST_ASSERT_EQUAL_INT(3, OTA_E_HASH);
TEST_ASSERT_EQUAL_INT(7, OTA_E_NOSLOT);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_url_rules);
RUN_TEST(test_sha256_parse_is_strict_and_round_trips);
RUN_TEST(test_plan);
RUN_TEST(test_ota_command_starts_a_download);
RUN_TEST(test_redelivered_ota_starts_once);
RUN_TEST(test_bad_arguments);
RUN_TEST(test_pending_image_refuses_ota);
RUN_TEST(test_ready_image_is_not_downloaded_again);
RUN_TEST(test_no_ota_capability);
RUN_TEST(test_ota_over_usb_is_accepted);
RUN_TEST(test_status_reports_failure_and_rolled_back_version);
RUN_TEST(test_health);
RUN_TEST(test_names);
return UNITY_END();
}
+169
查看文件
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""把一份固件推给板子(C2 OTA,firmware/main/ota.h)。
python tools/ota_push.py b81f3fc9c8bc # 推 build/phonerail.bin,只下载不重启
python tools/ota_push.py b81f3fc9c8bc --reboot # 校验通过后立刻重启(⚠️ 五路全断一次)
python tools/ota_push.py b81f3fc9c8bc --bin x.bin --no-watch
python tools/ota_push.py b81f3fc9c8bc --status # 只看板子 status 里的 ota 一块
做三件事:
1. 算 sha256,把镜像传到服务器 /root/phonerail/fw/<sha256 前 16 位>.bin
(nginx 7765 只放行这种文件名,rack/deploy/nginx-phonerail-fw.conf);
2. 在服务器上用后端账号经 127.0.0.1:1884 往 phonerail/<id>/cmd 发一条 `ota`;
3. 盯着 rsp / tele / state 看进度,直到就绪 / 失败 / 新版本上线(--reboot 时)。
口令只在服务器上读(/root/phonerail/mqtt.env),不经过本机。
seq 用随机大数:板子的判重窗口是全局的,不能和 rack 后端的 seq 撞。
"""
import argparse
import hashlib
import json
import os
import random
import shlex
import subprocess
import sys
SSH_HOST = os.environ.get("PHONERAIL_SSH", "10.5.5.13")
FW_DIR = "/root/phonerail/fw"
PUBLIC_BASE = os.environ.get("PHONERAIL_FW_BASE", "http://home.example:7765/fw")
URL_MAX = 64 # ota.h OTA_URL_MAX
SLOT = 0x300000 # ota.h OTA_SLOT_BYTES
# 在服务器上跑:订阅 rsp/tele/state,发一条命令,逐行打印收到的消息(topic 前缀),超时退出。
REMOTE = r'''
set -e
. /root/phonerail/mqtt.env
T=phonerail/{id}
if [ "{send}" = 1 ]; then
timeout {secs} mosquitto_sub -h 127.0.0.1 -p 1884 -u "$BACKEND_USER" -P "$BACKEND_PASS" -v \
-t "$T/rsp" -t "$T/tele" -t "$T/state" &
SUB=$!
sleep 1
mosquitto_pub -h 127.0.0.1 -p 1884 -u "$BACKEND_USER" -P "$BACKEND_PASS" -q 1 -t "$T/cmd" -m {msg}
wait $SUB || true
else
timeout {secs} mosquitto_sub -h 127.0.0.1 -p 1884 -u "$BACKEND_USER" -P "$BACKEND_PASS" -v \
-t "$T/tele" -C 1 || true
fi
'''
def ssh(cmd: str, **kw):
return subprocess.run(["ssh", SSH_HOST, cmd], **kw)
def watch(board: str, msg: str, secs: int, seq: int, reboot: bool, old_fw: str):
"""发命令并盯进度。返回退出码。"""
script = REMOTE.format(id=board, send=1, secs=secs, msg=shlex.quote(msg))
p = subprocess.Popen(["ssh", SSH_HOST, script], stdout=subprocess.PIPE, text=True,
encoding="utf-8", errors="replace")
last = None
rc = 1
last_up = None
rebooted = False
acked = False # 收到本条命令的回应了
saw_dl = False # 看到过「在下载」—— 之前那些 idle + err 是上一次留下的,不算这一次
try:
for line in p.stdout:
topic, _, body = line.strip().partition(" ")
try:
j = json.loads(body)
except ValueError:
continue
if topic.endswith("/rsp") and j.get("seq") == seq:
print("回应:", body)
if not j.get("ok"):
return 2
acked = True
if j.get("act") == "ready" and not reboot:
return 0
elif topic.endswith("/tele"):
up = j.get("up", 0)
if last_up is not None and up < last_up:
rebooted = True
print("板子重启了(up %d → %d),fw %s" % (last_up, up, j.get("fw")))
last_up = up
o = j.get("ota") or {}
cur = (o.get("st"), o.get("got"), o.get("err"))
if cur != last:
last = cur
print("ota:", json.dumps(o, ensure_ascii=False), " fw", j.get("fw"))
saw_dl = saw_dl or (acked and o.get("st") == "dl")
if not acked:
continue
if saw_dl and o.get("err") and o.get("st") == "idle":
print("失败:", o.get("err"))
return 3
if saw_dl and o.get("st") == "ready" and not reboot:
print("已就绪:下次复位生效(要立刻换:再跑一次加 --reboot,不会重下)")
return 0
if reboot and rebooted and o.get("pend") is False:
if o.get("bad") and j.get("fw") == old_fw:
print("⚠️ 回滚了:%s 被判无效,现在跑的是 %s" % (o.get("bad"), j.get("fw")))
return 4
print("固件 %s 已上线并自证健康" % j.get("fw"))
return 0
elif topic.endswith("/state"):
print("state:", body)
print("超时(%d s)" % secs)
return rc
finally:
p.kill()
def status(board: str) -> dict:
out = ssh(REMOTE.format(id=board, send=0, secs=10, msg="''"), capture_output=True, text=True,
encoding="utf-8").stdout
_, _, body = out.strip().partition(" ")
return json.loads(body) if body else {}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("board", help="板子 ID(STA MAC 12 位小写十六进制)")
ap.add_argument("--bin", default=os.path.join(os.path.dirname(__file__), "..", "build", "phonerail.bin"))
ap.add_argument("--reboot", action="store_true", help="校验通过后立刻重启(五路全断一次)")
ap.add_argument("--no-watch", action="store_true")
ap.add_argument("--status", action="store_true")
ap.add_argument("--timeout", type=int, default=300)
a = ap.parse_args()
st = status(a.board)
if not st:
print("10 s 内没收到这块板的遥测:板子不在线?")
return 1
print("板子当前 fw %s,ota %s" % (st.get("fw"), json.dumps(st.get("ota"), ensure_ascii=False)))
if a.status:
return 0
data = open(a.bin, "rb").read()
if not 0 < len(data) <= SLOT:
print("镜像大小 %d 不在 (0, %d]" % (len(data), SLOT))
return 1
sha = hashlib.sha256(data).hexdigest()
name = sha[:16] + ".bin"
url = "%s/%s" % (PUBLIC_BASE, name)
if len(url) > URL_MAX:
print("URL 超过 %d 字节:%s" % (URL_MAX, url))
return 1
print("镜像 %s %d 字节 sha256 %s" % (a.bin, len(data), sha))
up = ssh("cat > %s/.%s.tmp && mv %s/.%s.tmp %s/%s && chmod 644 %s/%s && sha256sum %s/%s"
% ((FW_DIR, name) * 5), input=data, capture_output=True)
if up.returncode != 0 or sha not in up.stdout.decode():
print("上传失败:", up.stderr.decode(errors="replace"))
return 1
print("已上传 →", url)
seq = random.randint(1 << 30, (1 << 32) - 1)
cmd = {"seq": seq, "cmd": "ota", "url": url, "sha256": sha, "size": len(data), "reboot": a.reboot}
msg = json.dumps(cmd, separators=(",", ":"))
if a.no_watch:
ssh(REMOTE.format(id=a.board, send=1, secs=5, msg=shlex.quote(msg)))
return 0
return watch(a.board, msg, a.timeout, seq, a.reboot, st.get("fw"))
if __name__ == "__main__":
sys.exit(main())
+3 -1
查看文件
@@ -35,6 +35,7 @@ EVENT_TYPES: Dict[int, Tuple[str, str]] = {
15: ("SLOT_EMPTY", "刀片不在位"),
16: ("PD_LIMIT", "PD 限功率"),
17: ("PG_TIMEOUT", "上电等不到 PG"), # A15:arg bit0 = FLT# 在报,bit1 = 按电压判
18: ("OTA", "固件升级"), # C2:arg 1 开始 / 2 就绪 / 3 失败(mV 列=原因) / 4 自证健康 / 5 回滚 / 6 为换固件重启
}
# 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。
@@ -42,7 +43,8 @@ EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14, 17}
# 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",
7: "fan", 8: "events", 9: "power", 10: "wifi", 11: "mqtt", 12: "net"}
7: "fan", 8: "events", 9: "power", 10: "wifi", 11: "mqtt", 12: "net",
13: "ota"}
KEYS = ("pwr", "vup", "vdn")
KEY_MAX_MS = 15000
+1
查看文件
@@ -81,6 +81,7 @@ def test_event_table_matches_firmware_numbers():
assert protocol.EVENT_TYPES[13][0] == "CMD_REJECT"
assert protocol.EVENT_TYPES[16][0] == "PD_LIMIT"
assert protocol.CMD_NAMES[9] == "power" and protocol.CMD_NAMES[12] == "net"
assert protocol.EVENT_TYPES[18][0] == "OTA" and protocol.CMD_NAMES[13] == "ota" # C2
def test_topic_matching():
+27
查看文件
@@ -0,0 +1,27 @@
# PhoneRail 固件 OTA 下载口(C2)。部署到 /etc/nginx/conf.d/phonerail-fw.conf。
#
# 为什么不走统一门户(7755):板子过不了登录门禁。这里**故意不设门禁**,理由:
# · 完整性不靠这条通道:`ota` 命令经 MQTT/TLS 带 sha256,板子哈希对不上就不切分区
# (firmware/main/ota.h)—— 明文下载被中间人改了也只是一次失败的 OTA;
# · 镜像里没有机密:WiFi / broker 口令都在板子的 NVS 里,不编进固件(IF-19)。
# 仍然收得很窄:只认 `/fw/<16 位小写十六进制>.bin`(文件名 = sha256 前 16 位,猜不到),
# 其余一律 404,不开目录列表。上传用 firmware/tools/ota_push.py。
server {
listen 7765;
listen [::]:7765;
server_name _;
root /root/phonerail;
autoindex off;
location ~ "^/fw/[0-9a-f]{16}\.bin$" {
default_type application/octet-stream;
add_header Cache-Control "no-store";
limit_except GET { deny all; }
}
location / { return 404; }
server_tokens off;
access_log /var/log/nginx/phonerail-fw.access.log;
error_log /var/log/nginx/phonerail-fw.error.log warn;
}
+1 -1
查看文件
@@ -517,7 +517,7 @@ R-3 依赖 R-1+R-2;R-4 依赖 R-3;R-5 / R-6 随时可插。
| 包 | 内容 | 触发条件 |
|---|---|---|
| **C1** | WiFi 配网流程(USB 下发凭据写 NVS,IF-19) | A14 之后 |
| **C2** | OTA:双分区 + 失败回滚 | B9 之后 |
| **C2** | ✅ OTA:双分区 + 失败回滚(2026-09-27,见需求 §10.8「C2 定稿的 OTA」) | B9 之后 |
| **C3** | 出厂标定流程(两点线性标定存 NVS) | B2 之后 |
| **C4** | 文档回填:实测值填回 `需求文档.md` §12.2,坑写进 §10 | 随时 |
+23 -1
查看文件
@@ -848,6 +848,28 @@ base = `phonerail/<id>`,id = 板子 STA MAC 的 12 位小写十六进制(板
- 失联不动作:WiFi 断按 1/2/4…60 s 退避重连,MQTT 由 esp-mqtt 每 10 s 重连;没有任何分支会碰输出。
- 实测(板 B,2026-09-27):经 WiFi `CANBOX-5G` → 公网 → 8883 TLS 连上;判重 / `link` / `empty` / `parse` 回应与遥测(1 条/s)全部符合。
#### C2 定稿的 OTA(2026-09-27,`firmware/main/ota.h` / `ota_esp.c`)
用户定的三条:**明文 HTTP 下载 + 命令带 sha256**(哈希走 MQTT/TLS,下载被改了只会哈希不对)、**不签名**(不烧 eFuse)、
**新固件 120 s 内连上 broker 才算健康,否则回滚**。
- 命令(两条链路都收,会动东西 ⇒ 必须带 seq、进判重窗口):
`{"seq":N,"cmd":"ota","url":"http://home.example:7765/fw/<sha256 前 16 位>.bin","sha256":"<64 位小写>","size":N,"reboot":false}`
回 `{"seq":N,"ok":true,"act":"start"|"reboot"|"ready"}`;下载中再来 `busy`;新固件还没自证时 `state`;size 超一个槽(3 MB)`range`。
url 只收 `http://`、≤64 字节(命令字符串上限)。
- ⚠️ **`reboot` 默认 false**:ESP 一复位 EN 就被下拉,**五台手机硬断电一次**,起来后按意图逐路恢复。
默认只下载 + 校验 + 切启动分区、下次复位生效;同一个镜像已就绪时再发带 `"reboot":true` 的,不重下、直接重启。回滚也是一次复位。
- status 多一块 `"ota":{"st":"idle|dl|ready","got":N,"size":N,"err":null|"http|size|hash|flash|image|boot|noslot","pend":bool,"bad":null|"版本号"}`;
`pend` = 正在跑的固件还没自证,`bad` = 被判无效回滚掉的那份的版本号(镜像头版本 = `PHONERAIL_FW_VERSION`)。
- 新事件 **`LOG_EV_OTA`(18)**,arg:1 开始 / 2 就绪 / 3 失败(bus_mv = 原因号)/ 4 自证健康 / 5 回滚 / 6 为换固件重启。新命令号 **`ota` = 13**。
- 健康判据:启动走完 + 配了 broker 就要 MQTT 连上(只配 WiFi 则 WiFi 通、都没配只看启动);在 `t_link` 里判,另有 esp_timer 在 150 s 兜底
(app_main 卡死也能回滚);自证前崩溃 / 看门狗由 bootloader 回滚(`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`)。
- 分区(16 MB):nvs 0x9000(没动)/ phy_init / **ota_0 0x10000、ota_1 0x310000 各 3 MB** / otadata 0x610000 / evlog 0x612000。
- 服务器:nginx 7765(`rack/deploy/nginx-phonerail-fw.conf`)只放行 `/fw/<16 位十六进制>.bin`,**不过门户**(板子过不了门禁;
完整性靠哈希、镜像里无机密);路由器 7765 转发 2026-09-27 已加。推送工具 `firmware/tools/ota_push.py <id> [--reboot]`。
- 实测(板 B,2026-09-27):1 MB 下载 ≈8 s,推送到自证健康 19 s;错哈希 ⇒ `err:hash`、不切分区;删掉板子 broker 账号后推送 ⇒
120.1 s 回滚到旧版、`bad` 报出被回滚的版本;下载期间巡检 `skipped` 0。
### 10.9 启动顺序
```
@@ -920,7 +942,7 @@ FR-12 是「中(可选)」,建议**首版先不做**,把接口留出来
| 项 | 状态 |
|---|---|
| ~~上位机链路选型(10.8)~~ | ✅ **2026-09-06 用户拍板:方案 C**,WiFi 常态 + USB CDC 兜底 |
| **OTA 升级** | 既然定了 WiFi,**建议做**:双分区 + 失败回滚。没有 OTA 的话一次坏固件要把板子从机架上拆下来插 USB,而 USB 兜底链路本身也能烧 —— 所以这是「省事」不是「救命」,可以排在首版之后 |
| ~~OTA 升级~~ | ✅ **2026-09-27 C2 完成**:双槽 + 失败回滚,定稿见 §10.8「C2 定稿的 OTA」 |
| WiFi 掉线重连策略 | 指数退避 + 上限;**重连过程不得阻塞 `t_safety`**(10.3),链路任务优先级最低就是为这个 |
| **MQTT broker 与 topic** | 2026-09-27 起由本项目自定(D7,broker 在 home 服务器);topic 约定随 A14 写进 §10.8 |
| 时间同步 | 当前方案是上位机下发墙钟做偏移,不加 RTC。若日志要脱机长期归档需重议 |