固件 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]>
这个提交包含在:
+7
@@ -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 就回滚。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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("<I", data, _CAPS + 8)[0] if data[_CAPS:_CAPS + 8] == _CAPS_TAG else 0
|
||||
info["warm"] = bool(caps & CAP_WARM)
|
||||
return info
|
||||
|
||||
|
||||
@@ -95,7 +101,15 @@ class FwStore:
|
||||
return None
|
||||
d = dict(rows[0])
|
||||
d["url"] = self.url_for(sha)
|
||||
d["present"] = os.path.isfile(os.path.join(self.dir, self.name_for(sha)))
|
||||
path = os.path.join(self.dir, self.name_for(sha))
|
||||
d["present"] = os.path.isfile(path)
|
||||
d["warm"] = False # 从文件现读,不进库(不用改表)
|
||||
if d["present"]:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
d["warm"] = parse_image(f.read(_CAPS + 12) + b"\0" * 256)["warm"]
|
||||
except (OSError, FwError):
|
||||
pass
|
||||
return d
|
||||
|
||||
def list(self) -> List[dict]:
|
||||
|
||||
@@ -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: "没有备用槽 / 新固件未自证"}
|
||||
|
||||
|
||||
@@ -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("<II8s", 0xABCD5432, 0, b"\0" * 8)
|
||||
desc += version.encode().ljust(32, b"\0") + project.encode().ljust(32, b"\0")
|
||||
desc += b"13:31:32".ljust(16, b"\0") + b"Sep 27 2026".ljust(16, b"\0") + b"v5.4".ljust(32, b"\0")
|
||||
desc = desc.ljust(256, b"\0")
|
||||
if warm:
|
||||
desc += b"PRCAPS1\0" + struct.pack("<I", 1)
|
||||
body = hdr + desc + salt
|
||||
return body + b"\xAA" * (size - len(body))
|
||||
|
||||
@@ -37,7 +40,8 @@ def image(version="0.1.0-c2", project="phonerail", size=4096, salt=b"") -> 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"]]
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
<template #default="{ row }"><code :title="row.sha256">{{ row.sha256.slice(0, 16) }}</code></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" width="90"><template #default="{ row }">{{ kb(row.size) }}</template></el-table-column>
|
||||
<el-table-column label="重启不断电" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.warm ? 'success' : 'info'" size="small">{{ row.warm ? '支持' : '不支持' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="编译于" width="170" prop="built" />
|
||||
<el-table-column label="上传" min-width="150">
|
||||
<template #default="{ row }">{{ row.uploaded_by }} · {{ fmtTime(row.uploaded) }}</template>
|
||||
@@ -66,8 +69,13 @@
|
||||
<el-switch v-model="reboot" />
|
||||
<span>校验通过后<b>立刻重启</b></span>
|
||||
</div>
|
||||
<el-alert v-if="reboot" type="error" :closable="false" show-icon>
|
||||
<template #title>重启 = 这块板上所有开着的手机<b>硬断电一次</b></template>
|
||||
<el-alert v-if="reboot && target?.warm" type="success" :closable="false" show-icon>
|
||||
<template #title>这份固件支持<b>重启不断电</b>:开着的手机不断电</template>
|
||||
重启期间约 1~2 秒没有过压 / 反灌巡检(eFuse 自己的硬件保护还在);刀片被拔掉的路起来后会关掉。
|
||||
板子当前跑的固件太老(0.1.0-c2 及以前)时它不会锁 EN,仍会断电一次。
|
||||
</el-alert>
|
||||
<el-alert v-else-if="reboot" type="error" :closable="false" show-icon>
|
||||
<template #title>这份固件<b>不支持</b>重启不断电:这块板上所有开着的手机<b>硬断电一次</b></template>
|
||||
ESP 复位时各路 EN 被下拉,起来后按原来的开关状态逐路恢复(约 1 秒)。
|
||||
<div v-for="b in chosenBoards" :key="b.id">
|
||||
{{ b.name || b.id }}:{{ railsOn(b).length ? `${railsOn(b).length} 台会断电(${railsOn(b).map(r => '#' + r).join(' ')})` : '没有开着的路' }}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 全不触发、风扇不转。靠**上下拉电阻**保证,不靠固件。<br>**例外(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>`,id = 板子 STA MAC 的 12 位小写十六进制(板
|
||||
`{"seq":N,"cmd":"ota","url":"http://home.example:7765/fw/<sha256 前 16 位>.bin","sha256":"<64 位小写>","size":N,"reboot":false}`
|
||||
回 `{"seq":N,"ok":true,"act":"start"|"reboot"|"ready"}`;下载中再来 `busy`;新固件还没自证时 `state`;size 超一个槽(3 MB)`range`。
|
||||
url 只收 `http://`、≤64 字节(命令字符串上限)。
|
||||
- ⚠️ **`reboot` 默认 false**:ESP 一复位 EN 就被下拉,**五台手机硬断电一次**,起来后按意图逐路恢复。
|
||||
默认只下载 + 校验 + 切启动分区、下次复位生效;同一个镜像已就绪时再发带 `"reboot":true` 的,不重下、直接重启。回滚也是一次复位。
|
||||
- ⚠️ **`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>`,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 降级矩阵
|
||||
|
||||
| 失效 | 检测方式 | 行为 |
|
||||
|
||||
在新工单中引用
屏蔽一个用户