diff --git a/PROGRESS.md b/PROGRESS.md index d1e1ddb..b460bd4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 [--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(启动顺序 + 降级矩阵)完成,已上板 diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index d1fea6d..fa90100 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -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) diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index 21a16bb..f32483d 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -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/.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 一格、格内线性插值); diff --git a/firmware/host/CMakeLists.txt b/firmware/host/CMakeLists.txt index 153e211..942794b 100644 --- a/firmware/host/CMakeLists.txt +++ b/firmware/host/CMakeLists.txt @@ -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 目标编)。 diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index a9376e5..72b4b42 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -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) diff --git a/firmware/main/app_main.c b/firmware/main/app_main.c index 9f530c6..5324cd5 100644 --- a/firmware/main/app_main.c +++ b/firmware/main/app_main.c @@ -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 每周期", diff --git a/firmware/main/cmd.c b/firmware/main/cmd.c index 648450c..d62ab14 100644 --- a/firmware/main/cmd.c +++ b/firmware/main/cmd.c @@ -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) diff --git a/firmware/main/cmd.h b/firmware/main/cmd.h index 7a2ccc3..c7dd65b 100644 --- a/firmware/main/cmd.h +++ b/firmware/main/cmd.h @@ -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; diff --git a/firmware/main/cmd_net.c b/firmware/main/cmd_net.c index ac438a7..140a4d2 100644 --- a/firmware/main/cmd_net.c +++ b/firmware/main/cmd_net.c @@ -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 #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; +} diff --git a/firmware/main/cmd_priv.h b/firmware/main/cmd_priv.h index ac0d603..d565618 100644 --- a/firmware/main/cmd_priv.h +++ b/firmware/main/cmd_priv.h @@ -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); diff --git a/firmware/main/cmd_report.c b/firmware/main/cmd_report.c index efd067b..2033f46 100644 --- a/firmware/main/cmd_report.c +++ b/firmware/main/cmd_report.c @@ -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; diff --git a/firmware/main/evlog.c b/firmware/main/evlog.c index f5de915..7b7bd5f 100644 --- a/firmware/main/evlog.c +++ b/firmware/main/evlog.c @@ -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 "?"; diff --git a/firmware/main/evlog.h b/firmware/main/evlog.h index 1437856..113d3b8 100644 --- a/firmware/main/evlog.h +++ b/firmware/main/evlog.h @@ -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; diff --git a/firmware/main/ota.c b/firmware/main/ota.c new file mode 100644 index 0000000..b80095f --- /dev/null +++ b/firmware/main/ota.c @@ -0,0 +1,189 @@ +/* OTA 的平台无关部分(C2)。说明在 ota.h。 */ +#include "ota.h" + +#include + +#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; +} diff --git a/firmware/main/ota.h b/firmware/main/ota.h new file mode 100644 index 0000000..fcf7bfd --- /dev/null +++ b/firmware/main/ota.h @@ -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 +#include +#include + +#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 */ diff --git a/firmware/main/ota_esp.c b/firmware/main/ota_esp.c new file mode 100644 index 0000000..2d8ecf9 --- /dev/null +++ b/firmware/main/ota_esp.c @@ -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 + +#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(); + } +} diff --git a/firmware/main/ota_esp.h b/firmware/main/ota_esp.h new file mode 100644 index 0000000..03acb55 --- /dev/null +++ b/firmware/main/ota_esp.h @@ -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 */ diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h index bf75dd9..5853ea7 100644 --- a/firmware/main/phonerail.h +++ b/firmware/main/phonerail.h @@ -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 diff --git a/firmware/partitions.csv b/firmware/partitions.csv index 9418b94..e8e1e76 100644 --- a/firmware/partitions.csv +++ b/firmware/partitions.csv @@ -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, diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index 23d43cb..95bf4e6 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -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,会把命令回应拆坏。 diff --git a/firmware/test/test_cmd.c b/firmware/test/test_cmd.c index cdd791d..5cb20c9 100644 --- a/firmware/test/test_cmd.c +++ b/firmware/test/test_cmd.c @@ -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); diff --git a/firmware/test/test_evlog.c b/firmware/test/test_evlog.c index 62a83a5..dd7d874 100644 --- a/firmware/test/test_evlog.c +++ b/firmware/test/test_evlog.c @@ -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 只产生其中两类。剩下的号先占住, diff --git a/firmware/test/test_ota.c b/firmware/test/test_ota.c new file mode 100644 index 0000000..04f0ff6 --- /dev/null +++ b/firmware/test/test_ota.c @@ -0,0 +1,385 @@ +/* C2:OTA 的平台无关部分(ota.h)+ `ota` 命令。 + + 下载 / 写 flash / 切分区是 ESP 胶水,这里测不到;测的是会写错而上板不容易查出来的那几件: + · 参数校验:URL 只收 http、哈希只收 64 位小写十六进制、size 不许超过一个槽; + · 状态判定:下载中拒第二条、**待验证的固件不许再 OTA**(备用槽里是回滚用的旧版)、 + 同一个镜像已就绪时不重下; + · 判重:QoS 1 重投的同一条 ota 不会开工两次; + · 回滚判据:该通的那一层网络没通就不算健康,超时判回滚。 */ +#include +#include + +#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(); +} diff --git a/firmware/tools/ota_push.py b/firmware/tools/ota_push.py new file mode 100644 index 0000000..eb0d996 --- /dev/null +++ b/firmware/tools/ota_push.py @@ -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/.bin + (nginx 7765 只放行这种文件名,rack/deploy/nginx-phonerail-fw.conf); + 2. 在服务器上用后端账号经 127.0.0.1:1884 往 phonerail//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()) diff --git a/rack/backend/prrack/protocol.py b/rack/backend/prrack/protocol.py index e525189..eacea0e 100644 --- a/rack/backend/prrack/protocol.py +++ b/rack/backend/prrack/protocol.py @@ -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 diff --git a/rack/backend/tests/test_core.py b/rack/backend/tests/test_core.py index 89644b1..f069982 100644 --- a/rack/backend/tests/test_core.py +++ b/rack/backend/tests/test_core.py @@ -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(): diff --git a/rack/deploy/nginx-phonerail-fw.conf b/rack/deploy/nginx-phonerail-fw.conf new file mode 100644 index 0000000..6f8d5a9 --- /dev/null +++ b/rack/deploy/nginx-phonerail-fw.conf @@ -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; +} diff --git a/固件开发计划.md b/固件开发计划.md index 5add5d1..901786b 100644 --- a/固件开发计划.md +++ b/固件开发计划.md @@ -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 | 随时 | diff --git a/需求文档.md b/需求文档.md index f05644d..530bcea 100644 --- a/需求文档.md +++ b/需求文档.md @@ -848,6 +848,28 @@ base = `phonerail/`,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/.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 [--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。若日志要脱机长期归档需重议 |