固件 0.1.0-c3:计划内重启不断电(OTA / 回滚锁住 EFUSE_EN,新固件接管;SR-6 改写)

- 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 <[email protected]>
这个提交包含在:
zhou yang
2026-09-27 14:35:52 +08:00
共同撰写人 Claude Opus 5.5
父节点 271c649f66
当前提交 7bf30e3c38
共修改 29 个文件,包含 690 行新增和 57 行删除
+3
查看文件
@@ -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)
+24 -9
查看文件
@@ -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(历史事件清空了一次)。
@@ -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 {
+29 -2
查看文件
@@ -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;
}
@@ -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;
}
@@ -2,6 +2,8 @@
#ifndef HAL_GPIO_H
#define HAL_GPIO_H
#include <stdbool.h>
#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
@@ -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;
@@ -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);
+6 -1
查看文件
@@ -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")
+12 -1
查看文件
@@ -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 ——
+22 -3
查看文件
@@ -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;
}
+10
查看文件
@@ -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);
+7 -4
查看文件
@@ -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 {
+5 -7
查看文件
@@ -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);
}
}
+1 -1
查看文件
@@ -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
+38 -5
查看文件
@@ -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) {
+13
查看文件
@@ -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);
+15
查看文件
@@ -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);
+5
查看文件
@@ -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);
+135
查看文件
@@ -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 <string.h>
#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();
}
+24
查看文件
@@ -0,0 +1,24 @@
/* 「重启不断电」的 ESP 胶水(2026-09-27,需求 SR-6 改写)。只在 IDF 目标里有;说明在 warm_esp.c 文件头。 */
#ifndef WARM_ESP_H
#define WARM_ESP_H
#include <stdbool.h>
#include <stdint.h>
#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 */
+161
查看文件
@@ -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();
}
+1
查看文件
@@ -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);
}