From 7bf30e3c38aa899181ecc240d7e14fc0cb76616c Mon Sep 17 00:00:00 2001 From: zhou yang Date: Sun, 27 Sep 2026 14:35:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E4=BB=B6=200.1.0-c3=EF=BC=9A=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E5=86=85=E9=87=8D=E5=90=AF=E4=B8=8D=E6=96=AD=E7=94=B5?= =?UTF-8?q?=EF=BC=88OTA=20/=20=E5=9B=9E=E6=BB=9A=E9=94=81=E4=BD=8F=20EFUSE?= =?UTF-8?q?=5FEN=EF=BC=8C=E6=96=B0=E5=9B=BA=E4=BB=B6=E6=8E=A5=E7=AE=A1?= =?UTF-8?q?=EF=BC=9BSR-6=20=E6=94=B9=E5=86=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - warm_esp.{h,c}:warm_restart() 记下开着的路(RTC_NOINIT,魔数 + 掩码 + 反码)→ gpio_hold 锁五根 EN → 重启; 目标镜像没有能力标记(第 288 字节 PRCAPS1,.rodata_custom_desc)就不锁 —— 老固件写不动锁住的 EN。 warm_take():软件复位 + 标记对得上才接管,一次性;再读 EN 真电平核对 - bringup:adopt 掩码;rail_en_init_keep() 接管的路写高、五根一律放开 hold(崩溃时旧锁可能还在); 刀片不在 / mux 失联的接管路立刻关(SLOT_EMPTY arg 1);第二段已开着的路不重开、不等 PG - pca9555_init_keep():方向寄存器是本固件写的才留 VBUS(掉过电的片子锁存器是 0xFF),键 / EDL 清零 - HAL:hal_gpio_hold();ESP 输出脚配 INPUT_OUTPUT(读得到真电平);sim 给 hold 建模 - 事件 LOG_EV_OTA arg 7 = 接管;兜底定时器回滚故意不走接管 - 顶层 CMake:phonerail.h 进 CMAKE_CONFIGURE_DEPENDS(否则镜像头版本停在旧值);-u g_phonerail_caps 防 gc - test_bringup 加 6 例(接管 / 非计划复位全关 / 空槽 / mux 失联 / PCA 掉过电 / hold 冻结),全工程 448 例全绿 - rack:固件库识别能力标记,推送对话框按「支持 / 不支持重启不断电」给不同提示(已部署) - 需求文档:SR-6 改为「非计划复位全关」+ 计划内重启例外;§10.9 新增「重启不断电」 - 板 B:台架镜像(空槽强制 CH1 在位,未提交)开 CH1 → OTA 带重启 → 事件 18/7 mv=1,CH1 一直开着; 现跑 0.1.0-c3。未测:示波器看重启期间 EN / 输出毛刺(要插刀片) Co-Authored-By: Claude Opus 5.5 --- PROGRESS.md | 7 + firmware/CMakeLists.txt | 3 + firmware/HANDOFF.md | 33 +++- firmware/components/drv/include/pca9555.h | 6 + firmware/components/drv/pca9555.c | 31 +++- .../phonerail_hal/esp/hal_gpio_esp.c | 13 +- .../phonerail_hal/include/hal_gpio.h | 8 + .../phonerail_hal/sim/hal_gpio_sim.c | 97 +++++++++-- .../components/phonerail_hal/sim/sim_hal.h | 5 + firmware/main/CMakeLists.txt | 7 +- firmware/main/app_main.c | 13 +- firmware/main/bringup.c | 25 ++- firmware/main/bringup.h | 10 ++ firmware/main/ota.h | 11 +- firmware/main/ota_esp.c | 12 +- firmware/main/phonerail.h | 2 +- firmware/main/rail_cfg.c | 43 ++++- firmware/main/rail_cfg.h | 13 ++ firmware/main/slot.c | 15 ++ firmware/main/slot.h | 5 + firmware/main/warm_esp.c | 135 +++++++++++++++ firmware/main/warm_esp.h | 24 +++ firmware/test/test_bringup.c | 161 ++++++++++++++++++ firmware/test/test_ota.c | 1 + rack/backend/prrack/firmware.py | 16 +- rack/backend/prrack/protocol.py | 3 +- rack/backend/tests/test_firmware.py | 12 +- rack/frontend/src/pages/Firmware.vue | 14 +- 需求文档.md | 22 ++- 29 files changed, 690 insertions(+), 57 deletions(-) create mode 100644 firmware/main/warm_esp.c create mode 100644 firmware/main/warm_esp.h diff --git a/PROGRESS.md b/PROGRESS.md index b460bd4..ef7c309 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,5 +1,12 @@ # 进度与交接 +## ⭐ 2026-09-27 夜:固件 0.1.0-c3「重启不断电」,已上板 + +- OTA 换固件 / 超时回滚锁住 `EFUSE_EN`、新固件接管,手机不断电;崩溃 / 看门狗 / 上电仍全关。**SR-6 已改写**(需求 §10.9「重启不断电」)。 +- 三道闸:RTC 标记 + 目标镜像能力标记(老固件不认就不锁,否则五路关不掉)+ 起来读 EN 真电平。VBUS 保持,键 / EDL 松开。 +- 板 B 台架验证:CH1 开着 OTA 重启 → 新固件读到 IO9 仍为高并接管(事件 18/7)。**还欠**:插刀片 + 示波器看 EN / 输出有没有毛刺。 +- rack 固件库显示「重启不断电:支持 / 不支持」,推送对话框按它给不同的提示。宿主机 448 例、rack 39 例全绿。 + ## ⭐ 2026-09-27 夜:固件 C2 OTA 完成(双槽 + 失败回滚),已上板 - `ota` 命令给 URL + sha256 + size,板子经 HTTP(服务器 nginx 7765,不过门户)下载,哈希对上才切分区;新固件 120 s 内连不上 broker 就回滚。 diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index fa90100..6f4b8cb 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -13,6 +13,9 @@ if(NOT CMAKE_MATCH_1) message(FATAL_ERROR "main/phonerail.h 里找不到 PHONERAIL_FW_VERSION") endif() set(PROJECT_VER "${CMAKE_MATCH_1}") +# 改了 phonerail.h 要重新配置:不然增量构建沿用上一次读到的版本(0.1.0-c3 上板实测踩过: +# 板子自报 c3,镜像头却还是 c2)。 +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/main/phonerail.h") include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(phonerail) diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index 2e585eb..27887cc 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -1,10 +1,30 @@ # 交接 -更新:2026-09-27 完成 **C2 OTA**(双槽 + 失败回滚,已上板实测正常 / 哈希错 / 回滚三条路径) +更新:2026-09-27 **0.1.0-c3:重启不断电**(计划内重启锁住 EFUSE_EN、新固件接管;SR-6 已改写) ## 一句话现状 -`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」。 +OTA 换固件 / 超时回滚现在走 `warm_restart()`(`main/warm_esp.c`):目标固件认接管 ⇒ 手机不断电; +崩溃 / 看门狗 / 上电 / 兜底回滚仍全关(SR-6)。**26 个 ctest、448 个用例全绿**。板 B 跑 **0.1.0-c3**(经 OTA 推上去的)。 +定稿写在需求 §10.9「重启不断电」。C2 OTA 的说明在下面。 + +## 重启不断电(c3) +- 三道闸:RTC 标记(软件复位 + 魔数 / 反码,一次性)· 目标镜像能力标记(第 288 字节 `PRCAPS1`,老固件不认就不锁)· 起来后读 EN 真电平核对。 +- `rail_en_init_keep(adopt)` **五根一律放开 hold**:崩溃时上一条命的锁可能还在。 +- `pca9555_init_keep()`:方向寄存器是我们写的才留 VBUS;键 / EDL 清零。 +- 实测:台架镜像(空槽强制 CH1 在位,**没提交**)开 CH1 → OTA 带重启 → 事件 `18/7 mv=1`(新固件读到 IO9 仍为高、接管)。 + ⚠️ 没测到的:示波器上看 EN 在重启期间有没有毛刺(`warm_take` 读到高只能说明「起来时还是高」); + 「锁着 + 非计划复位」这条路径只有宿主机测试(真板上造不出来)。插上刀片 + 手机后要看一次手机有没有掉电重启。 +- **第一次从 c2 升到 c3 仍会断电一次**:锁 EN 的是**正在跑**的固件,c2 不会锁。 + +## 下一步(没人定,候选) +- 示波器验证:插一片刀片,CH 开着,OTA 带重启,抓 EN / 输出电压(`scope_*` 工具)。 +- 小毛病(以前就有):PCA9555 不应答时 `power off` 回 `busy`,其实 eFuse 已经关了、意图也清了(`from_hal` 把 VBUS 失败的 HAL_ERR_STATE 映射成了 busy)。 +- 服务器 fw 目录不会自动清理旧镜像(每个 ~1 MB,问题不大)。 + +## 踩到的坑(c3) +- `.rodata_custom_desc` 没人引用会被 `--gc-sections` 丢掉 ⇒ `main/CMakeLists.txt` 加 `-u g_phonerail_caps`。 +- `PROJECT_VER` 在 CMake 配置时读 ⇒ 改 `phonerail.h` 不会重配,镜像头版本会是旧的 ⇒ 顶层加了 `CMAKE_CONFIGURE_DEPENDS`。 +- 撤掉台架补丁后要**重新编译**再推:`build/phonerail.bin` 还是台架版(这次推错了一次,已换回)。 +- Git Bash heredoc 里的 Python 写 `\0` 又变成真 NUL 了(这次在 rack/firmware.py)—— 含反斜杠的一律写 .py 文件。 ## 怎么推固件 ``` @@ -16,11 +36,6 @@ python tools/ota_push.py b81f3fc9c8bc --status 再在服务器上用后端账号经 1884 发命令、盯遥测。 **rack 管理界面「固件」页也能推**(2026-09-27 加,上传 → 选板 → 可选立刻重启并二次确认;见 `rack/README.md`「推固件」)。 -## 下一步(没人定,候选) -- ~~rack 界面加「推固件」~~ ✅ 2026-09-27 已上线。 -- 「重启不断电」: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(历史事件清空了一次)。 diff --git a/firmware/components/drv/include/pca9555.h b/firmware/components/drv/include/pca9555.h index 725fded..ac3e35c 100644 --- a/firmware/components/drv/include/pca9555.h +++ b/firmware/components/drv/include/pca9555.h @@ -133,6 +133,12 @@ hal_err_t pca9555_probe(void); 通道必须由调用方先选好(本驱动不碰 mux)。 */ hal_err_t pca9555_init(int slot); +/* 同上,但输出锁存器里 `keep_mask` 那几位**保留现值**(「重启不断电」时留 VBUS, + 按键 / EDL 照样清零 —— 重启前正按着的键,起来就松开)。 + **只在这片的方向寄存器已经是 PCA9555_CONFIG0_INIT 时才留**:否则它掉过电, + 锁存器是复位值 0xFF,留下来 = 凭空打开 VBUS。`keep_mask` 只许是 Port0 的输出位。 */ +hal_err_t pca9555_init_keep(int slot, uint8_t keep_mask); + /* 巡检主路径:**一次事务**读两个输入端口(PCA9555 的读指针在 0x00 / 0x01 之间 toggle,这两个字节是 datasheet 保证的)。 */ typedef struct { diff --git a/firmware/components/drv/pca9555.c b/firmware/components/drv/pca9555.c index af7b6fc..aa657ba 100644 --- a/firmware/components/drv/pca9555.c +++ b/firmware/components/drv/pca9555.c @@ -164,18 +164,45 @@ hal_err_t pca9555_probe(void) return (c0 == PCA9555_DIR_ALL_INPUT && c1 == PCA9555_DIR_ALL_INPUT) ? HAL_OK : HAL_ERR_BUS; } +static hal_err_t init_with(int slot, uint8_t out0); + hal_err_t pca9555_init(int slot) { if (slot < 0 || slot >= PCA9555_SLOT_COUNT) { return HAL_ERR_ARG; } + return init_with(slot, 0x00); +} + +hal_err_t pca9555_init_keep(int slot, uint8_t keep_mask) +{ + if (slot < 0 || slot >= PCA9555_SLOT_COUNT || (keep_mask & (uint8_t)~PCA9555_P0_OUT_MASK) != 0) { + return HAL_ERR_ARG; + } + uint8_t cfg0 = 0; + uint8_t out0 = 0; + hal_err_t err = read_reg(PCA9555_REG_CONFIG0, &cfg0); + if (err == HAL_OK) { + err = read_reg(PCA9555_REG_OUTPUT0, &out0); + } + if (err != HAL_OK) { + return err; + } + /* ⭐ 只有「这片已经被上一条命配过」才留:方向字不是我们写的那个 = 它掉过电(复位值 + 0xFF 的输出锁存器里 VBUS 位是 1),照留就是凭空把 VBUS 打开。 */ + const uint8_t keep = (cfg0 == PCA9555_CONFIG0_INIT) ? (uint8_t)(out0 & keep_mask) : 0u; + return init_with(slot, keep); +} + +static hal_err_t init_with(int slot, uint8_t out0) +{ /* 方向字的合法性是编译期就能判的事,assert 拦的是「哪天有人顺手改了常量」。 */ assert(pca9555_dir_safe(0, PCA9555_CONFIG0_INIT)); assert(pca9555_dir_safe(1, PCA9555_CONFIG1_INIT)); /* ⛔ 顺序就是安全本身(头文件那个框):**输出锁存器先清零,方向最后改**。 复位值 0xFF 的输出锁存器 + 先改方向 = 三个键全按下 + VBUS 上电。 */ - hal_err_t err = write_verify(PCA9555_REG_OUTPUT0, 0x00); + hal_err_t err = write_verify(PCA9555_REG_OUTPUT0, out0); if (err != HAL_OK) { return err; } @@ -206,7 +233,7 @@ hal_err_t pca9555_init(int slot) return err; } - s_out0[slot] = 0; + s_out0[slot] = out0; return HAL_OK; } diff --git a/firmware/components/phonerail_hal/esp/hal_gpio_esp.c b/firmware/components/phonerail_hal/esp/hal_gpio_esp.c index f540418..6ab6812 100644 --- a/firmware/components/phonerail_hal/esp/hal_gpio_esp.c +++ b/firmware/components/phonerail_hal/esp/hal_gpio_esp.c @@ -17,7 +17,9 @@ hal_err_t hal_gpio_config(int pin, hal_gpio_mode_t mode) } gpio_config_t cfg = { .pin_bit_mask = 1ULL << pin, - .mode = (mode == HAL_GPIO_OUTPUT) ? GPIO_MODE_OUTPUT : GPIO_MODE_INPUT, + /* 输出也开输入缓冲:`hal_gpio_read()` 读到的是**引脚上的真电平**,不是寄存器。 + 「重启不断电」靠它核对锁住的 EN 脚是不是真的还是高(warm_esp.c)。 */ + .mode = (mode == HAL_GPIO_OUTPUT) ? GPIO_MODE_INPUT_OUTPUT : GPIO_MODE_INPUT, .pull_up_en = (mode == HAL_GPIO_INPUT_PULLUP) ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE, .intr_type = GPIO_INTR_DISABLE, @@ -45,3 +47,12 @@ hal_err_t hal_gpio_read(int pin, int *level) *level = gpio_get_level(pin); return HAL_OK; } + +hal_err_t hal_gpio_hold(int pin, bool on) +{ + if (pin < 0 || pin >= PIN_MAX) { + return HAL_ERR_ARG; + } + return ((on ? gpio_hold_en(pin) : gpio_hold_dis(pin)) == ESP_OK) ? HAL_OK : HAL_ERR_ARG; +} + diff --git a/firmware/components/phonerail_hal/include/hal_gpio.h b/firmware/components/phonerail_hal/include/hal_gpio.h index ad09938..4c10321 100644 --- a/firmware/components/phonerail_hal/include/hal_gpio.h +++ b/firmware/components/phonerail_hal/include/hal_gpio.h @@ -2,6 +2,8 @@ #ifndef HAL_GPIO_H #define HAL_GPIO_H +#include + #include "hal_err.h" #ifdef __cplusplus @@ -24,6 +26,12 @@ hal_err_t hal_gpio_write(int pin, int level); /* level 用出参,是为了把「读到低电平」和「读失败」分开。 */ hal_err_t hal_gpio_read(int pin, int *level); +/* 引脚保持(「重启不断电」用,warm_esp.c / rail_cfg.c)。on = 把**此刻的引脚状态**锁住: + 之后的配置与写入只改寄存器、不改引脚,**跨软件复位 / 看门狗复位保持**,上电复位才释放。 + off = 放开,引脚跟上寄存器现值 —— 所以接管的顺序是「先配成输出、写好电平,再放开」。 + ⚠️ 锁着的脚固件写不动:`t_safety` 关不掉。只许在 esp_restart() 的前一刻锁,起来第一件事放开。 */ +hal_err_t hal_gpio_hold(int pin, bool on); + #ifdef __cplusplus } #endif diff --git a/firmware/components/phonerail_hal/sim/hal_gpio_sim.c b/firmware/components/phonerail_hal/sim/hal_gpio_sim.c index 531e364..5a3d595 100644 --- a/firmware/components/phonerail_hal/sim/hal_gpio_sim.c +++ b/firmware/components/phonerail_hal/sim/hal_gpio_sim.c @@ -13,6 +13,9 @@ typedef struct { uint64_t rise_us; /* 最后一次 0→1 的虚拟时刻(µs),0 = 没有过 */ bool ext; /* 外部世界在驱这根线(`sim_gpio_drive`),跨 config 保持 */ int ext_level; + bool held; /* hal_gpio_hold:引脚锁在 pad 上,`level` 只是寄存器 */ + int pad; /* 锁着时引脚上的电平 */ + uint32_t holds; /* 被锁过几次 */ } pin_t; static sim_gpio_hook_t s_hook; @@ -40,12 +43,40 @@ void sim_gpio_reset_internal(void) static bool valid(int pin) { return pin >= 0 && pin < SIM_GPIO_PINS; } +/* 引脚上的电平:锁着看 pad,没锁看寄存器。 */ +static int pad_of(const pin_t *p) { return p->held ? p->pad : p->level; } + +/* 引脚真的变了才通知外部世界(假 595 / 假 mux / 测试的钩子)。 */ +static void notify(int pin, int was) +{ + pin_t *p = &s_pin[pin]; + const int now = pad_of(p); + if (now == was) { + return; + } + if (was == 0 && now == 1) { + p->rise_us = hal_time_now_us(); + } + sim_sr595_gpio_hook(pin, now); + sim_mux_gpio_hook(pin, now); + if (s_hook != NULL) { + s_hook(pin, now); + } +} + hal_err_t hal_gpio_config(int pin, hal_gpio_mode_t mode) { if (!valid(pin)) { return HAL_ERR_ARG; } pin_t *p = &s_pin[pin]; + if (p->held) { + /* 锁着:方向与寄存器照改,引脚不动(ESP 的 gpio_hold 就是这样)。 */ + p->configured = true; + p->mode = mode; + p->level = 0; + return HAL_OK; + } p->configured = true; p->mode = mode; /* 上拉输入没人驱动时读回高电平 —— 五路 FLT/PG 都是开漏 + 上拉, @@ -71,27 +102,65 @@ hal_err_t hal_gpio_write(int pin, int level) if (!valid(pin) || !s_pin[pin].configured || s_pin[pin].mode != HAL_GPIO_OUTPUT) { return HAL_ERR_ARG; } - const int was = s_pin[pin].level; - s_pin[pin].level = level ? 1 : 0; - s_pin[pin].writes++; - s_pin[pin].seq = ++s_seq; - if (was == 0 && s_pin[pin].level == 1) { - s_pin[pin].rise_us = hal_time_now_us(); + pin_t *p = &s_pin[pin]; + if (p->held) { + p->level = level ? 1 : 0; /* 只改寄存器;writes 不计 —— 线上什么都没发生 */ + return HAL_OK; } - sim_sr595_gpio_hook(pin, s_pin[pin].level); - sim_mux_gpio_hook(pin, s_pin[pin].level); + const int was = p->level; + p->level = level ? 1 : 0; + p->writes++; + p->seq = ++s_seq; + if (was == 0 && p->level == 1) { + p->rise_us = hal_time_now_us(); + } + sim_sr595_gpio_hook(pin, p->level); + sim_mux_gpio_hook(pin, p->level); if (s_hook != NULL) { - s_hook(pin, s_pin[pin].level); + s_hook(pin, p->level); } return HAL_OK; } +hal_err_t hal_gpio_hold(int pin, bool on) +{ + if (!valid(pin)) { + return HAL_ERR_ARG; + } + pin_t *p = &s_pin[pin]; + if (on) { + if (!p->held) { + p->pad = p->level; + p->held = true; + p->holds++; + } + return HAL_OK; + } + if (p->held) { + const int was = p->pad; + p->held = false; + notify(pin, was); + } + return HAL_OK; +} + +void sim_gpio_preheld(int pin, int level) +{ + assert(valid(pin)); + pin_t *p = &s_pin[pin]; + p->held = true; + p->pad = level ? 1 : 0; + p->level = 0; +} + +bool sim_gpio_held(int pin) { return valid(pin) && s_pin[pin].held; } + hal_err_t hal_gpio_read(int pin, int *level) { if (!valid(pin) || !s_pin[pin].configured || level == NULL) { return HAL_ERR_ARG; } - *level = s_pin[pin].level; + *level = pad_of(&s_pin[pin]); return HAL_OK; } @@ -110,7 +179,13 @@ void sim_gpio_drive(int pin, int level) int sim_gpio_level(int pin) { - if (!valid(pin) || !s_pin[pin].configured) { + if (!valid(pin)) { + return -1; + } + if (s_pin[pin].held) { + return s_pin[pin].pad; /* 锁着的脚即使这一条命还没配过,线上也是有电平的 */ + } + if (!s_pin[pin].configured) { return -1; } return s_pin[pin].level; diff --git a/firmware/components/phonerail_hal/sim/sim_hal.h b/firmware/components/phonerail_hal/sim/sim_hal.h index a8d371d..0883449 100644 --- a/firmware/components/phonerail_hal/sim/sim_hal.h +++ b/firmware/components/phonerail_hal/sim/sim_hal.h @@ -120,6 +120,11 @@ void sim_gpio_drive(int pin, int level); /* 该脚当前电平:输出脚 = 固件驱动的值,输入脚 = 外部驱动的值。没配过返回 -1。 */ int sim_gpio_level(int pin); +/* 「上一条命锁住的脚」:模拟计划内重启之后、这一条命还没配之前,引脚被 hold 在 level。 + (sim_hal_reset() 之后调,代表重启前后外部世界是连续的。) */ +void sim_gpio_preheld(int pin, int level); +bool sim_gpio_held(int pin); + /* 被 hal_gpio_write() 写成功过几次。断言「改 CH1 没动 CH2~5」用得上(A5)。 */ uint32_t sim_gpio_writes(int pin); diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 72b4b42..1d4f092 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 / ota_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过 +# app_main.c / ulink_esp.c / mlink_esp.c / thermal_esp.c / ota_esp.c / warm_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过 # (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。 idf_component_register( SRCS "app_main.c" @@ -29,9 +29,14 @@ idf_component_register( "thermal_esp.c" "ota.c" "ota_esp.c" + "warm_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 app_update esp_http_client mbedtls) + +# warm_esp.c 的能力标记(.rodata_custom_desc,镜像第 288 字节)没有代码引用它, +# 不加 -u 会被 --gc-sections 丢掉 —— 那样每个新固件都会被当成「不认接管」,EN 永远不锁(安全,但功能白做)。 +target_link_libraries(${COMPONENT_LIB} INTERFACE "-u g_phonerail_caps") diff --git a/firmware/main/app_main.c b/firmware/main/app_main.c index 5324cd5..4955d29 100644 --- a/firmware/main/app_main.c +++ b/firmware/main/app_main.c @@ -31,7 +31,9 @@ #include "mlink_esp.h" #include "nvs.h" #include "nvs_flash.h" +#include "ota.h" #include "ota_esp.h" +#include "warm_esp.h" #include "ulink_esp.h" static const char *TAG = "phonerail"; @@ -355,6 +357,10 @@ void app_main(void) const esp_reset_reason_t why = esp_reset_reason(); + /* 「重启不断电」:是不是我们自己计划的重启、重启前哪几路开着(warm_esp.c)。 + **必须在 bringup 之前**:那几根 EN 此刻还被上一条命锁在高,bringup 第一步决定留还是放。 */ + const uint8_t adopt = warm_take(why); + /* 事件日志**最先**起(A15 挪到这里):下面的启动序列会记事件(缺件 / mux / PD), 它们必须排在这次的 BOOT 之后,否则时间线上看着像是上一次运行留下的。 BOOT 的 arg = 复位原因 —— §10.10「看门狗复位 … 记一条复位事件」就是它。 @@ -385,13 +391,18 @@ void app_main(void) 整段在 bringup.c 里、宿主机上测得到(A15 之前它在这里,一行也测不到)。 缺件一律降级不 abort;返回错误 = 引脚 / 配置常量写错了,那才 abort。 */ static bringup_report_t boot; - const bringup_in_t in = { .power_on_reset = (why == ESP_RST_POWERON) }; + const bringup_in_t in = { .power_on_reset = (why == ESP_RST_POWERON), .adopt = adopt }; err = bringup_devices(&in, &boot); if (err != HAL_OK) { ESP_LOGE(TAG, "启动序列失败(固件配置错误):%s", hal_err_str(err)); abort(); } log_report(&boot); + if (adopt != 0) { + evlog_put(0, LOG_EV_OTA, OTA_EV_WARM, (int32_t)boot.adopted, (int32_t)boot.adopt_dropped); + ESP_LOGI(TAG, "接管:一直开着 0x%02x,关掉 0x%02x(刀片不在 / mux 失联)", + (unsigned)boot.adopted, (unsigned)boot.adopt_dropped); + } /* §10.9 第 7 步的前半:风扇闭环。**放在上电之前**:t_thermal_init 先满速、再拉 FAN_EN, 第一圈之前风扇就在转(t_thermal.h)。ADC / PCNT 起不来不 abort —— diff --git a/firmware/main/bringup.c b/firmware/main/bringup.c index b816c8a..fcb1aa7 100644 --- a/firmware/main/bringup.c +++ b/firmware/main/bringup.c @@ -198,7 +198,8 @@ hal_err_t bringup_devices(const bringup_in_t *in, bringup_report_t *r) /* ① 五根 `EFUSE_EN` 配成输出并拉低。**先于一切外设**:DAC / eFuse 一上电就有 输出,这五根线这时必须已经确定是低的。VBUS 此刻靠硬件关着(PCA9555 复位后 全是输入 + R701 下拉),真正「固件说了算」要等下面的 `slot_init()`。 */ - hal_err_t err = rail_en_init(); + const uint8_t all = (uint8_t)((1u << PHONERAIL_RAIL_COUNT) - 1u); + hal_err_t err = rail_en_init_keep((uint8_t)(in->adopt & all)); if (err != HAL_OK) { return err; } @@ -256,16 +257,30 @@ hal_err_t bringup_devices(const bringup_in_t *in, bringup_report_t *r) /* ③ 五片刀片。空槽不去碰(只会白等 NAK);mux 失联时一路也够不着。 */ for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + const bool adopt = (in->adopt & bit_of(rail)) != 0; if (!slot_present(rail)) { r->empty |= bit_of(rail); + if (adopt) { + /* 重启前还开着,重启后刀片不在了(或 PRSNT# 虚接):关,记一条。 */ + (void)rail_efuse_enable(rail, false); + r->adopt_dropped |= bit_of(rail); + evlog_put((uint8_t)rail, LOG_EV_SLOT_EMPTY, 1, 0, 0); + } continue; } if (!mux_ok) { + if (adopt) { + (void)rail_efuse_enable(rail, false); /* §5.8:看不见就不许带电 */ + r->adopt_dropped |= bit_of(rail); + } continue; /* 整板级已经记过 MUX_LOST,不再逐路刷十条 I2C_LOST */ } /* PCA9555:驱动保证「输出锁存器先清零、方向最后改」(计划 §5.3)。 - 这一步之后 VBUS 才真的由固件说了算。 */ - hal_err_t e = slot_init(rail); + 这一步之后 VBUS 才真的由固件说了算。接管的路 VBUS 留着(按键 / EDL 照样清零)。 */ + hal_err_t e = adopt ? slot_init_keep_vbus(rail) : slot_init(rail); + if (adopt) { + r->adopted |= bit_of(rail); + } if (e != HAL_OK) { r->io_lost |= bit_of(rail); lost(rail, PCA9555_ADDR, e); @@ -342,6 +357,10 @@ void bringup_power_restore(uint8_t want, bringup_report_t *r) /* 能开的:想开、在位、mux 在。空槽且想开 = 刀片被拔走了(或 PRSNT# 虚接),记一条。 */ uint8_t todo = 0; for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + if (rail_efuse_is_on(rail)) { + r->on |= bit_of(rail); /* 第一段接管的:一直开着,不重开、不等 PG */ + continue; + } if ((want & bit_of(rail)) == 0) { continue; } diff --git a/firmware/main/bringup.h b/firmware/main/bringup.h index 82d825f..6299553 100644 --- a/firmware/main/bringup.h +++ b/firmware/main/bringup.h @@ -87,6 +87,12 @@ typedef struct { 其余复位原因下脉冲 = 自己把自己的 USB 控制台拔了),也只有它为真才核对 DAC 的 NVM 默认值(热重启时 DAC 没断过电,里面是上一次命令设的值)。 */ bool power_on_reset; + + /* 「重启不断电」(warm_esp.c):计划内重启前开着的路,bit0 = CH1。0 = 普通启动。 + 这几路 EN 起来就是高(上一条命 hold 住的),第一段直接认领、不关;VBUS 也留着。 + 刀片不在 / mux 失联的,第一段里立刻关掉(`adopt_dropped`)—— 看不见的路不许带电(§5.8)。 + ⚠️ 只有软件复位 + RTC 里的标记对得上才会非 0;崩溃 / 看门狗 / 上电一律 0(SR-6)。 */ + uint8_t adopt; } bringup_in_t; typedef struct { @@ -96,6 +102,8 @@ typedef struct { uint8_t empty; /* `PRSNT#` 高:刀片不在位 */ uint8_t blind; /* 刀片 INA226 不应答:失去遥测、判据不武装(「盲跑」) */ uint8_t io_lost; /* 刀片 PCA9555 不应答:FAULT/PG 看不见,VBUS 关不掉 */ + uint8_t adopted; /* 接管了、一直开着没断过的路 */ + uint8_t adopt_dropped;/* 想接管、但刀片不在 / mux 失联,已关掉 */ /* 第二段填。 */ uint8_t want; /* 请求恢复的(上次的意图) */ @@ -119,6 +127,8 @@ hal_err_t bringup_devices(const bringup_in_t *in, bringup_report_t *r); 等不到就关回去并记 `LOG_EV_PG_TIMEOUT`。阻塞,最坏约 0.4 + 0.5 s。 不能开的(空槽 / mux 失联)直接跳过,空槽且想开的记一条 SLOT_EMPTY。 */ void bringup_power_restore(uint8_t want, bringup_report_t *r); +/* ↑ 已经开着的路(第一段接管的)不再上电、不等 PG,直接算进 `on` —— 不管 `want` 里有没有它: + 接管的是「重启前的真实状态」,意图与之不符的那种(一路正在硬重启的断电阶段)本来就不会被接管。 */ /* 「意图」掩码的更新(`power` 命令改它,app_main 把结果存 NVS)。纯函数。 */ uint8_t bringup_want_update(uint8_t want, int rail, bool on); diff --git a/firmware/main/ota.h b/firmware/main/ota.h index fcf7bfd..f2773c7 100644 --- a/firmware/main/ota.h +++ b/firmware/main/ota.h @@ -12,9 +12,11 @@ 启动序列走完 + 连上 broker(已发出 state)。做不到 ⇒ 标记无效、重启回旧版。 新固件在自证之前崩了 / 看门狗了,bootloader 自己回滚(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE)。 - ---- ⚠️ 重启 = 五路全断一次 ------------------------------------------------- - ESP 一复位,`EFUSE_EN` 靠下拉关掉(bringup.h),五台手机**硬断电**, - 新固件起来后再按意图逐路恢复(§10.9)。所以 `ota` 命令的 `reboot` **默认 0**: + ---- 重启还断不断电 ------------------------------------------------------ + 2026-09-27 起 OTA 的重启与超时回滚走 `warm_restart()`(warm_esp.c):目标固件认接管 ⇒ + EN 锁住、**手机不断电**;目标固件不认(推老版本)/ 兜底定时器回滚 / 新固件崩溃 ⇒ + ESP 一复位 `EFUSE_EN` 靠下拉关掉,五台手机**硬断电一次**,起来后按意图逐路恢复(§10.9)。 + 不管哪种,`ota` 命令的 `reboot` **默认 0**: 只下载 + 校验 + 切启动分区,**下次复位才生效**;要立刻换就显式带 `"reboot":true`。 已经下好的同一个镜像(sha256 相同)再发一次带 `"reboot":true` 的,不重下,直接重启。 回滚也是一次复位,同样五路全断一次。 @@ -74,7 +76,8 @@ typedef enum { 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_REBOOT = 6, /* 为了换固件而重启 */ + OTA_EV_WARM = 7, /* 计划内重启后接管(没断电),bus_mv = 接管的路掩码,current_ua = 想接管却关掉的 */ } ota_ev_t; typedef enum { diff --git a/firmware/main/ota_esp.c b/firmware/main/ota_esp.c index 2d8ecf9..1b040d2 100644 --- a/firmware/main/ota_esp.c +++ b/firmware/main/ota_esp.c @@ -38,6 +38,7 @@ #include "ota.h" #include "ota_esp.h" #include "tasks.h" +#include "warm_esp.h" static const char *TAG = "ota"; @@ -258,7 +259,8 @@ static void task_ota(void *arg) } /* 兜底:判据的正常路径在 t_link 里;app_main 卡死 / t_link 起不来时它来回滚。 - esp_timer 任务里调,事件来不及落盘 —— 这条路本来就只在出大事时走。 */ + esp_timer 任务里调,事件来不及落盘 —— 这条路本来就只在出大事时走。 + ⚠️ **故意不走 warm_restart()**:走到这里说明这份固件卡死了,它的「开着哪几路」不可信 ⇒ 按非计划复位全关。 */ static void backstop_cb(void *arg) { (void)arg; @@ -366,11 +368,7 @@ void ota_esp_poll(void) } 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(); + /* 计划内重启:目标固件认接管就锁住 EN、手机不断电;不认就五路全断一次(warm_esp.c)。 */ + warm_restart(s_rollback); } } diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h index 5853ea7..504b0e9 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-c2" +#define PHONERAIL_FW_VERSION "0.1.0-c3" /* 通道数。五路直供,见设计文档 §2。 */ #define PHONERAIL_RAIL_COUNT 5 diff --git a/firmware/main/rail_cfg.c b/firmware/main/rail_cfg.c index 85e13da..302f834 100644 --- a/firmware/main/rail_cfg.c +++ b/firmware/main/rail_cfg.c @@ -261,26 +261,59 @@ bool rail_efuse_is_on(int rail) return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT && s_efuse_on[rail - 1]; } -hal_err_t rail_en_init(void) +hal_err_t rail_en_init(void) { return rail_en_init_keep(0); } + +hal_err_t rail_en_init_keep(uint8_t keep) { for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { - const int pin = k_cfg[rail - 1].efuse_en; + const int pin = k_cfg[rail - 1].efuse_en; + const bool on = (keep & (1u << (rail - 1))) != 0; hal_err_t err = hal_gpio_config(pin, HAL_GPIO_OUTPUT); if (err != HAL_OK) { return err; } /* 配成输出的那一刻两边的 HAL 都已经在输出 0 了,这一句是把「关」 - 写成明文 —— 别让「五路上电即全关」依赖某个默认值。 */ - err = en_write(pin, false); + 写成明文 —— 别让「五路上电即全关」依赖某个默认值。 + 接管的那几路写 1:此刻引脚还锁着(上一条命 hold 的),寄存器先对上,下面放开才不会抖。 */ + err = en_write(pin, on); + if (err != HAL_OK) { + return err; + } + s_efuse_on[rail - 1] = on; + /* ⭐ **无条件**放开:不接管的那几路,锁着的高电平在这一句之后才真的落到低 —— + 崩溃 / 看门狗复位时上一条命的 hold 还在,不放开就是固件永远关不掉它。 */ + err = hal_gpio_hold(pin, false); if (err != HAL_OK) { return err; } - s_efuse_on[rail - 1] = false; } return HAL_OK; } +uint8_t rail_efuse_mask(void) +{ + uint8_t m = 0; + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + if (s_efuse_on[rail - 1]) { + m |= (uint8_t)(1u << (rail - 1)); + } + } + return m; +} + +hal_err_t rail_en_hold_all(void) +{ + hal_err_t first = HAL_OK; + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + const hal_err_t err = hal_gpio_hold(k_cfg[rail - 1].efuse_en, true); + if (err != HAL_OK && first == HAL_OK) { + first = err; + } + } + return first; +} + hal_err_t rail_efuse_enable(int rail, bool on) { if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) { diff --git a/firmware/main/rail_cfg.h b/firmware/main/rail_cfg.h index f5ad98c..e4d3b71 100644 --- a/firmware/main/rail_cfg.h +++ b/firmware/main/rail_cfg.h @@ -111,6 +111,19 @@ const char *rail_selftest_str(uint32_t bit); 有电压了,eFuse 的 EN 这时得已经确定是低的。任何一个脚配失败就返回错误码。 */ hal_err_t rail_en_init(void); +/* 同上,但 `keep` 里的那几路(bit0 = CH1)写**高**:「重启不断电」的接管 + (warm_esp.c 记下的、重启前开着的路)。写完电平之后**五个脚一律放开 hold**, + 不管接不接管 —— 崩溃 / 看门狗复位时上一条命的 hold 可能还锁着,不放开固件就关不掉它。 + `rail_en_init()` = `rail_en_init_keep(0)`。 */ +hal_err_t rail_en_init_keep(uint8_t keep); + +/* 五路影子的掩码(bit0 = CH1)。计划内重启前记下「哪几路开着」用。 */ +uint8_t rail_efuse_mask(void); + +/* 把五根 EN 脚**此刻的电平**锁住(hal_gpio_hold),跨软件复位保持。 + ⚠️ 锁住之后 `rail_efuse_enable()` 写不到线上 —— 只许在 esp_restart() 的前一刻调。 */ +hal_err_t rail_en_hold_all(void); + /* rail 越界 → HAL_ERR_ARG;没 `rail_en_init()` 过 → HAL_ERR_ARG(HAL 会拦)。 */ hal_err_t rail_efuse_enable(int rail, bool on); diff --git a/firmware/main/slot.c b/firmware/main/slot.c index 0701ac5..5c38b4c 100644 --- a/firmware/main/slot.c +++ b/firmware/main/slot.c @@ -71,6 +71,12 @@ static hal_err_t do_init(void *arg) return pca9555_init(rail - 1); } +static hal_err_t do_init_keep_vbus(void *arg) +{ + const int rail = *(const int *)arg; + return pca9555_init_keep(rail - 1, (uint8_t)(1u << PCA9555_BIT_VBUS_EN)); +} + /* ---- 输出 ---------------------------------------------------------------- */ /* 已持锁版本:影子下标与通道由调用方保证一致 —— 调用方只可能是 @@ -101,6 +107,15 @@ hal_err_t slot_init(int rail) return i2cbus_xact(slot_mux_ch(rail), SLOT_LOCK_TIMEOUT_MS, do_init, &r); } +hal_err_t slot_init_keep_vbus(int rail) +{ + if (!rail_valid(rail)) { + return HAL_ERR_ARG; + } + int r = rail; + return i2cbus_xact(slot_mux_ch(rail), SLOT_LOCK_TIMEOUT_MS, do_init_keep_vbus, &r); +} + hal_err_t slot_out0_write(int rail, uint8_t mask, uint8_t value) { return out0_xact(rail, mask, value); diff --git a/firmware/main/slot.h b/firmware/main/slot.h index f58acd6..edf1205 100644 --- a/firmware/main/slot.h +++ b/firmware/main/slot.h @@ -61,6 +61,11 @@ void slot_prsnt_poll(void); ⚠️ 必须在 `rail_en_init()` 之后:这一步之后 VBUS 才真的受控。 */ hal_err_t slot_init(int rail); +/* 「重启不断电」接管的那一路:同 slot_init(),但 VBUS **保持重启前的样子** + (PCA9555 不随 ESP32 复位,锁存器里还是上一条命写的值);按键 / EDL 照样清零。 + 这片掉过电(方向寄存器不是我们写的)就不留,等同 slot_init()(pca9555_init_keep)。 */ +hal_err_t slot_init_keep_vbus(int rail); + /* ---- VBUS(PCA9555 P0.4 → TPS22918) ------------------------------------- */ hal_err_t slot_vbus_enable(int rail, bool on); diff --git a/firmware/main/warm_esp.c b/firmware/main/warm_esp.c new file mode 100644 index 0000000..8984faf --- /dev/null +++ b/firmware/main/warm_esp.c @@ -0,0 +1,135 @@ +/* 「重启不断电」(2026-09-27 用户定:**只管计划内重启**,VBUS 也保持)。只进 IDF 目标。 + + ---- SR-6 改成了什么 -------------------------------------------------------- + 原文:ESP32 复位 / 上电期间五路全关,靠上下拉保证,不靠固件。现在分两类: + · **非计划复位**(上电、崩溃、看门狗、按 EN、esptool)—— 仍然全关,仍然靠下拉; + · **计划内重启**(OTA 换固件、自证超时回滚 —— 都经 `warm_restart()`)—— 五根 EFUSE_EN + 用 gpio_hold 锁在重启前的电平,新固件起来**接管**:不重新上电、不等 PG; + PCA9555 不随 ESP 复位,VBUS 照留,按键 / EDL 清零(bringup.h 的 `adopt`)。 + 代价:从 esp_restart 到新固件 t_safety 起来 ≈1~2 s,SR-5 / SW-8 不判(eFuse 自己的硬件保护还在)。 + + ---- 三道闸 ---------------------------------------------------------------- + 1. **RTC 里的标记**(RTC_NOINIT,跨软件复位保留,上电是垃圾):魔数 + 掩码 + 反码。 + 只有「复位原因 = ESP_RST_SW」且标记对得上才接管;**读一次就作废**。 + ⚠️ gpio_hold 本身不分复位原因:崩溃 / 看门狗时锁照样还在 —— 所以不接管的路 + `rail_en_init_keep()` 也**无条件放开 hold**,那一刻才真的落到低。 + 2. **目标固件认不认**:镜像头后面放一个能力标记(`.rodata_custom_desc`,紧跟 esp_app_desc_t)。 + 推一个老固件(比如 0.1.0-c2)再锁 EN,它起来会照常写低、却写不到锁住的线上 —— + **五路从此关不掉**。所以目标不认就不锁,照老样子全断重启。 + 3. **线上核对**:起来后先读 EN 脚的真电平(上一条命配的是 INPUT_OUTPUT,锁住的输入缓冲是开的)。 + 标记说开着、线上却是低 ⇒ hold 没生效,那一路不接管,交给按意图恢复(错开 + 等 PG)。 + + ---- 最坏情况 -------------------------------------------------------------- + 新固件在接管之前就崩了:这次复位原因是 PANIC,不接管 ⇒ 全关。多穿过的是一次启动(≈1 s)。 */ +#include + +#include "driver/gpio.h" +#include "esp_attr.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "phonerail.h" +#include "rail_cfg.h" +#include "warm_esp.h" + +static const char *TAG = "warm"; + +#define WARM_MAGIC 0x5752414Du /* "WRAM" */ + +typedef struct { + uint32_t magic; + uint8_t mask; + uint8_t nmask; /* ~mask:RTC 内存上电是随机值,魔数碰巧对上也过不了这一道 */ +} warm_mark_t; + +static RTC_NOINIT_ATTR warm_mark_t s_mark; + +/* ---- 能力标记 ------------------------------------------------------------- + 镜像里的位置:镜像头 24 B + 第一个段头 8 B + esp_app_desc_t 256 B = **288**。 + IDF 的链接脚本保证 `.rodata_custom_desc` 紧跟 `.rodata_desc`(sections.ld.in)。 + rack 后端上传时也可以读它(firmware.py)。 */ +#define CAPS_OFFSET (24 + 8 + sizeof(esp_app_desc_t)) +#define CAPS_TAG "PRCAPS1" +#define CAP_WARM (1u << 0) /* 认「重启不断电」的接管 */ + +typedef struct { + char tag[8]; + uint32_t caps; +} caps_t; + +const caps_t g_phonerail_caps __attribute__((section(".rodata_custom_desc"), used)) = { + .tag = CAPS_TAG, + .caps = CAP_WARM, +}; + +bool warm_target_ok(const esp_partition_t *part) +{ + caps_t c; + if (part == NULL || esp_partition_read(part, CAPS_OFFSET, &c, sizeof(c)) != ESP_OK) { + return false; + } + return memcmp(c.tag, CAPS_TAG, sizeof(c.tag)) == 0 && (c.caps & CAP_WARM) != 0; +} + +uint8_t warm_take(esp_reset_reason_t why) +{ + const bool valid = s_mark.magic == WARM_MAGIC && (uint8_t)~s_mark.mask == s_mark.nmask; + const uint8_t all = (uint8_t)((1u << PHONERAIL_RAIL_COUNT) - 1u); + const uint8_t before = valid ? (uint8_t)(s_mark.mask & all) : 0u; + uint8_t m = (why == ESP_RST_SW) ? before : 0u; + memset(&s_mark, 0, sizeof(s_mark)); /* 一次性:下一次(哪怕是软件复位)不再算数 */ + + /* 此刻 EN 线上的真电平(没有 hold 时是下拉的 0;上一条命锁过的才可能是 1)。 + 非计划复位时这里是 1 的那几路,bringup 第一步放开 hold 之后才落到低 —— 日志里留一笔。 */ + uint8_t pads = 0; + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + if (gpio_get_level(rail_cfg(rail)->efuse_en) != 0) { + pads |= (uint8_t)(1u << (rail - 1)); + } + } + if (pads != 0 && !(valid && why == ESP_RST_SW)) { + ESP_LOGW(TAG, "非计划复位(原因 %d)但 EN 线上还锁着 0x%02x:马上放开、全关(SR-6)", (int)why, + (unsigned)pads); + } + + if (!valid) { + return 0; + } + if (why != ESP_RST_SW) { + ESP_LOGW(TAG, "有接管标记但复位原因是 %d(不是软件复位)—— 按非计划复位全关", (int)why); + return 0; + } + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + const uint8_t bit = (uint8_t)(1u << (rail - 1)); + if ((m & bit) == 0) { + continue; + } + const int pin = rail_cfg(rail)->efuse_en; + if (gpio_get_level(pin) == 0) { + ESP_LOGE(TAG, "CH%d:标记说开着,EN 线上却是低(hold 没生效?)—— 不接管,按意图重开", rail); + m &= (uint8_t)~bit; + } + } + ESP_LOGI(TAG, "计划内重启:接管 0x%02x(重启前开着 0x%02x),不重新上电", (unsigned)m, + (unsigned)before); + return m; +} + +void warm_restart(bool rollback) +{ + const esp_partition_t *target = + rollback ? esp_ota_get_next_update_partition(NULL) : esp_ota_get_boot_partition(); + if (warm_target_ok(target)) { + const uint8_t m = rail_efuse_mask(); + s_mark = (warm_mark_t){ .magic = WARM_MAGIC, .mask = m, .nmask = (uint8_t)~m }; + (void)rail_en_hold_all(); /* ⚠️ 从这一句起 t_safety 写不动 EN —— 下一句就是重启 */ + ESP_LOGW(TAG, "%s:锁住 EN(开着 0x%02x),手机不断电", rollback ? "回滚" : "换固件", (unsigned)m); + } else { + ESP_LOGW(TAG, "%s:目标固件(%s)不认接管 —— 五路全断一次", rollback ? "回滚" : "换固件", + target ? target->label : "?"); + } + if (rollback) { + esp_ota_mark_app_invalid_rollback_and_reboot(); /* 成功不返回 */ + ESP_LOGE(TAG, "回滚失败(没有可回的旧版?),就地重启"); + } + esp_restart(); +} diff --git a/firmware/main/warm_esp.h b/firmware/main/warm_esp.h new file mode 100644 index 0000000..318b29d --- /dev/null +++ b/firmware/main/warm_esp.h @@ -0,0 +1,24 @@ +/* 「重启不断电」的 ESP 胶水(2026-09-27,需求 SR-6 改写)。只在 IDF 目标里有;说明在 warm_esp.c 文件头。 */ +#ifndef WARM_ESP_H +#define WARM_ESP_H + +#include +#include + +#include "esp_partition.h" +#include "esp_system.h" + +/* 启动时调(app_main 最前面、bringup 之前):这次是不是**我们自己计划的**重启、 + 重启前哪几路开着。返回 bringup 的 `adopt`(bit0 = CH1);不是 = 0。标记一次性,读完就作废。 + 还会核对那几根 EN 脚**线上**真的是高(hold 真的生效了);是低的那几路不接管。 */ +uint8_t warm_take(esp_reset_reason_t why); + +/* 这个分区里的固件认不认「接管」(镜像头后面的能力标记)。认不了的固件起来会照常把 EN 写低, + 但写不到被锁住的线上 —— 五路从此关不掉,所以对它**不许**锁。 */ +bool warm_target_ok(const esp_partition_t *part); + +/* 计划内重启:目标固件认接管 ⇒ 记下开着的路、锁住五根 EN、重启;不认 ⇒ 照老样子重启(五路全断一次)。 + `rollback` = 标记当前固件无效、回滚到另一个槽(esp_ota_mark_app_invalid_rollback_and_reboot)。不返回。 */ +void warm_restart(bool rollback); + +#endif /* WARM_ESP_H */ diff --git a/firmware/test/test_bringup.c b/firmware/test/test_bringup.c index 24e54b7..b211547 100644 --- a/firmware/test/test_bringup.c +++ b/firmware/test/test_bringup.c @@ -558,6 +558,161 @@ static void test_dac_nvm_not_checked_on_warm_reboot(void) /* ---- 意图掩码 ------------------------------------------------------------ */ +/* ---- 「重启不断电」:计划内重启的接管(warm_esp.c 给 adopt) ----------------- */ + +/* 上一条命:这一路开着(EN 被 hold 在高)、PG 在、PCA9555 已被配过、锁存器是 out0。 */ +static void prev_life(int rail, uint8_t out0) +{ + const int ch = ch_of(rail); + sim_i2c_set_reg_ch(ch, PCA9555_ADDR, PCA9555_REG_CONFIG0, PCA9555_CONFIG0_INIT); + sim_i2c_set_reg_ch(ch, PCA9555_ADDR, PCA9555_REG_CONFIG1, PCA9555_CONFIG1_INIT); + sim_i2c_set_reg_ch(ch, PCA9555_ADDR, PCA9555_REG_OUTPUT0, out0); + sim_i2c_set_reg_ch(ch, PCA9555_ADDR, PCA9555_REG_OUTPUT1, 0); + sim_pca9555_drive(rail - 1, 1, P1_PG); + sim_gpio_preheld(rail_cfg(rail)->efuse_en, 1); +} + +static int g_en_fell[N]; /* 这一条命里 EN 线上出现过几次 1→0 */ +static int g_en_last[N]; + +static void watch_en(int pin, int level) +{ + world(pin, level); + for (int rail = 1; rail <= N; rail++) { + if (pin == rail_cfg(rail)->efuse_en) { + if (g_en_last[rail - 1] == 1 && level == 0) { + g_en_fell[rail - 1]++; + } + g_en_last[rail - 1] = level; + } + } +} + +static void warm(uint8_t adopt) +{ + for (int rail = 1; rail <= N; rail++) { + g_en_fell[rail - 1] = 0; + g_en_last[rail - 1] = sim_gpio_level(rail_cfg(rail)->efuse_en); + } + sim_gpio_set_hook(watch_en); + const bringup_in_t in = { .power_on_reset = false, .adopt = adopt }; + TEST_ASSERT_EQUAL_INT(HAL_OK, bringup_devices(&in, &g_r)); +} + +#define VBUS ((uint8_t)(1u << PCA9555_BIT_VBUS_EN)) +#define KPWR ((uint8_t)(1u << PCA9555_BIT_KEY_PWR)) + +/* ⭐ 接管:EN 一次都没掉下来过;hold 放开了(固件又管得住它了);VBUS 留着、正按着的键松开; + 第二段不重开、不等 PG,只把没接管的「想开的」那一路按老规矩开。 */ +static void test_planned_restart_keeps_the_rails_it_adopts(void) +{ + prev_life(1, (uint8_t)(VBUS | KPWR)); /* 重启那一刻 CH1 正按着电源键 */ + prev_life(3, VBUS); + warm(BIT(1) | BIT(3)); + + TEST_ASSERT_EQUAL_UINT8(BIT(1) | BIT(3), g_r.adopted); + TEST_ASSERT_EQUAL_UINT8(0, g_r.adopt_dropped); + for (int rail = 1; rail <= N; rail++) { + const int pin = rail_cfg(rail)->efuse_en; + TEST_ASSERT_FALSE_MESSAGE(sim_gpio_held(pin), "hold 必须放开,否则 t_safety 关不掉"); + const bool adopted = rail == 1 || rail == 3; + TEST_ASSERT_EQUAL_INT(adopted ? 1 : 0, sim_gpio_level(pin)); + TEST_ASSERT_EQUAL(adopted, rail_efuse_is_on(rail)); + TEST_ASSERT_EQUAL_INT(0, g_en_fell[rail - 1]); + } + TEST_ASSERT_EQUAL_UINT8(VBUS, sim_pca9555_output(0, 0)); /* 键松开,VBUS 留着 */ + TEST_ASSERT_EQUAL_UINT8(VBUS, sim_pca9555_output(2, 0)); + TEST_ASSERT_TRUE(slot_vbus_is_on(1)); + TEST_ASSERT_TRUE(slot_vbus_is_on(3)); + /* (sim 的 unsafe_enable 在 VBUS 合法开着时也会计数,这里不看它;直接看键 / EDL 位。) */ + const uint8_t keys_edl = (uint8_t)(pca9555_key_mask() | (1u << PCA9555_BIT_EDL_EN)); + TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(0, 0) & keys_edl); + + bringup_power_restore(BIT(1) | BIT(2) | BIT(3), &g_r); + TEST_ASSERT_EQUAL_UINT8(BIT(1) | BIT(2) | BIT(3), g_r.on); + TEST_ASSERT_EQUAL_UINT64(0, sim_gpio_rise_us(rail_cfg(1)->efuse_en)); /* 这条命里没「上电」过 */ + TEST_ASSERT_EQUAL_UINT64(0, sim_gpio_rise_us(rail_cfg(3)->efuse_en)); + TEST_ASSERT_TRUE(sim_gpio_rise_us(rail_cfg(2)->efuse_en) != 0); + TEST_ASSERT_EQUAL_INT(0, g_en_fell[0] + g_en_fell[2]); +} + +/* ⭐ SR-6:**非计划**复位(崩溃 / 看门狗)时上一条命的 hold 可能还锁着 —— + adopt = 0 就必须全关,而且 hold 放开(不放开 = 固件写低也落不到线上)。 */ +static void test_unplanned_reset_drops_held_rails(void) +{ + prev_life(1, VBUS); + prev_life(2, VBUS); + warm(0); + + for (int rail = 1; rail <= 2; rail++) { + TEST_ASSERT_FALSE(sim_gpio_held(rail_cfg(rail)->efuse_en)); + TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(rail)->efuse_en)); + TEST_ASSERT_FALSE(rail_efuse_is_on(rail)); + TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(rail - 1, 0)); /* VBUS 也断 */ + } + TEST_ASSERT_EQUAL_UINT8(0, g_r.adopted); +} + +/* 重启前还开着、重启后刀片不在了:关,记一条 SLOT_EMPTY(arg 1 = 接管时发现的)。 */ +static void test_adopt_on_an_empty_slot_is_dropped(void) +{ + prev_life(2, VBUS); + pull_slot(2); + warm(BIT(2)); + + TEST_ASSERT_EQUAL_UINT8(BIT(2), g_r.adopt_dropped); + TEST_ASSERT_EQUAL_UINT8(0, g_r.adopted); + TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(2)->efuse_en)); + TEST_ASSERT_FALSE(rail_efuse_is_on(2)); + evlog_rec_t rec; + TEST_ASSERT_TRUE(last_ev(LOG_EV_SLOT_EMPTY, &rec)); + TEST_ASSERT_EQUAL_UINT8(2, rec.rail); + TEST_ASSERT_EQUAL_UINT8(1, rec.arg); +} + +/* mux 失联:五路都看不见(§5.8)⇒ 接管的也关。 */ +static void test_adopt_with_mux_lost_is_dropped(void) +{ + prev_life(1, VBUS); + sim_i2c_set_present(TCA9548A_ADDR, false); + warm(BIT(1)); + TEST_ASSERT_EQUAL_UINT8(BIT(1), g_r.adopt_dropped); + TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(1)->efuse_en)); + bringup_power_restore(ALL, &g_r); + TEST_ASSERT_EQUAL_UINT8(0, g_r.on); +} + +/* ⭐ PCA9555 掉过电(方向 / 锁存器都是复位值 0xFF):**不许**照留锁存器 —— 那是凭空打开 VBUS + 与三个键。EN 照样接管(它由 ESP 的 hold 管,与这片无关)。 */ +static void test_adopt_does_not_trust_a_pca_that_lost_power(void) +{ + sim_gpio_preheld(rail_cfg(4)->efuse_en, 1); /* 只有 EN 是上一条命的;PCA9555 是复位值 */ + sim_pca9555_drive(3, 1, P1_PG); + warm(BIT(4)); + + TEST_ASSERT_EQUAL_UINT8(BIT(4), g_r.adopted); + TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(rail_cfg(4)->efuse_en)); + TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(3, 0)); + TEST_ASSERT_FALSE(slot_vbus_is_on(4)); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_unsafe_enable()); +} + +/* 重启前一刻锁 EN:锁住之后固件**写不到**线上(这正是只许在 esp_restart 前一刻锁的理由), + rail_efuse_mask 报的是锁之前的意图。 */ +static void test_hold_all_freezes_the_pins(void) +{ + devices(true); + bringup_power_restore(BIT(1) | BIT(5), &g_r); + TEST_ASSERT_EQUAL_UINT8(BIT(1) | BIT(5), rail_efuse_mask()); + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_hold_all()); + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(1, false)); + TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(rail_cfg(1)->efuse_en)); /* 线上没动 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init_keep(BIT(5))); /* 下一条命:只接管 CH5 */ + TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(1)->efuse_en)); + TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(rail_cfg(5)->efuse_en)); + TEST_ASSERT_FALSE(sim_gpio_held(rail_cfg(5)->efuse_en)); +} + static void test_want_update(void) { TEST_ASSERT_EQUAL_UINT8(BIT(3), bringup_want_update(0, 3, true)); @@ -592,6 +747,12 @@ int main(void) RUN_TEST(test_pg_timeout_turns_that_rail_back_off); RUN_TEST(test_dac_nvm_mismatch_is_flagged_on_power_on); RUN_TEST(test_dac_nvm_not_checked_on_warm_reboot); + RUN_TEST(test_planned_restart_keeps_the_rails_it_adopts); + RUN_TEST(test_unplanned_reset_drops_held_rails); + RUN_TEST(test_adopt_on_an_empty_slot_is_dropped); + RUN_TEST(test_adopt_with_mux_lost_is_dropped); + RUN_TEST(test_adopt_does_not_trust_a_pca_that_lost_power); + RUN_TEST(test_hold_all_freezes_the_pins); RUN_TEST(test_want_update); return UNITY_END(); } diff --git a/firmware/test/test_ota.c b/firmware/test/test_ota.c index 04f0ff6..a88dc47 100644 --- a/firmware/test/test_ota.c +++ b/firmware/test/test_ota.c @@ -361,6 +361,7 @@ static void test_names(void) /* 进 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(7, OTA_EV_WARM); TEST_ASSERT_EQUAL_INT(3, OTA_E_HASH); TEST_ASSERT_EQUAL_INT(7, OTA_E_NOSLOT); } diff --git a/rack/backend/prrack/firmware.py b/rack/backend/prrack/firmware.py index 31a5eca..bc12da5 100644 --- a/rack/backend/prrack/firmware.py +++ b/rack/backend/prrack/firmware.py @@ -29,6 +29,10 @@ SHA_RE = re.compile(r"^[0-9a-f]{64}$") # esp_app_desc_t 紧跟在镜像头(24 B)+ 第一个段头(8 B)之后 _DESC = 32 _DESC_MAGIC = 0xABCD5432 +# 能力标记(firmware/main/warm_esp.c 的 g_phonerail_caps,.rodata_custom_desc)紧跟 esp_app_desc_t(256 B) +_CAPS = _DESC + 256 +_CAPS_TAG = b"PRCAPS1\0" +CAP_WARM = 1 # 认「重启不断电」的接管:推它并立刻重启,手机不断电 class FwError(ValueError): """镜像不能收 / 推送请求不对(中文说明)。""" @@ -55,6 +59,8 @@ def parse_image(data: bytes) -> dict: } if info["project"] != PROJECT: raise FwError("这是「%s」的固件,不是 %s" % (info["project"], PROJECT)) + caps = struct.unpack_from(" List[dict]: diff --git a/rack/backend/prrack/protocol.py b/rack/backend/prrack/protocol.py index 543a946..b4b4cbe 100644 --- a/rack/backend/prrack/protocol.py +++ b/rack/backend/prrack/protocol.py @@ -39,7 +39,8 @@ EVENT_TYPES: Dict[int, Tuple[str, str]] = { } # LOG_EV_OTA 的 arg(firmware/main/ota.h 的 ota_ev_t)与失败原因(ota_err_t,失败那条的 mV 列)。⚠️ 冻结。 -OTA_EV_NAMES = {1: "开始下载", 2: "已就绪", 3: "失败", 4: "新固件自证健康", 5: "回滚", 6: "为换固件重启"} +OTA_EV_NAMES = {1: "开始下载", 2: "已就绪", 3: "失败", 4: "新固件自证健康", 5: "回滚", 6: "为换固件重启", + 7: "计划内重启:接管、没断电"} OTA_ERR_NAMES = {1: "下载失败", 2: "大小不对", 3: "哈希不对", 4: "写 flash 失败", 5: "镜像校验不过", 6: "切启动分区失败", 7: "没有备用槽 / 新固件未自证"} diff --git a/rack/backend/tests/test_firmware.py b/rack/backend/tests/test_firmware.py index 1dfcb7b..68a6891 100644 --- a/rack/backend/tests/test_firmware.py +++ b/rack/backend/tests/test_firmware.py @@ -22,12 +22,15 @@ B = "b81f3fc9c8bc" BASE = "http://home.example:7765/fw" -def image(version="0.1.0-c2", project="phonerail", size=4096, salt=b"") -> bytes: +def image(version="0.1.0-c2", project="phonerail", size=4096, salt=b"", warm=False) -> bytes: """最小的「像 ESP app 镜像」:镜像头 24 B + 段头 8 B + esp_app_desc_t。""" hdr = bytes([0xE9, 3, 2, 0x4F]) + b"\0" * 20 + b"\0" * 8 desc = struct.pack(" bytes def test_parse_image_reads_the_app_descriptor(): info = parse_image(image()) assert info == {"version": "0.1.0-c2", "project": "phonerail", "built": "Sep 27 2026 13:31:32", - "idf": "v5.4"} + "idf": "v5.4", "warm": False} + assert parse_image(image(warm=True))["warm"] is True @pytest.mark.parametrize("data, word", [ @@ -132,8 +136,8 @@ def wait_for(fn, t=2.0): def test_upload_and_list(env): c, hub, sim = env - r = upload(c, image()) - assert r.status_code == 200 and r.json()["version"] == "0.1.0-c2" + r = upload(c, image(warm=True)) + assert r.status_code == 200 and r.json()["version"] == "0.1.0-c2" and r.json()["warm"] is True assert r.json()["uploaded_by"] == "zhouyang" lst = c.get("/api/firmware").json() assert lst["slot"] == SLOT_BYTES and [x["sha256"] for x in lst["items"]] == [r.json()["sha256"]] diff --git a/rack/frontend/src/pages/Firmware.vue b/rack/frontend/src/pages/Firmware.vue index 1643bec..4a44c1d 100644 --- a/rack/frontend/src/pages/Firmware.vue +++ b/rack/frontend/src/pages/Firmware.vue @@ -17,6 +17,9 @@ + + + @@ -66,8 +69,13 @@ 校验通过后立刻重启 - - + + + 重启期间约 1~2 秒没有过压 / 反灌巡检(eFuse 自己的硬件保护还在);刀片被拔掉的路起来后会关掉。 + 板子当前跑的固件太老(0.1.0-c2 及以前)时它不会锁 EN,仍会断电一次。 + + + ESP 复位时各路 EN 被下拉,起来后按原来的开关状态逐路恢复(约 1 秒)。
{{ b.name || b.id }}:{{ railsOn(b).length ? `${railsOn(b).length} 台会断电(${railsOn(b).map(r => '#' + r).join(' ')})` : '没有开着的路' }} @@ -155,7 +163,7 @@ function openPush(row) { } async function push() { - if (reboot.value) { + if (reboot.value && !target.value.warm) { const n = chosenBoards.value.reduce((s, b) => s + railsOn(b).length, 0) try { await ElMessageBox.confirm( diff --git a/需求文档.md b/需求文档.md index 530bcea..200adff 100644 --- a/需求文档.md +++ b/需求文档.md @@ -175,7 +175,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU | **SR-3** | **第二层 · eFuse 独立过压断开**(每路一颗,**最后一道硬件防线**):OVP 4.36 V 标称、**断开而非钳位**、响应 2 µs;限流 4.45 A 恒流、不依赖 MCU | §5 | | **SR-4** | eFuse 的过压响应**必须来得及**:按最坏值(Buck 高侧限流 14 A、负载 2200 µF)核算,余量 ≥ 10 倍 | §5 | | **SR-5** | **第三层 · 软件监控**:电压偏离设定 >±100 mV 且持续 20 ms → 拉低该路 EN 并上报。**仅承担记录与告警,不承担实时保护** | §5 | -| **SR-6** | **复位安全**:ESP32 复位 / 上电期间,五路输出全关、五路 VBUS 全断、15 路按键全不按、5 路 EDL 全不触发、风扇不转。靠**上下拉电阻**保证,不靠固件 | §8.1 / §22.4 | +| **SR-6** | **复位安全**:ESP32 **非计划**复位(上电、崩溃、看门狗、按 EN、esptool)期间,五路输出全关、五路 VBUS 全断、15 路按键全不按、5 路 EDL 全不触发、风扇不转。靠**上下拉电阻**保证,不靠固件。
**例外(2026-09-27 用户定)**:固件自己发起的**计划内重启**(OTA 换固件、自证超时回滚)锁住 `EFUSE_EN`、新固件接管,手机不断电;按键 / EDL 仍全松,VBUS 保持。见 §10.9「重启不断电」 | §8.1 / §22.4 | | **SR-7** | 放电 MOS 与 eFuse EN **硬件反相联锁**,物理上不可能同时导通 | §7.3 | | **SR-8** | **输入保护**:保险丝 + TVS + P-MOS 反接保护 | §2 | | **SR-9** | **12 V 母线泄放**:母线 >13.2 V 时导通泄放,防 USB 反灌或突卸能量抬高母线 | §15.2 | @@ -334,6 +334,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU - 任务优先级不需要为保护让路,只需要保证遥测不被执行器序列饿死; - 看门狗复位是**可接受的失效**(复位即全关,SR-6),不需要为了「保持输出」而弱化看门狗; + (计划内重启不断电是另一回事:那是固件自己选的时机、自己留的标记,§10.9「重启不断电」。) - 固件可以随时被重启、被降级运行,硬件安全性不变。 ### 10.2 架构的第一个判断:失联 ≠ 断电 @@ -341,6 +342,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU | 状态 | 输出该怎么办 | 为什么 | |---|---|---| | ESP32 复位 / 上电中 | **全关**(硬件下拉保证,SR-6) | 状态未知,且此刻手机本来就没在跑 | +| ESP32 **计划内**重启(OTA / 回滚) | **保持**(EN 锁住,新固件接管) | 重启前的状态是已知的;为换一次固件让五台手机全掉电不值得 | | 与上位机失联 | **保持供电不动** | 五台手机正在跑业务;因为控制链路断了就把它们全断电,是把一次网络故障放大成一次全场停机 | | 本地判据触发(SR-5 / SW-8 / 过温) | **关对应那一路** | 已知的危险,且范围可控 | @@ -857,8 +859,8 @@ base = `phonerail/`,id = 板子 STA MAC 的 12 位小写十六进制(板 `{"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` 的,不重下、直接重启。回滚也是一次复位。 +- ⚠️ **`reboot` 默认 false**:只下载 + 校验 + 切启动分区、下次复位生效;同一个镜像已就绪时再发带 `"reboot":true` 的,不重下、直接重启。 + 重启 / 超时回滚:板上固件 ≥0.1.0-c3 且目标镜像认接管 ⇒ **手机不断电**(§10.9「重启不断电」);否则 ESP 一复位 EN 就被下拉,**五台手机硬断电一次**。 - 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**。 @@ -900,6 +902,20 @@ base = `phonerail/`,id = 板子 STA MAC 的 12 位小写十六进制(板 PCA9555 读不到时改按 INA226 电压(≥ 设定 − 100 mV)判;两样都读不到 = **开着、报「确认不了」**,不当成坏了。 - `HUB_RST#` 只在上电复位时脉冲,其余复位原因连配置都不做(配成输出那一刻就是一次复位)。 +**重启不断电(2026-09-27,`firmware/main/warm_esp.c` + `bringup.h` 的 `adopt`)**——用户定:只管计划内重启,VBUS 也保持。 +- 重启前(`warm_restart()`):记下开着的路(RTC_NOINIT:魔数 + 掩码 + 反码)→ `gpio_hold` 锁住五根 `EFUSE_EN` → 重启。 + **目标固件不认接管就不锁**(镜像第 288 字节的能力标记 `PRCAPS1`,`.rodata_custom_desc`):推老版本(≤0.1.0-c2)时 + 它起来写不动被锁住的 EN,五路会**永远关不掉** —— 所以对它照老样子全断重启。 +- 起来后(`warm_take()`,bringup 之前):复位原因 = `ESP_RST_SW` **且**标记对得上才接管;标记一次性。 + 再读 EN 脚真电平核对(输出脚配成 INPUT_OUTPUT,锁住时输入缓冲还开着),线上是低的那路不接管。 +- 第 1 步 `rail_en_init_keep(adopt)`:接管的路写高,**五根一律放开 hold** —— 崩溃 / 看门狗时上一条命的锁可能还在,不放开就关不掉。 + 刀片不在 / mux 失联的接管路立刻关(`adopt_dropped`,空槽记 `SLOT_EMPTY` arg 1)。 +- PCA9555 用 `pca9555_init_keep(VBUS 位)`:方向寄存器已是本固件写的才留 VBUS(掉过电的片子锁存器是 0xFF,照留 = 凭空开 VBUS),键 / EDL 清零。 +- 第 5~6 步:已开着的路不重开、不等 PG。DAC 照常 init(中间约 1 ms Hi-Z ⇒ 输出退化到 3.890 V,设计文档「DAC 未供电 / 输出 Hi-Z」那一行),自定义电压回到 3.90 V(本来就不持久化)。 +- 事件:`LOG_EV_OTA` arg 7,bus_mv = 接管掩码,µA 列 = 关掉的掩码。 +- 代价:esp_restart → 新固件 t_safety 起来约 1~2 s,SR-5 / SW-8 不判(eFuse 硬件保护在)。兜底定时器回滚、新固件崩溃都**不**走接管。 +- 实测(板 B,空槽强制 CH1 在位的台架镜像):CH1 开着 → OTA 带重启 → 新固件读到 IO9 仍为高、记 `18/7 mv=1` → CH1 一直开着。 + ### 10.10 降级矩阵 | 失效 | 检测方式 | 行为 |