diff --git a/PROGRESS.md b/PROGRESS.md
index 02849d6..b049d90 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -1,5 +1,12 @@
# 进度与交接
+## ⭐ 2026-09-27 晚:固件 A15(启动顺序 + 降级矩阵)完成,已上板
+
+- 启动序列从 `app_main.c` 拆到 `firmware/main/bringup.{h,c}`,宿主机 `test_bringup.c` 24 例(逐个拔器件对 §10.10 逐行),全工程 425 例全绿。
+- 板 B 已烧 **0.1.0-a15**:输入侧电流第一次读出真值(+98.5 mA,以前没写 Calibration 恒 0);BOOT 事件带复位原因;CH224A 读到 5000 mA ⇒ 100 W(⚠️ 与 09-27 早上记的 20V/3A 不一致,待核)。
+- 新事件号 17 `PG_TIMEOUT`;`rack/backend/prrack/protocol.py` 已加并已部署(服务器 26 例过,板 B 显示 a15)。
+- 仍欠:A16 风扇闭环;I²C 总线挂死恢复(HAL 层);PD 上限的降载动作(§8.3)。详见 `firmware/HANDOFF.md`。
+
## ⭐ 2026-09-27 下午:机架管理全链路打通(固件 A12~A14 + 服务器前后端)
- **固件**(`firmware/HANDOFF.md`):A12 命令处理器、A13 USB 分帧、A14 WiFi/MQTT + C1 配网,板 B(`b81f3fc9c8bc`,COM5)已烧 0.1.0-a14、真连网。
diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md
index 6d3d770..204f9ff 100644
--- a/firmware/HANDOFF.md
+++ b/firmware/HANDOFF.md
@@ -1,44 +1,49 @@
# 交接
-更新:2026-09-27 完成 **A14 + C1**(WiFi/MQTT 链路 + 经 USB 配网,需求 §10.8「A14 定稿的 MQTT 链路」)
+更新:2026-09-27 完成 **A15**(启动顺序 + 降级矩阵,需求 §10.9 / §10.10)
## 一句话现状
-板 B(`b81f3fc9c8bc`)经 WiFi `CANBOX-5G` → 公网 → home 服务器 mosquitto 8883(TLS)**真连网跑通**:
-retained state、1 s 遥测、命令 / 判重 / `link` / `empty` / `parse` 回应全对。两条链路(USB + MQTT)都已上板。
-**23 个 ctest、400 个用例全绿**,`idf.py build` 通过(**0xE2220,分区只剩 12%**)。板 B 烧的是 0.1.0-a14,配置已存 NVS。
+启动序列从 `app_main.c`(只进 IDF、一行测不到)拆进 `main/bringup.{h,c}`,宿主机 `test_bringup.c` **24 例**逐个「拔掉」器件对 §10.10 逐行。
+**24 个 ctest、425 个用例全绿**,`idf.py build` 通过(**0xE2C60,分区剩 11%**)。板 B(COM5)已烧 **0.1.0-a15**。
## 下一步
-固件还欠 **A15**(启动顺序 + 降级矩阵)/ **A16**(风扇闭环接进 t_thermal 任务体)。
-或者转**服务器侧前后端**(需求 D7):后端以 `phonerail-backend` 订阅 `phonerail/+/rsp|tele|state`,经 `/cmd` 下发。
+- **A16**:风扇闭环接进 `t_thermal` 任务体(app_main.c 的 `task_thermal` 还是空壳;`cmd_env.thermal` 仍是 NULL)。
+- 服务器:`rack/backend/prrack/protocol.py` 已加事件 17,已部署(HOST=10.5.5.13,26 例过)。
+- 待核:CH224A 读到 5000 mA(100 W),而 PROGRESS 早上记的是 20V/3A —— 换了充电器,还是 0x50 的理解有误?
## 本次新增/修改的文件
-- `main/netcfg.{h,c}` **新** — 配置校验 / 持有 / 存储回调;网络状态;「密码永不外泄」
-- `main/mlink.{h,c}` **新** — topic、入站消息校验、WiFi 退避、遥测节拍、上线消息
-- `main/cmd_net.c` **新** — `wifi` / `mqtt`(只收 USB)/ `net` 三条命令;新增错误码 `link`(11)、命令号 10~12(冻结)
-- `main/mlink_esp.{h,c}` **新**(只进 IDF)— NVS、WiFi STA、esp-mqtt(TLS,enqueue 不阻塞)、遥测
-- `main/certs/evse_ca.pem` **新** — 服务器 mosquitto 的自签 CA(公钥证书),`EMBED_TXTFILES`
-- `main/cmd_json.h` — 字符串上限 32 → 65(WPA2 口令 63)
-- `main/app_main.c` / `CMakeLists.txt` — 接线;REQUIRES 加 wifi / netif / event / timer / nvs / mqtt
-- `test/test_net.c`(10 例)**新**;`test_cmd.c` 冻结编号更新;版本号 `0.1.0-a14`
+- `main/bringup.{h,c}` **新** — 两段:`bringup_devices()`(§10.9 第 1~4 步 + 降级)、`bringup_power_restore(want)`(第 5~6 步)
+- `main/app_main.c` — 启动段换成两段调用;**`t_safety` 在两段之间 spawn**(上电时巡检已在看);
+ `evlog_init_ex(esp_reset_reason())` 挪到最前;NVS 键 `phonerail/rail_want` 存「意图」;`hub_reset_pulse_once()` 搬进 bringup.c
+- `main/cmd.h` / `cmd_exec.c` — `cmd_env_t` 加 `intent` 回调,只有 `power` 命令真的生效时调
+- `main/evlog.{h,c}` — `evlog_init_ex(boot_arg)`;新事件 **`LOG_EV_PG_TIMEOUT = 17`**
+- `main/rail.{h,c}` — `rail_volt_init_one(addr)`;`rail_volt_init()` 一片失败**不再放弃另一片**
+- sim:`sim_gpio_rise_us()`、`sim_gpio_set_hook()`;`sim_gpio_drive()` 可在配置前调、跨 config 保持(刀片把 PRSNT# 短到地与固件何时配脚无关)
+- `test/test_bringup.c` **新**(24 例);`test_cmd.c` +1(意图);`test_evlog.c` 冻结号 +17;版本 `0.1.0-a15`
-## 服务器上改了什么(home 服务器,mosquitto 1.6.9,evse 项目共用)
-- `evse.passwd` 加用户 `b81f3fc9c8bc`、`phonerail-backend`;`evse.acl` 末尾追加 phonerail 段(`pattern … phonerail/%u/…`)。
-- ⛔ **修了 evse 原有的漏洞**:8883 listener 缺 `cafile` ⇒ TLS 根本没生效、**公网明文**。补 `cafile /etc/mosquitto/certs/ca.crt` 后重启;
- 改前 8883 上零成功连接(日志自 09-24 起),evse-backend 走本机 1884,重启后 5 s 自动重连。
-- 三个文件都有 `.bak-20260927-*` 备份。口令在 `/root/phonerail/mqtt.env`(700 目录,仅 root)。
+## §10.10 各行落在哪
+| 行 | 行为 | 用例 |
+|---|---|---|
+| 刀片 INA226 无应答 | 照常上电、`blind`、I2C_LOST(rail, 0x40) | `test_row_rail_ina_missing_keeps_power` |
+| DAC 无应答 | 不关输出、另一片照样初始化、影子回 3.90 | `test_row_dac_missing_…` |
+| PCA9555 无应答 | 照常上电,PG 改按 INA226 电压;两样都看不见 = 开着报 unverified | `test_row_pca_*`(3 例) |
+| mux 选不中 | 脉冲一次 MUX_RST#,仍不通 ⇒ 一路不开、MUX_LOST,不碰主干 | `test_row_mux_*`(2 例) |
+| PRSNT# 高 | 不发一个总线字节、不上电;想开的才记 SLOT_EMPTY | `test_row_empty_slot_*`(2 例) |
+| CH224A 读不到 / 偏低 | 按 60 W / 按读到的,PD_LIMIT 事件 | `test_row_pd_*` + 通道 5 兜底 |
+| 看门狗复位 | BOOT.arg = 复位原因 | `test_row_watchdog_reset_is_recorded_in_boot` |
+| **I²C 总线挂死** | ⛔ **没做**(9 个时钟恢复在 HAL 层) | — |
-## 验收命令与结果
+注错验证过:删掉 `ina226_set_reversed(0x43)` ⇒ 第一条红;错开改成 1 ms ⇒ 两条红。
+
+## 上板实测(板 B,五槽空)
```
-$ bash host/test.sh # 100% tests passed, 0 tests failed out of 23(400 例)
-$ python tools/prlink.py COM5 net # "wifi":"up","ip":"192.168.8.108","mqtt":"up"
-服务器:mosquitto_sub -h 127.0.0.1 -p 1884 -u phonerail-backend … -t 'phonerail/#' -v
+status: "fw":"0.1.0-a15","in":{"mv":12034,"ua":98500} ← 输入电流第一次是真值(以前 Calibration 没写,恒 0)
+events: [27, 51, 0, 1(BOOT), 11(ESP_RST_USB), …] [28, 155, 0, 16(PD_LIMIT), 0, 1000(=100 W), 5000 mA]
```
-注错验证过:放开「只收 USB」、去掉 JSON 字符串清洗 → 各自有用例变红。
+没有 I2C_LOST ⇒ 两片 DAC / 输入侧 INA226 / CH224A 都在主干应答。复位原因是 USB ⇒ 没碰 HUB_RST#,COM5 没掉。
+**上电复位路径(Hub 脉冲、DAC NVM 核对)和有刀片时的逐路上电还没在实物上走过**——要等刀片 + 手机上架。
## 踩到的坑(只写会再犯的)
-- ⛔ **`openssl s_client … | grep "Verify return code"` 不能证明 TLS 通了**:握手失败、没拿到证书时它也打 `0 (ok)`。要看 `Cipher is` / `Protocol`。
-- ⛔ mosquitto 1.6:listener 只有 certfile/keyfile、没有 cafile/capath ⇒ **静默退化成明文**。TLS 选项改动要 restart,HUP 不生效。
-- esp-tls 的「Certificate verified.」在握手失败时也会打,别被它带偏;服务器抓包(tcpdump 看 RST 时机)最快。
-- 服务器 pid 文件不在 `/var/run/mosquitto.pid`,HUP 用 `pgrep -x mosquitto`。`mosquitto_passwd -b`(1.6.9)会把文件内容打到终端,不是出错。
-- 本机(192.168.8.x)与服务器(10.5.5.x)不同网段,板子只能走公网 `home.example:8883`;**明文 mqtt:// 只许用在同一局域网**。
-- 固件 0xE2220 / 1 MB:WiFi + MQTT + TLS ≈ 600 KB。**C2(OTA 双分区)前必须重排分区表**(模组是 16 MB,sdkconfig 只按 8 MB 用)。
-- WiFi 断开原因码 3(AUTH_LEAVE)是重配网时我们主动断的,不是故障;201 = 找不到 AP(比如只有 5 GHz)。
+- ⛔ **mux 死了主干也够不着**:`i2cbus_xact(TRUNK)` 先写 mux「全关」,失败就不跑回调。别以为「DAC 在主干上就不受 mux 影响」。
+- sim 里同一地址在主干和某通道各挂一个,主干那颗 `present=false` 时仍会先被匹配 ⇒ NAK。测「只在通道 5」要主干上干脆不挂。
+- 设定电压**不持久化**:热重启后五路回到 3.90 V(bringup 把 DAC 重写成安全值)。上位机要的话重下。
+- `python` 在这台机器的 Git Bash 里是 Windows Store 占位程序,会挂住;一律用 `/c/Python314/python`。
diff --git a/firmware/components/drv/include/ch224a.h b/firmware/components/drv/include/ch224a.h
index ec0eed3..c94be72 100644
--- a/firmware/components/drv/include/ch224a.h
+++ b/firmware/components/drv/include/ch224a.h
@@ -18,7 +18,9 @@
│ 是同一条规矩:**能烧板子的动作不给接口,而不是给了再叮嘱别用。** │
└──────────────────────────────────────────────────────────────────────────┘
- 挂在 TCA9548A 的**通道 5**(`TCA9548A_CH_PD`)上,不在主干。 */
+ 设计上挂在 TCA9548A 的**通道 5**(`TCA9548A_CH_PD`)上。
+ ⚠️ **但 2026-09-27 板 B 实测它在主干应答**(0x22/0x23,mux 全关时也在),与设计不一致、原因未查。
+ `bringup.c` 的 `pd_up()` 两处都找(先主干);弄清之后删掉一处。 */
#ifndef CH224A_H
#define CH224A_H
diff --git a/firmware/components/phonerail_hal/sim/hal_gpio_sim.c b/firmware/components/phonerail_hal/sim/hal_gpio_sim.c
index e69f8d9..531e364 100644
--- a/firmware/components/phonerail_hal/sim/hal_gpio_sim.c
+++ b/firmware/components/phonerail_hal/sim/hal_gpio_sim.c
@@ -10,8 +10,13 @@ typedef struct {
int level;
uint32_t writes;
uint32_t seq; /* 最后一次被写时的全局序号,0 = 没写过 */
+ uint64_t rise_us; /* 最后一次 0→1 的虚拟时刻(µs),0 = 没有过 */
+ bool ext; /* 外部世界在驱这根线(`sim_gpio_drive`),跨 config 保持 */
+ int ext_level;
} pin_t;
+static sim_gpio_hook_t s_hook;
+
static pin_t s_pin[SIM_GPIO_PINS];
static uint32_t s_seq; /* 全局写计数,从 1 开始发号 */
@@ -30,6 +35,7 @@ void sim_gpio_reset_internal(void)
{
memset(s_pin, 0, sizeof(s_pin));
s_seq = 0;
+ s_hook = NULL;
}
static bool valid(int pin) { return pin >= 0 && pin < SIM_GPIO_PINS; }
@@ -45,6 +51,11 @@ hal_err_t hal_gpio_config(int pin, hal_gpio_mode_t mode)
/* 上拉输入没人驱动时读回高电平 —— 五路 FLT/PG 都是开漏 + 上拉,
「没有故障」在假板子上也必须是 1,不然测试会把默认 0 当成一直有故障。 */
p->level = (mode == HAL_GPIO_INPUT_PULLUP) ? 1 : 0;
+ /* 外部驱着的线,配成输入之后读到的是外部电平 —— 真板上刀片把 `PRSNT#` 短到地,
+ 与 ESP32 什么时候配这个脚无关(A15:启动序列自己配脚、配完立刻读)。 */
+ if (mode != HAL_GPIO_OUTPUT && p->ext) {
+ p->level = p->ext_level;
+ }
/* 配成输出的那一刻引脚就开始驱动了(ESP-IDF 的 gpio_config 也是这个行为,
输出寄存器复位值 0)。`KEY_OE#` 正是靠这一条:配成输出 = 输出立刻使能,
所以 sr595_init() 必须等安全值锁存完了再配它。 */
@@ -60,11 +71,18 @@ 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();
+ }
sim_sr595_gpio_hook(pin, s_pin[pin].level);
sim_mux_gpio_hook(pin, s_pin[pin].level);
+ if (s_hook != NULL) {
+ s_hook(pin, s_pin[pin].level);
+ }
return HAL_OK;
}
@@ -80,9 +98,14 @@ hal_err_t hal_gpio_read(int pin, int *level)
void sim_gpio_drive(int pin, int level)
{
assert(valid(pin));
- assert(s_pin[pin].configured);
- assert(s_pin[pin].mode != HAL_GPIO_OUTPUT); /* 外部驱一个输出脚 = 打架,测试写错了 */
- s_pin[pin].level = level ? 1 : 0;
+ /* 外部驱一个输出脚 = 打架,测试写错了。**没配过的脚可以驱**:外部世界不等固件
+ (电平记下来,配成输入的那一刻生效)。 */
+ assert(!s_pin[pin].configured || s_pin[pin].mode != HAL_GPIO_OUTPUT);
+ s_pin[pin].ext = true;
+ s_pin[pin].ext_level = level ? 1 : 0;
+ if (s_pin[pin].configured) {
+ s_pin[pin].level = level ? 1 : 0;
+ }
}
int sim_gpio_level(int pin)
@@ -96,3 +119,7 @@ int sim_gpio_level(int pin)
uint32_t sim_gpio_writes(int pin) { return valid(pin) ? s_pin[pin].writes : 0; }
uint32_t sim_gpio_write_seq(int pin) { return valid(pin) ? s_pin[pin].seq : 0; }
+
+uint64_t sim_gpio_rise_us(int pin) { return valid(pin) ? s_pin[pin].rise_us : 0; }
+
+void sim_gpio_set_hook(sim_gpio_hook_t fn) { s_hook = fn; }
diff --git a/firmware/components/phonerail_hal/sim/sim_hal.h b/firmware/components/phonerail_hal/sim/sim_hal.h
index 1af8948..a8d371d 100644
--- a/firmware/components/phonerail_hal/sim/sim_hal.h
+++ b/firmware/components/phonerail_hal/sim/sim_hal.h
@@ -113,7 +113,8 @@ void sim_i2c_set_bus_time(bool on);
直接 HAL_ERR_ARG)。边界写成「个数」而不是「上限」,就是这么错的。 */
#define SIM_GPIO_PINS 49
-/* 外部世界把某个**输入**脚拉成 level。对输出脚调用会 abort(那是总线打架)。 */
+/* 外部世界把某个**输入**脚拉成 level。对输出脚调用会 abort(那是总线打架)。
+ 可以在脚配置之前调:电平记着,配成输入的那一刻生效(压过内部上拉)。 */
void sim_gpio_drive(int pin, int level);
/* 该脚当前电平:输出脚 = 固件驱动的值,输入脚 = 外部驱动的值。没配过返回 -1。 */
@@ -127,6 +128,16 @@ uint32_t sim_gpio_writes(int pin);
「17 ms 上电时序」(A9)这类事,光看每个脚的终值和次数是看不出来的。 */
uint32_t sim_gpio_write_seq(int pin);
+/* 该脚**最后一次 0→1** 时的虚拟时刻(µs),从没抬高过返回 0。
+ 「五路上电错开 100 ms」(A15)要的是线上的时刻,不是被测代码自己报的时刻。 */
+uint64_t sim_gpio_rise_us(int pin);
+
+/* 每次 `hal_gpio_write()` 成功之后调一次(`sim_hal_reset()` 清掉)。
+ 给测试扮演「外部世界对引脚的反应」用 —— 比如 eFuse 的 EN 一抬高,
+ 那一路 PCA9555 上的 PGOOD 就跟着来(A15 的上电序列靠它才测得出「等 PG」)。 */
+typedef void (*sim_gpio_hook_t)(int pin, int level);
+void sim_gpio_set_hook(sim_gpio_hook_t fn);
+
/* ---------------- 74HC595 ×3 的 24 位链 ---------------- */
/* 按 datasheet 建模的假移位链,**只看线上真实发生的边**(SRCLK / RCLK 上升沿)。
diff --git a/firmware/host/CMakeLists.txt b/firmware/host/CMakeLists.txt
index 168a956..8a9db57 100644
--- a/firmware/host/CMakeLists.txt
+++ b/firmware/host/CMakeLists.txt
@@ -40,6 +40,7 @@ set(PHONERAIL_CORE_SRCS
${FW_ROOT}/main/cmd_net.c
${FW_ROOT}/main/netcfg.c
${FW_ROOT}/main/mlink.c
+ ${FW_ROOT}/main/bringup.c
)
# HAL:接口在 include/,宿主机链 sim/ 的实现(esp/ 那份只有 IDF 目标编)。
diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt
index 695902b..b2a4dc4 100644
--- a/firmware/main/CMakeLists.txt
+++ b/firmware/main/CMakeLists.txt
@@ -24,6 +24,7 @@ idf_component_register(
"cmd_net.c"
"netcfg.c"
"mlink.c"
+ "bringup.c"
INCLUDE_DIRS "."
EMBED_TXTFILES "certs/evse_ca.pem"
REQUIRES phonerail_hal drv esp_driver_usb_serial_jtag
diff --git a/firmware/main/app_main.c b/firmware/main/app_main.c
index ae434d1..6235d53 100644
--- a/firmware/main/app_main.c
+++ b/firmware/main/app_main.c
@@ -3,6 +3,7 @@
#include
#include "board_pins.h"
+#include "bringup.h"
#include "cmd.h"
#include "cmd_queue.h"
#include "esp_log.h"
@@ -26,6 +27,8 @@
#include "t_safety.h"
#include "tasks.h"
#include "mlink_esp.h"
+#include "nvs.h"
+#include "nvs_flash.h"
#include "ulink_esp.h"
static const char *TAG = "phonerail";
@@ -236,44 +239,77 @@ static void task_log(void *arg)
}
}
-/* USB2517I 的复位脉冲 —— **整个固件里唯一碰 `HUB_RST#` 的地方,而且只在启动调一次**。
+/* ---- 每路「意图」的持久化(A15,§10.9 第 4 步「上次的每路使能状态」)--------
+ 一个字节,bit0 = CH1。只有 `power` 命令改它(cmd.h 的 `cmd_intent_fn`)。
+ 与网络配置同一个 NVS 命名空间(mlink_esp.c),各用各的键。
+ 新板 / 读不到 = 0 = 一路都不开:宁可让人手动开一次,也不在不知道的情况下给五台手机上电。 */
+#define WANT_NS "phonerail"
+#define WANT_KEY "rail_want"
- 低有效(板上 R716 上拉到 +3V3_HUB),给 1 ms 足够:手册要的是几十微秒量级。
+static uint8_t s_want;
- ⛔⛔ **运行期一个字都不许再写它**(R2 D13 代价 2)。ESP32 自己的
- USB-Serial-JTAG 就挂在这个 Hub 的第 6 个下行口上,所以复位 Hub =
- **自己把自己的控制台拔了**,而且下一次 `idf.py flash` 也进不来
- —— 到那时候唯一的退路是 J33 那个 5P 应急串口(UART0,IO43/IO44),
- 得拆机接线。写一行 "Hub 卡了就复位一下" 的自愈代码,代价是整机变砖。
-
- ⚠️ 这一下放在 `rail_en_init()` 之后、其它一切之前:Hub 复位会让 USB 重新枚举,
- 期间串口日志是丢的。越早做,后面越多的启动日志能被电脑收到。
- 失败**不 abort**:Hub 没起来只影响调试与手机的数据口,五路供电照常
- (§10.2:失联 ≠ 断电)。
-
- ⛔ **只在上电复位(`ESP_RST_POWERON`)时脉冲**(2026-09-27 上板改)。
- 原来是「每次启动都脉冲」,而 ESP32 的每一次重启都算启动:esptool 经 USB 复位芯片、
- 崩溃 / 看门狗重启、`esp_restart()` —— 每一次都把 Hub 复位、把自己的 COM 口拔掉。
- 实测后果:板上一旦有这份固件,`esptool --before usb_reset` 连三次全断
- (`ClearCommError PermissionError 13`),只能走 J33 手动进下载模式。
- 上电复位时 Hub 自己也刚上电,脉冲没有代价;J33 的 EN 脚复位在 S3 上也报 POWERON,
- 应急路径不受影响。其余原因一律不碰 `HUB_RST#`(此时它仍是复位态的高阻,靠 R716 拉高)。 */
-static void hub_reset_pulse_once(void)
+static void nvs_up(void)
{
- const esp_reset_reason_t why = esp_reset_reason();
- if (why != ESP_RST_POWERON) {
- ESP_LOGI(TAG, "复位原因 %d 不是上电复位 —— 不动 HUB_RST#(保住 USB 控制台)", (int)why);
- return;
+ esp_err_t e = nvs_flash_init();
+ if (e == ESP_ERR_NVS_NO_FREE_PAGES || e == ESP_ERR_NVS_NEW_VERSION_FOUND) {
+ nvs_flash_erase(); /* 同 mlink_esp.c:格式不认就清掉重来 */
+ e = nvs_flash_init();
}
- hal_err_t err = hal_gpio_config(BOARD_PIN_HUB_RST_N, HAL_GPIO_OUTPUT);
- if (err != HAL_OK) {
- ESP_LOGE(TAG, "HUB_RST# 配置失败:%s —— 跳过复位脉冲", hal_err_str(err));
- return;
+ if (e != ESP_OK) {
+ ESP_LOGE(TAG, "NVS 起不来:%s —— 本次按「一路都不开」启动", esp_err_to_name(e));
+ }
+}
+
+static uint8_t want_load(void)
+{
+ nvs_handle_t h;
+ uint8_t v = 0;
+ if (nvs_open(WANT_NS, NVS_READONLY, &h) == ESP_OK) {
+ if (nvs_get_u8(h, WANT_KEY, &v) != ESP_OK) {
+ v = 0;
+ }
+ nvs_close(h);
+ }
+ return v;
+}
+
+static void want_save(void *ctx, int rail, bool on)
+{
+ (void)ctx;
+ const uint8_t v = bringup_want_update(s_want, rail, on);
+ if (v == s_want) {
+ return; /* 没变就不写 flash */
+ }
+ nvs_handle_t h;
+ esp_err_t e = nvs_open(WANT_NS, NVS_READWRITE, &h);
+ if (e == ESP_OK) {
+ e = nvs_set_u8(h, WANT_KEY, v);
+ if (e == ESP_OK) {
+ e = nvs_commit(h);
+ }
+ nvs_close(h);
+ }
+ if (e == ESP_OK) {
+ s_want = v;
+ } else {
+ /* 存不下不影响这一次的开关(已经生效了),只影响下次开机恢复成什么样。 */
+ ESP_LOGW(TAG, "意图没存进 NVS:%s", esp_err_to_name(e));
+ }
+}
+
+static void log_report(const bringup_report_t *r)
+{
+ ESP_LOGI(TAG, "启动:降级位 0x%02x,空槽 0x%02x,盲跑 0x%02x,PCA 失联 0x%02x,PD %u mA → 上限 %u mW",
+ (unsigned)r->deg, (unsigned)r->empty, (unsigned)r->blind, (unsigned)r->io_lost,
+ (unsigned)r->pd_ma, (unsigned)r->pd_limit_mw);
+ if ((r->deg & BRINGUP_DEG_MUX) != 0) {
+ ESP_LOGE(TAG, "TCA9548A 复位后仍选不中 —— 五路一路都不上电(§5.8)");
+ }
+ if ((r->deg & BRINGUP_DEG_DAC_NVM) != 0) {
+ ESP_LOGW(TAG, "DAC 上电值不是 3.90 V(NVM 被改过?):%u %u %u %u %u mV",
+ r->dac_nvm_mv[0], r->dac_nvm_mv[1], r->dac_nvm_mv[2], r->dac_nvm_mv[3],
+ r->dac_nvm_mv[4]);
}
- (void)hal_gpio_write(BOARD_PIN_HUB_RST_N, 0); /* 低 = 复位 */
- hal_time_delay_ms(1);
- (void)hal_gpio_write(BOARD_PIN_HUB_RST_N, 1); /* 放开,之后再也不动 */
- ESP_LOGI(TAG, "USB Hub 已复位一次(运行期不再动 HUB_RST#)");
}
static void spawn(task_id_t id, TaskFunction_t fn)
@@ -299,96 +335,38 @@ void app_main(void)
selftest_or_die();
- /* ⭐ R2:**五根** `EFUSE_EN` 配成输出并全部拉低(R1 是十根:另外五根 `VBUS_EN`
- 已经挪到刀片的 PCA9555 上了)。**先于一切外设**:DAC / eFuse 一上电就有
- 输出,这五根线这时必须已经确定是低的。 */
- hal_err_t err = rail_en_init();
+ const esp_reset_reason_t why = esp_reset_reason();
+
+ /* 事件日志**最先**起(A15 挪到这里):下面的启动序列会记事件(缺件 / mux / PD),
+ 它们必须排在这次的 BOOT 之后,否则时间线上看着像是上一次运行留下的。
+ BOOT 的 arg = 复位原因 —— §10.10「看门狗复位 … 记一条复位事件」就是它。
+ 只碰 flash,不碰总线,放在 EN 脚配置之前也安全(那几根线此刻靠下拉关着)。
+ ⚠️ 失败**不 abort**:日志是排查手段,不是安全链的一环(§10.2)。 */
+ hal_err_t err = evlog_init_ex((uint8_t)why);
if (err != HAL_OK) {
- ESP_LOGE(TAG, "EN 引脚初始化失败:%s", hal_err_str(err));
+ ESP_LOGE(TAG, "事件日志落盘不可用:%s —— 事件只留在 RAM 里", hal_err_str(err));
+ } else {
+ evlog_stats_t st;
+ evlog_stats(&st);
+ ESP_LOGI(TAG, "事件日志:容量 %u 条,flash 里已有 %u~%u(复位原因 %d)",
+ (unsigned)evlog_capacity(), (unsigned)st.oldest_seq, (unsigned)st.next_seq,
+ (int)why);
+ }
+
+ nvs_up();
+ s_want = want_load();
+
+ /* §10.9 第 1~4 步:EN 脚安全态 → Hub 脉冲(仅上电复位)→ 总线 → 认件 / 初始化。
+ 整段在 bringup.c 里、宿主机上测得到(A15 之前它在这里,一行也测不到)。
+ 缺件一律降级不 abort;返回错误 = 引脚 / 配置常量写错了,那才 abort。 */
+ static bringup_report_t boot;
+ const bringup_in_t in = { .power_on_reset = (why == ESP_RST_POWERON) };
+ err = bringup_devices(&in, &boot);
+ if (err != HAL_OK) {
+ ESP_LOGE(TAG, "启动序列失败(固件配置错误):%s", hal_err_str(err));
abort();
}
- /* ⚠️ R2:这一步关掉的**只有五路直供**(`EFUSE_EN` 是 GPIO 直连)。
- `VBUS_EN` 已经挪到刀片的 PCA9555 P0.4 上了(计划 §5.4),此刻还不受控 ——
- 它靠的是硬件:PCA9555 复位后全是输入(高阻)+ R701 100 kΩ 下拉 = VBUS 断。
- 真正"固件说了算"要等到下面的 `slot_init()`。 */
- ESP_LOGI(TAG, "五路 eFuse 使能已全部拉低(VBUS 此刻靠下拉电阻关着)");
-
- /* USB Hub 复位脉冲 —— 只在上电复位时、只此一次,见函数头上那一段。 */
- hub_reset_pulse_once();
-
- /* 锁必须早于任何一个用总线的任务。
- ⚠️ R2 之后**全系统只剩一把** `HAL_LOCK_I2C`(595 那把随着移位链一起退役),
- 所以「两把锁嵌套」在物理上不可能了;代价是执行器与巡检开始争同一把锁
- (计划 §5.6)。 */
- (void)hal_lock_init();
-
- const hal_i2c_cfg_t i2c = {
- .sda_gpio = BOARD_PIN_I2C_SDA, /* ⭐ R2:IO41(R1 是 IO14) */
- .scl_gpio = BOARD_PIN_I2C_SCL, /* ⭐ R2:IO42(R1 是 IO15) */
- .freq_hz = 400000,
- };
- err = hal_i2c_init(&i2c);
- if (err != HAL_OK) {
- ESP_LOGE(TAG, "I²C 初始化失败:%s", hal_err_str(err));
- abort();
- }
-
- /* ⭐ R2 新增:带 mux 的总线门面。配 `MUX_RST#`、认 TCA9548A(0x70)、把通道全关。
- **必须在 `hal_i2c_init()` 与 `hal_lock_init()` 之后**(i2cbus.h)。
- ⚠️ 失败**不 abort**:mux 认不到是**运行期**的降级情形(计划 §5.8 —— 判据会
- 先脉冲 `MUX_RST#` 再决定要不要五路全断),不是固件写错了。这与
- `selftest_or_die()` 的分界线始终是同一条:常量表错 = abort,器件不在 = 降级。 */
- err = i2cbus_init();
- if (err != HAL_OK) {
- ESP_LOGE(TAG, "TCA9548A(0x%02x) 初始化失败:%s —— 五路的电压电流这一刻全看不见",
- (unsigned)TCA9548A_ADDR, hal_err_str(err));
- }
-
- /* ⭐ R2 新增:五根 `PRSNT#` 配成上拉输入并立刻读一遍(`slot_prsnt_init()` 自带
- 一次 poll)。**必须在 `slot_init()` 之前**:下面要拿它决定哪几槽值得初始化。
- ⚠️ 这一步失败就 abort:它只可能因为引脚号非法而失败,而那正是
- `rail_selftest()` 的 `PINS` 位刚刚查过的东西 —— 走到这里还失败 = 固件写错了。
- 而且降级的方向是**不安全**的那一侧:`slot_present()` 读不到时返回"在位",
- 于是空槽也会被允许上电 = 金手指裸露带 12V(计划 §5.8)。 */
- err = slot_prsnt_init();
- if (err != HAL_OK) {
- ESP_LOGE(TAG, "PRSNT# 引脚初始化失败:%s", hal_err_str(err));
- abort();
- }
-
- /* ⭐⭐ R2 必须有的一行(计划 §5.9):底板输入侧那片 INA226(U13, 0x43)的
- `IN+`/`IN−` **在板上是对调接的**(pin9 = `VIN_SW` 上游、pin10 = `+12V` 下游)。
- 漏了这一行,正常供电时它读回来的电流是负的,SW-8 会把「板子在好好工作」
- 判成**反灌**,于是一上电就断 VBUS —— 而电压、功率、温度全都正常,
- 日志里只会看到反复的 `LOG_EV_REVERSE`。
- ⚠️ 这是纯软件状态(驱动里一个标志位),不碰总线,所以放在认件之前也没关系;
- 但必须早于 `t_safety` 起来,那才是读它的人。 */
- ina226_set_reversed(INA226_ADDR_INPUT, true);
-
- /* ⭐ R2 新增:逐片刀片的 PCA9555 初始化(自带 `i2cbus_xact`,各走各的 mux 通道)。
- 驱动里那个框保证了顺序 **输出锁存器先清零、方向最后改** —— 反过来的那一刻
- P0.0~P0.4 全输出 1 = 三个键一起按下 + EDL 短接 + VBUS 上电(计划 §5.3)。
- 这一步之后 `VBUS_EN` 才真的由固件说了算。
- ⚠️ 失败**不 abort**(同 `i2cbus_init()`:这是运行期降级)。代价写在 §5.4:
- PCA9555 失联 ⇒ 该路 VBUS **关不掉**,兜底只剩关 `EFUSE_EN`。 */
- for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
- if (!slot_present(rail)) {
- /* 空槽不去初始化:那一片器件本来就不在,只会白等五次 NAK。
- 这一路也不许上电 —— 判据在 `t_safety` 里每圈查(计划 §5.8)。 */
- ESP_LOGW(TAG, "槽 %d 没插刀片(PRSNT# 为高):跳过初始化,且不许上电", rail);
- continue;
- }
- const hal_err_t e = slot_init(rail);
- if (e != HAL_OK) {
- ESP_LOGE(TAG, "槽 %d 的 PCA9555(0x%02x) 初始化失败:%s —— 这一路的 VBUS "
- "关不掉了,兜底只剩 EFUSE_EN",
- rail, (unsigned)PCA9555_ADDR, hal_err_str(e));
- }
- }
-
- /* ⚠️ 还没做的:INA226 的 Config/Calibration 与 DAC 的上电值(A15 那一包)。
- 在那之前 t_safety 读回来的电流恒为 0(Calibration 还没写),
- 这是预期的,不是故障。 */
+ log_report(&boot);
/* 执行器:PG 从巡检的快照里取,本任务自己不碰 I²C(t_actuator.h 开头那一段)。
⚠️ 必须在 `spawn(TASK_SAFETY, ...)` 之前:判据要关路时会先 `t_actuator_abort()`,
@@ -396,15 +374,31 @@ void app_main(void)
t_actuator_init(act_pg_from_safety, NULL);
/* 命令处理器(A12)。status 读巡检快照;热管理快照等 A16 把任务体接上再给。
+ `power` 命令的意图经 `want_save` 存 NVS(A15)。
队列要在执行器任务起来之前建好 —— 那个任务第一件事就是等它。 */
- const cmd_env_t cmd_env = { .safety = &s_snap, .thermal = NULL };
+ const cmd_env_t cmd_env = { .safety = &s_snap, .thermal = NULL, .intent = want_save };
cmd_init(&cmd_env);
s_cmd_q = xQueueCreate(CMD_QUEUE_DEPTH, sizeof(cmd_msg_t));
if (s_cmd_q == NULL) {
ESP_LOGE(TAG, "命令队列建不起来");
abort();
}
- /* A13:USB 命令链路。装驱动 + 把日志复制一份到 USB(UART0 照旧)。
+
+ /* 事件出口。要在巡检起来之前接上,否则第一批事件没人接。 */
+ t_safety_set_event_sink(log_safety_event, NULL);
+
+ /* ⭐ 巡检**先于上电**起来(bringup.h「分两段」那一段):
+ 五路一开始带电,SR-5 / SW-8 就已经在看了。 */
+ spawn(TASK_SAFETY, task_safety);
+
+ /* §10.9 第 5~6 步:按上次的意图逐路上电、错开 100 ms、等 PG(阻塞,最坏约 0.9 s)。 */
+ bringup_power_restore(s_want, &boot);
+ ESP_LOGI(TAG, "上电恢复:意图 0x%02x → 开着 0x%02x(等不到 PG 0x%02x,确认不了 0x%02x)",
+ (unsigned)boot.want, (unsigned)boot.on, (unsigned)boot.pg_fail,
+ (unsigned)boot.unverified);
+
+ /* §10.9 第 7 步:链路与其余任务。
+ A13:USB 命令链路。装驱动 + 把日志复制一份到 USB(UART0 照旧)。
失败不 abort:USB 命令链路不可用,供电与别的链路照常(§10.2)。 */
err = ulink_esp_init();
if (err != HAL_OK) {
@@ -416,26 +410,6 @@ void app_main(void)
ESP_LOGE(TAG, "WiFi 命令链路不可用:%s", hal_err_str(err));
}
- /* 事件日志(§10.7)。**要在接事件出口之前 init**,否则第一条事件进的是
- 一个还没扫过 flash 的缓冲。
- ⚠️ 失败**不 abort**:没有 `evlog` 分区时日志落不了盘,但五路照供电
- —— 日志是排查手段,不是安全链的一环(§10.2)。这与 `selftest_or_die()`
- 的区别在于:那里错的是「命令会发到哪一路」,这里错的只是「记不下来」。 */
- err = evlog_init();
- if (err != HAL_OK) {
- ESP_LOGE(TAG, "事件日志落盘不可用:%s —— 事件只留在 RAM 里", hal_err_str(err));
- } else {
- evlog_stats_t st;
- evlog_stats(&st);
- ESP_LOGI(TAG, "事件日志:容量 %u 条,flash 里已有 %u~%u",
- (unsigned)evlog_capacity(), (unsigned)st.oldest_seq,
- (unsigned)st.next_seq);
- }
-
- /* 事件出口。同样要在巡检起来之前接上,否则第一批事件没人接。 */
- t_safety_set_event_sink(log_safety_event, NULL);
-
- spawn(TASK_SAFETY, task_safety);
spawn(TASK_THERMAL, task_thermal);
spawn(TASK_ACTUATOR, task_actuator);
spawn(TASK_LINK, task_link);
diff --git a/firmware/main/board_pins.h b/firmware/main/board_pins.h
index 6cc21aa..864ed9e 100644
--- a/firmware/main/board_pins.h
+++ b/firmware/main/board_pins.h
@@ -50,7 +50,7 @@ extern "C" {
/* USB2517I 的复位,上拉到 +3V3_HUB ⇒ 低有效。
⛔⛔ **运行期一个字都不许写它**(R2 D13 代价 2):ESP32 自己的控制台与烧录口
就挂在这个 Hub 的第 6 个下行口上,误复位 = 自断控制台,而且下一次烧录也进不来。
- 只在**上电复位**时脉冲一次(其余复位原因不碰,见 app_main.c);真出事的退路是 J33 那个 5P 应急串口。 */
+ 只在**上电复位**时脉冲一次(其余复位原因不碰,见 bringup.c 的 `hub_pulse()`);真出事的退路是 J33 那个 5P 应急串口。 */
#define BOARD_PIN_HUB_RST_N 8 /* 模组脚 12 → U32.43 */
#define BOARD_PIN_UP_VBUS_DET 6 /* 模组脚 6 → R721/R722 分压,上游 USB-C 插没插 */
diff --git a/firmware/main/bringup.c b/firmware/main/bringup.c
new file mode 100644
index 0000000..b816c8a
--- /dev/null
+++ b/firmware/main/bringup.c
@@ -0,0 +1,410 @@
+/* 启动顺序 + 降级矩阵。契约与 §10.10 各行的落点见 bringup.h。 */
+#include "bringup.h"
+
+#include
+#include
+
+#include "board_pins.h"
+#include "ch224a.h"
+#include "dac63204.h"
+#include "evlog.h"
+#include "hal_gpio.h"
+#include "hal_i2c.h"
+#include "hal_lock.h"
+#include "hal_time.h"
+#include "i2cbus.h"
+#include "ina226.h"
+#include "pca9555.h"
+#include "rail.h"
+#include "rail_cfg.h"
+#include "slot.h"
+#include "tca9548a.h"
+
+/* 启动期拿锁等多久。此刻只有巡检(第二段)在和我们抢,它一次最多持锁 ~2 ms。 */
+#define BRINGUP_LOCK_MS SLOT_LOCK_TIMEOUT_MS
+
+static uint8_t bit_of(int rail) { return (uint8_t)(1u << (rail - 1)); }
+
+static void lost(int rail, uint8_t addr, hal_err_t err)
+{
+ /* arg = 地址(evlog.h 的约定),current_ua 顺手带上 HAL 错误码 ——
+ NAK(不在)与 BUS(在但 ID 不对 = 贴错件)是两种完全不同的现场。 */
+ evlog_put((uint8_t)rail, LOG_EV_I2C_LOST, addr, 0, (int32_t)err);
+}
+
+/* ---- 一次 xact 的正文们 -------------------------------------------------- */
+
+static hal_err_t nop_fn(void *arg)
+{
+ (void)arg;
+ return HAL_OK; /* 选通道本身就是探测:选得中 = mux 在 */
+}
+
+static hal_err_t ina_up_fn(void *arg)
+{
+ const uint8_t addr = *(const uint8_t *)arg;
+ hal_err_t err = ina226_probe(addr);
+ return (err == HAL_OK) ? ina226_init(addr) : err;
+}
+
+static hal_err_t dac_up_fn(void *arg)
+{
+ return rail_volt_init_one(*(const uint8_t *)arg);
+}
+
+typedef struct {
+ uint16_t mv[PHONERAIL_RAIL_COUNT];
+} dac_peek_t;
+
+static hal_err_t dac_peek_fn(void *arg)
+{
+ dac_peek_t *p = (dac_peek_t *)arg;
+ for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
+ int mv = 0;
+ p->mv[rail - 1] = (rail_get_mv(rail, &mv) == HAL_OK) ? (uint16_t)mv : 0u;
+ }
+ return HAL_OK;
+}
+
+static hal_err_t pd_fn(void *arg)
+{
+ return ch224a_read_max_ma(CH224A_ADDR_DEFAULT, (uint32_t *)arg);
+}
+
+/* ---- 第一段 -------------------------------------------------------------- */
+
+/* USB2517I 的复位脉冲 —— **整个固件里唯一碰 `HUB_RST#` 的地方,而且只在启动调一次**
+ (A15 从 app_main.c 搬过来的,理由原样保留)。
+
+ 低有效(板上 R716 上拉到 +3V3_HUB),给 1 ms 足够:手册要的是几十微秒量级。
+
+ ⛔⛔ **运行期一个字都不许再写它**(R2 D13 代价 2)。ESP32 自己的
+ USB-Serial-JTAG 就挂在这个 Hub 的第 6 个下行口上,所以复位 Hub =
+ **自己把自己的控制台拔了**,而且下一次 `idf.py flash` 也进不来
+ —— 到那时候唯一的退路是 J33 那个 5P 应急串口(UART0,IO43/IO44),
+ 得拆机接线。写一行 "Hub 卡了就复位一下" 的自愈代码,代价是整机变砖。
+
+ ⛔ **只在上电复位(`ESP_RST_POWERON`)时脉冲**(2026-09-27 上板改)。
+ ESP32 的每一次重启都算启动:esptool 经 USB 复位芯片、崩溃 / 看门狗重启、
+ `esp_restart()` —— 原来每一次都把 Hub 复位、把自己的 COM 口拔掉,实测
+ `esptool --before usb_reset` 连三次全断(`ClearCommError PermissionError 13`)。
+ 上电复位时 Hub 自己也刚上电,脉冲没有代价;J33 的 EN 脚复位在 S3 上也报 POWERON,
+ 应急路径不受影响。其余原因**连配置都不做**(配成输出那一刻就是输出 0 = 一次复位),
+ 它仍是复位态的高阻,靠 R716 拉高。
+
+ 失败**不 abort**:Hub 没起来只影响调试与手机的数据口,五路供电照常(§10.2)。 */
+static void hub_pulse(void)
+{
+ if (hal_gpio_config(BOARD_PIN_HUB_RST_N, HAL_GPIO_OUTPUT) != HAL_OK) {
+ return;
+ }
+ (void)hal_gpio_write(BOARD_PIN_HUB_RST_N, 0); /* 低 = 复位 */
+ hal_time_delay_ms(1);
+ (void)hal_gpio_write(BOARD_PIN_HUB_RST_N, 1); /* 放开,之后再也不动 */
+}
+
+/* mux:`i2cbus_init()` 认不到 ⇒ 按 §5.8 先脉冲一次 `MUX_RST#` 再判。 */
+static bool mux_up(bringup_report_t *r)
+{
+ if (i2cbus_init() == HAL_OK) {
+ return true;
+ }
+ (void)i2cbus_recover(); /* 影子作废 ⇒ 下面那次 xact 一定真的发选通字节 */
+ if (i2cbus_xact(I2CBUS_CH_TRUNK, BRINGUP_LOCK_MS, nop_fn, NULL) == HAL_OK) {
+ return true;
+ }
+ r->deg |= BRINGUP_DEG_MUX;
+ evlog_put(0, LOG_EV_MUX_LOST, 0, 0, 0);
+ return false;
+}
+
+static void dac_up(const bringup_in_t *in, bringup_report_t *r)
+{
+ /* §10.9 第 3 步:读回 NVM 默认值与期望比对。**只在上电复位时有意义** ——
+ 热重启时 DAC 没断过电,里面是上一条 set_voltage 设的值,不是 NVM 默认。
+ 它要证明的是「DAC 不应答时输出会停在 3.90 V」这条降级前提还成立。 */
+ if (in->power_on_reset) {
+ dac_peek_t p;
+ memset(&p, 0, sizeof(p));
+ (void)i2cbus_xact(I2CBUS_CH_TRUNK, BRINGUP_LOCK_MS, dac_peek_fn, &p);
+ for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
+ r->dac_nvm_mv[rail - 1] = p.mv[rail - 1];
+ /* 读不到(0)归「不应答」那一行管,这里只抓「读到了但不对」。
+ 容差一个码以内:码 ↔ mV 的换算本身有 ±1 mV 的取整。 */
+ const int d = (int)p.mv[rail - 1] - RAIL_MV_SAFE;
+ if (p.mv[rail - 1] != 0 && (d > 1 || d < -1)) {
+ r->deg |= BRINGUP_DEG_DAC_NVM;
+ }
+ }
+ }
+
+ static const uint32_t deg_bit[DAC63204_COUNT] = { BRINGUP_DEG_DAC_U6, BRINGUP_DEG_DAC_U7 };
+ _Static_assert(DAC63204_COUNT == 2, "deg_bit 跟着改");
+ bool any_fail = false;
+ for (int i = 0; i < DAC63204_COUNT; i++) {
+ uint8_t addr = dac63204_all_addr[i];
+ const hal_err_t err = i2cbus_xact(I2CBUS_CH_TRUNK, BRINGUP_LOCK_MS, dac_up_fn, &addr);
+ if (err != HAL_OK) {
+ r->deg |= deg_bit[i];
+ lost(0, addr, err);
+ any_fail = true;
+ }
+ }
+ /* §10.10:DAC 不应答 **不关输出** —— 输出停在 NVM 默认 3.90 V,是安全值。
+ 影子也得说这句实话,否则判据拿一个「上次设过的值」去比,会把 3.90 判成偏离。 */
+ if (any_fail) {
+ rail_setpoint_reset();
+ }
+}
+
+static void pd_up(bringup_report_t *r)
+{
+ /* ⚠️ CH224A 在哪条支路上,设计文档与实物**不一致**:驱动头写的是 mux 通道 5,
+ 而 2026-09-27 板 B 实测它在**主干**应答(0x22/0x23,PROGRESS 顶部)。
+ 两处都试:先主干(实测),不应答再试通道 5(设计)。
+ 选到通道 5 时主干照样看得见,所以这不会读到别的东西。 */
+ uint32_t ma = 0;
+ hal_err_t err = i2cbus_xact(I2CBUS_CH_TRUNK, BRINGUP_LOCK_MS, pd_fn, &ma);
+ if (err != HAL_OK) {
+ err = i2cbus_xact(TCA9548A_CH_PD, BRINGUP_LOCK_MS, pd_fn, &ma);
+ }
+
+ uint8_t arg = 0;
+ if (err != HAL_OK || ma == 0) {
+ r->deg |= BRINGUP_DEG_PD;
+ r->pd_ma = 0;
+ r->pd_limit_mw = ch224a_power_limit_mw(BRINGUP_PD_FALLBACK_MA);
+ arg = 1; /* 1 = 按保守值 */
+ if (err != HAL_OK) {
+ lost(0, CH224A_ADDR_DEFAULT, err);
+ }
+ } else {
+ r->pd_ma = ma;
+ r->pd_limit_mw = ch224a_power_limit_mw(ma);
+ if (ma < BRINGUP_PD_FALLBACK_MA) {
+ r->deg |= BRINGUP_DEG_PD_LOW;
+ }
+ }
+ /* 不管读没读到都记一条:「这次上电按多少瓦跑」是排查降载的第一个问题。
+ ⚠️ 本包只**定**上限;逼近上限时按优先级降载(§8.3)还没人做(HANDOFF)。 */
+ evlog_put(0, LOG_EV_PD_LIMIT, arg, (int32_t)(r->pd_limit_mw / 100u), (int32_t)ma);
+}
+
+hal_err_t bringup_devices(const bringup_in_t *in, bringup_report_t *r)
+{
+ assert(in != NULL && r != NULL);
+ memset(r, 0, sizeof(*r));
+
+ /* ① 五根 `EFUSE_EN` 配成输出并拉低。**先于一切外设**:DAC / eFuse 一上电就有
+ 输出,这五根线这时必须已经确定是低的。VBUS 此刻靠硬件关着(PCA9555 复位后
+ 全是输入 + R701 下拉),真正「固件说了算」要等下面的 `slot_init()`。 */
+ hal_err_t err = rail_en_init();
+ if (err != HAL_OK) {
+ return err;
+ }
+
+ /* Hub 越早复位越好:它会让 USB 重新枚举,期间日志是丢的。 */
+ if (in->power_on_reset) {
+ hub_pulse();
+ }
+
+ /* 锁必须早于任何一个用总线的人(R2 之后全系统只剩这一把)。 */
+ (void)hal_lock_init();
+ const hal_i2c_cfg_t i2c = {
+ .sda_gpio = BOARD_PIN_I2C_SDA,
+ .scl_gpio = BOARD_PIN_I2C_SCL,
+ .freq_hz = 400000,
+ };
+ err = hal_i2c_init(&i2c);
+ if (err != HAL_OK) {
+ return err;
+ }
+
+ const bool mux_ok = mux_up(r);
+
+ /* `PRSNT#`:失败 = 引脚号非法 = 固件写错了,而且降级方向是不安全的那一侧
+ (`slot_present()` 读不到时返回「在位」⇒ 空槽也会被允许上电)。 */
+ err = slot_prsnt_init();
+ if (err != HAL_OK) {
+ return err;
+ }
+
+ /* ⭐⭐ 底板输入侧 U13(0x43)的 IN+/IN− **在板上是对调接的**(计划 §5.9)。
+ 漏了这一行,正常供电时它读回来的电流是负的,被当成反灌 ——
+ `test_bringup.c` 的第一条用例就是它。纯软件状态,不碰总线。 */
+ ina226_set_reversed(INA226_ADDR_INPUT, true);
+
+ /* ② 主干:两片 DAC、输入侧 INA226、CH224A。
+ ⚠️ **mux 失联时主干也够不着**:`i2cbus_xact(TRUNK)` 要先给 mux 写「全关」,
+ 那一步失败 fn 就不会被调(i2cbus.h 规矩 2)。物理上主干器件还在,但这一层
+ 没有「绕过 mux」的路 —— 也不该有,否则通道影子就不可信了。
+ 这时五路反正一路都不开(§5.8),所以不去试、不刷三条误导性的 I2C_LOST,
+ 只把状态说成实话:DAC 停在 NVM 默认、功率按保守值。 */
+ if (mux_ok) {
+ dac_up(in, r);
+ uint8_t addr = INA226_ADDR_INPUT;
+ const hal_err_t e = i2cbus_xact(I2CBUS_CH_TRUNK, BRINGUP_LOCK_MS, ina_up_fn, &addr);
+ if (e != HAL_OK) {
+ r->deg |= BRINGUP_DEG_INPUT_INA;
+ lost(0, addr, e);
+ }
+ pd_up(r);
+ } else {
+ rail_setpoint_reset();
+ r->pd_limit_mw = ch224a_power_limit_mw(BRINGUP_PD_FALLBACK_MA);
+ }
+
+ /* ③ 五片刀片。空槽不去碰(只会白等 NAK);mux 失联时一路也够不着。 */
+ for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
+ if (!slot_present(rail)) {
+ r->empty |= bit_of(rail);
+ continue;
+ }
+ if (!mux_ok) {
+ continue; /* 整板级已经记过 MUX_LOST,不再逐路刷十条 I2C_LOST */
+ }
+ /* PCA9555:驱动保证「输出锁存器先清零、方向最后改」(计划 §5.3)。
+ 这一步之后 VBUS 才真的由固件说了算。 */
+ hal_err_t e = slot_init(rail);
+ if (e != HAL_OK) {
+ r->io_lost |= bit_of(rail);
+ lost(rail, PCA9555_ADDR, e);
+ }
+ uint8_t addr = rail_cfg(rail)->ina_addr;
+ e = i2cbus_xact(slot_mux_ch(rail), BRINGUP_LOCK_MS, ina_up_fn, &addr);
+ if (e != HAL_OK) {
+ r->blind |= bit_of(rail);
+ lost(rail, addr, e);
+ }
+ }
+ return HAL_OK;
+}
+
+/* ---- 第二段 -------------------------------------------------------------- */
+
+typedef struct {
+ int rail;
+ pca9555_in_t in;
+ ina226_vi_t vi;
+} probe_t;
+
+static hal_err_t read_io_fn(void *arg)
+{
+ probe_t *p = (probe_t *)arg;
+ return slot_read_inputs_locked(p->rail, &p->in);
+}
+
+static hal_err_t read_vi_fn(void *arg)
+{
+ probe_t *p = (probe_t *)arg;
+ return ina226_read_vi(rail_cfg(p->rail)->ina_addr, &p->vi);
+}
+
+typedef enum { PG_WAIT, PG_OK, PG_BLIND } pg_t;
+
+/* 这一路的电源好了没有。优先看 PCA9555 的 PGOOD;读不到就退到 INA226 的电压
+ (§10.10 PCA9555 那一行「改用 INA226 的电压判据兜底」);两样都读不到 = 看不见。 */
+static pg_t pg_of(int rail, const bringup_report_t *r, uint8_t *arg)
+{
+ probe_t p = { .rail = rail };
+ if ((r->io_lost & bit_of(rail)) == 0 &&
+ i2cbus_xact(slot_mux_ch(rail), BRINGUP_LOCK_MS, read_io_fn, &p) == HAL_OK) {
+ *arg = pca9555_efuse_fault(&p.in) ? BRINGUP_ARG_FLT : 0u;
+ return pca9555_efuse_pg(&p.in) ? PG_OK : PG_WAIT;
+ }
+ *arg = BRINGUP_ARG_BY_VOLT;
+ if ((r->blind & bit_of(rail)) == 0 &&
+ i2cbus_xact(slot_mux_ch(rail), BRINGUP_LOCK_MS, read_vi_fn, &p) == HAL_OK) {
+ return (p.vi.bus_mv >= rail_setpoint_mv(rail) - BRINGUP_PG_VOLT_MARGIN_MV) ? PG_OK
+ : PG_WAIT;
+ }
+ return PG_BLIND;
+}
+
+uint8_t bringup_want_update(uint8_t want, int rail, bool on)
+{
+ if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) {
+ return want;
+ }
+ return on ? (uint8_t)(want | bit_of(rail)) : (uint8_t)(want & ~bit_of(rail));
+}
+
+void bringup_power_restore(uint8_t want, bringup_report_t *r)
+{
+ assert(r != NULL);
+ const uint8_t all = (uint8_t)((1u << PHONERAIL_RAIL_COUNT) - 1u);
+ want &= all;
+ r->want = want;
+ r->on = 0;
+ r->pg_fail = 0;
+ r->unverified = 0;
+
+ /* 能开的:想开、在位、mux 在。空槽且想开 = 刀片被拔走了(或 PRSNT# 虚接),记一条。 */
+ uint8_t todo = 0;
+ for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
+ if ((want & bit_of(rail)) == 0) {
+ continue;
+ }
+ if ((r->empty & bit_of(rail)) != 0) {
+ evlog_put((uint8_t)rail, LOG_EV_SLOT_EMPTY, 0, 0, 0);
+ continue;
+ }
+ if ((r->deg & BRINGUP_DEG_MUX) != 0) {
+ continue; /* §5.8:mux 失联时五路电压电流全看不见 ⇒ 一路也不开 */
+ }
+ todo |= bit_of(rail);
+ }
+
+ uint8_t pending = 0;
+ uint32_t last_on = 0;
+ bool any_on = false;
+ int next = 1;
+
+ while (todo != 0 || pending != 0) {
+ const uint32_t now = hal_time_now_ms();
+
+ /* §10.9 第 5 步:到点了就开下一路。**按上一路 EN 抬高的时刻算**,
+ 不按「上一路 PG 来了没有」算 —— 后者会让一路慢的拖住后面四路。 */
+ if (todo != 0 && (!any_on || hal_time_elapsed_ms(last_on) >= BRINGUP_STAGGER_MS)) {
+ while ((todo & bit_of(next)) == 0) {
+ next++;
+ }
+ const int rail = next;
+ todo &= (uint8_t)~bit_of(rail);
+ if (rail_efuse_enable(rail, true) == HAL_OK) {
+ r->on_ms[rail - 1] = now;
+ r->on |= bit_of(rail);
+ pending |= bit_of(rail);
+ last_on = now;
+ any_on = true;
+ }
+ }
+
+ /* 第 6 步:每一路各自的 500 ms。 */
+ for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
+ if ((pending & bit_of(rail)) == 0) {
+ continue;
+ }
+ uint8_t arg = 0;
+ const pg_t pg = pg_of(rail, r, &arg);
+ if (pg == PG_OK) {
+ pending &= (uint8_t)~bit_of(rail);
+ } else if (pg == PG_BLIND) {
+ /* 看不见 ≠ 坏了(`ACT_RES_NO_PG` 同一条理由):开着,报「确认不了」。 */
+ pending &= (uint8_t)~bit_of(rail);
+ r->unverified |= bit_of(rail);
+ } else if (hal_time_elapsed_ms(r->on_ms[rail - 1]) >= BRINGUP_PG_TIMEOUT_MS) {
+ /* 等不到:关回去(§10.9 原文)。VBUS 在这一段里从没开过,只关 eFuse。 */
+ (void)rail_efuse_enable(rail, false);
+ pending &= (uint8_t)~bit_of(rail);
+ r->on &= (uint8_t)~bit_of(rail);
+ r->pg_fail |= bit_of(rail);
+ evlog_put((uint8_t)rail, LOG_EV_PG_TIMEOUT, arg, 0, 0);
+ }
+ }
+
+ if (todo != 0 || pending != 0) {
+ hal_time_delay_ms(BRINGUP_PG_POLL_MS);
+ }
+ }
+}
diff --git a/firmware/main/bringup.h b/firmware/main/bringup.h
new file mode 100644
index 0000000..82d825f
--- /dev/null
+++ b/firmware/main/bringup.h
@@ -0,0 +1,130 @@
+/* `bringup` —— 启动顺序 + 降级矩阵(A15,需求 §10.9 / §10.10)。
+
+ A14 之前这一段整个写在 `app_main.c` 里,而 `app_main.c` 只进 IDF 目标 ——
+ 于是像 `ina226_set_reversed(INA226_ADDR_INPUT, true)` 这种「漏了就把正常供电
+ 判成反灌」的单行,**唯一的拦网是人眼**(计划 A15 那一行的 ⭐)。
+ 现在整段都在这里,宿主机上逐个「拔掉」器件跑得到。
+
+ ---- 分两段,中间让 `t_safety` 起来 --------------------------------------
+
+ bringup_devices() §10.9 第 1~4 步:引脚安全态 → 总线 → 认件 / 初始化
+ (缺件按 §10.10 降级,**不 abort**)
+ ── app_main 在这里 spawn(TASK_SAFETY) ──
+ bringup_power_restore() §10.9 第 5~6 步:按上次的意图逐路上电、错开 100 ms、等 PG
+
+ 拆开的理由:上电那一刻巡检必须已经在跑。§10.9 原文把「起链路任务」放在第 7 步,
+ 但没说巡检什么时候起;若等五路全开完(最坏 0.4 s 错开 + 0.5 s 等 PG)才起,
+ 这将近一秒里 SR-5 / SW-8 是瞎的。两段之间锁是通的(一切都经 `i2cbus_xact`),
+ 所以巡检与上电序列并发没有问题。
+
+ ---- 分界线:什么 abort、什么降级 -----------------------------------------
+ 与 `selftest_or_die()` 同一条线:**固件自己错了 = 返回错误码(app_main abort),
+ 器件不在 = 降级、记事件、接着起**。前者只有三种:EN 脚配不上、I²C 外设起不来、
+ `PRSNT#` 脚配不上 —— 全是引脚号 / 配置常量的问题,换块板子也一样。
+
+ ---- §10.10 各行在这里怎么落地 -----------------------------------------
+ 某片 INA226 无应答 → 该路照常上电,`blind` 置位,记 I2C_LOST;
+ 判据自己会因为 `rail_ok` 为假而不武装(t_safety.c 的三道闸)
+ DAC 无应答 → 不关输出,设定值影子回到 NVM 默认(3.90 V),记 I2C_LOST
+ 某片 PCA9555 无应答 → 不关输出(照常上电),`io_lost` 置位,记 I2C_LOST;
+ 等 PG 改按 INA226 电压判
+ mux 连续选不中 → 先脉冲一次 `MUX_RST#`;仍不通 ⇒ 五路一路都不上电,记 MUX_LOST
+ `PRSNT#` 为高 → 不初始化、不上电;只在「上次是开着的」时记 SLOT_EMPTY
+ CH224A 读不到 / 偏低 → 不关输出,按读到的档位(读不到按 60 W)定上限,记 PD_LIMIT
+ 看门狗复位 → app_main 把复位原因填进 BOOT 事件的 arg(evlog_init_ex)
+ I²C 总线挂死 → ⚠️ **本包没做**(9 个时钟的总线恢复在 HAL 层,见 HANDOFF) */
+#ifndef BRINGUP_H
+#define BRINGUP_H
+
+#include
+#include
+
+#include "hal_err.h"
+#include "phonerail.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* §10.9 第 5 步:路与路之间错开多久。单路缓启动涌流 0.8 A,五路同开是 4 A 叠在
+ 12 V 输入上;错开 100 ms 后各路 11.5 ms 的斜坡完全不重叠。**是必须,不是讲究**。 */
+#define BRINGUP_STAGGER_MS 100
+
+/* §10.9 第 6 步:开了电之后等 PG 最多等多久。与硬重启的 `ACT_HR_PG_TIMEOUT_MS`
+ 同一个数(PG 在斜坡中段就该来,~30 倍余量)。⚠️ 与硬重启**处理不同**:
+ 硬重启等不到 PG 不断电(那是运行中的手机),启动时等不到就关回去(§10.9 原文)。
+ —— 但「PCA9555 读不到」不算「等不到」,那是看不见,见 `BRINGUP_ARG_BY_VOLT`。 */
+#define BRINGUP_PG_TIMEOUT_MS 500
+
+/* 等 PG 期间多久看一眼。ESP 上 `hal_time_delay_ms()` 至少一个 tick(10 ms),
+ 所以实际是 10 ms;错开的 100 ms 因此会落在 100~110 ms 之间。 */
+#define BRINGUP_PG_POLL_MS 5
+
+/* PCA9555 读不到时的 PG 替身:INA226 母线电压到了「设定值 − 这个数」就算好了。
+ 与 SR-5 的 ±100 mV 同一个数 —— 到了这个电压判据就不会再判它欠压。 */
+#define BRINGUP_PG_VOLT_MARGIN_MV 100
+
+/* CH224A 读不到 / 读到 0 时按这个电流算上限(20 V × 3 A = 60 W,§10.10「最保守的 60 W」)。 */
+#define BRINGUP_PD_FALLBACK_MA 3000
+
+/* `LOG_EV_PG_TIMEOUT` 的 arg 位。 */
+#define BRINGUP_ARG_FLT 0x01u /* 超时那一刻 FLT# 在报 */
+#define BRINGUP_ARG_BY_VOLT 0x02u /* 这一路 PCA9555 读不到,是按 INA226 电压判的 */
+
+/* 整板级降级位(`bringup_report_t.deg`)。 */
+typedef enum {
+ BRINGUP_DEG_MUX = 1u << 0, /* mux 复位后仍选不中:五路不上电 */
+ BRINGUP_DEG_DAC_U6 = 1u << 1, /* 0x4A 不应答:CH1/2/4 停在 NVM 默认 */
+ BRINGUP_DEG_DAC_U7 = 1u << 2, /* 0x4B 不应答:CH3/5 停在 NVM 默认 */
+ BRINGUP_DEG_DAC_NVM = 1u << 3, /* 上电复位时读回的码不是 3.90 V(NVM 被改过) */
+ BRINGUP_DEG_INPUT_INA = 1u << 4, /* 输入侧 0x43 不应答:整板功率看不见 */
+ BRINGUP_DEG_PD = 1u << 5, /* CH224A 读不到:按 60 W */
+ BRINGUP_DEG_PD_LOW = 1u << 6, /* CH224A 读到了,但低于 60 W */
+} bringup_deg_t;
+
+typedef struct {
+ /* 这次复位是不是上电复位。只有它为真才脉冲 `HUB_RST#`(app_main 的那一大段理由:
+ 其余复位原因下脉冲 = 自己把自己的 USB 控制台拔了),也只有它为真才核对
+ DAC 的 NVM 默认值(热重启时 DAC 没断过电,里面是上一次命令设的值)。 */
+ bool power_on_reset;
+} bringup_in_t;
+
+typedef struct {
+ uint32_t deg; /* bringup_deg_t 的或 */
+
+ /* 每路一位,bit0 = CH1。 */
+ uint8_t empty; /* `PRSNT#` 高:刀片不在位 */
+ uint8_t blind; /* 刀片 INA226 不应答:失去遥测、判据不武装(「盲跑」) */
+ uint8_t io_lost; /* 刀片 PCA9555 不应答:FAULT/PG 看不见,VBUS 关不掉 */
+
+ /* 第二段填。 */
+ uint8_t want; /* 请求恢复的(上次的意图) */
+ uint8_t on; /* 最后真的开着的 */
+ uint8_t pg_fail; /* 等不到 PG、已关回去 */
+ uint8_t unverified; /* PCA9555 与 INA226 都读不到:开着,但确认不了 */
+ uint32_t on_ms[PHONERAIL_RAIL_COUNT]; /* 每路 EN 抬高的时刻(没开是 0) */
+
+ uint32_t pd_ma; /* CH224A 读到的档位电流(读不到是 0) */
+ uint32_t pd_limit_mw; /* 整板功率上限(读不到按 60 W) */
+ uint16_t dac_nvm_mv[PHONERAIL_RAIL_COUNT]; /* 上电复位时读回的每路设定(没读是 0) */
+} bringup_report_t;
+
+/* §10.9 第 1~4 步。返回非 HAL_OK **只**表示固件 / 引脚配置本身错了(见文件头),
+ 调用方应当 abort;器件缺失一律 HAL_OK + `r` 里的降级位 + 事件。
+ ⚠️ 调之前 `evlog_init_ex()` 要已经跑过:这里产生的事件要排在 BOOT 那一条之后。 */
+hal_err_t bringup_devices(const bringup_in_t *in, bringup_report_t *r);
+
+/* §10.9 第 5~6 步。`want` 是上次的每路意图(bit0 = CH1,来自 NVS;新板是 0 = 全不开)。
+ 逐路:能开的开、错开 `BRINGUP_STAGGER_MS`、各自等 PG(`BRINGUP_PG_TIMEOUT_MS`),
+ 等不到就关回去并记 `LOG_EV_PG_TIMEOUT`。阻塞,最坏约 0.4 + 0.5 s。
+ 不能开的(空槽 / mux 失联)直接跳过,空槽且想开的记一条 SLOT_EMPTY。 */
+void bringup_power_restore(uint8_t want, bringup_report_t *r);
+
+/* 「意图」掩码的更新(`power` 命令改它,app_main 把结果存 NVS)。纯函数。 */
+uint8_t bringup_want_update(uint8_t want, int rail, bool on);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* BRINGUP_H */
diff --git a/firmware/main/cmd.h b/firmware/main/cmd.h
index 13918d9..7a2ccc3 100644
--- a/firmware/main/cmd.h
+++ b/firmware/main/cmd.h
@@ -102,9 +102,18 @@ typedef enum {
/* 请求行的最大长度(含结尾 0)。超长的行由适配器整行丢弃(A13 的分帧规矩)。 */
#define CMD_LINE_MAX 256
+/* `power` 命令改了某一路的「意图」(A15)。启动时按这个意图逐路恢复(§10.9 第 4 步
+ 「上次的每路使能状态」),所以 app_main 在这里把它存进 NVS。
+ ⚠️ 只有 `power` 命令改意图:判据跳闸、硬重启、mux 失联全断都**不改** ——
+ 那些是事故,不是操作员的决定;重启之后按意图恢复,跳闸的那一路再由判据管。
+ 在执行器任务里同步调(可以写 flash,毫秒级,命令本来就稀)。 */
+typedef void (*cmd_intent_fn)(void *ctx, int rail, bool on);
+
typedef struct {
const safety_snap_t *safety; /* NULL = status 里不报这一块 */
const thermal_snap_t *thermal; /* NULL = 同上 */
+ cmd_intent_fn intent; /* NULL = 不记意图(宿主机测试) */
+ void *intent_ctx;
} cmd_env_t;
/* 清判重窗口、记下两份快照的位置。幂等。
diff --git a/firmware/main/cmd_exec.c b/firmware/main/cmd_exec.c
index bdcc5c1..7456015 100644
--- a/firmware/main/cmd_exec.c
+++ b/firmware/main/cmd_exec.c
@@ -199,6 +199,15 @@ cmd_err_t do_vbus(ctx_t *c)
return from_hal(slot_vbus_enable(c->rail, on));
}
+/* A15:意图出口(cmd.h 的 `cmd_intent_fn`)。 */
+static void note_intent(int rail, bool on)
+{
+ const cmd_env_t *env = cmd_env();
+ if (env != NULL && env->intent != NULL) {
+ env->intent(env->intent_ctx, rail, on);
+ }
+}
+
cmd_err_t do_power(ctx_t *c)
{
cmd_err_t e = need_rail(c);
@@ -213,13 +222,20 @@ cmd_err_t do_power(ctx_t *c)
return CMD_ERR_BUSY; /* 硬重启走到一半再开关一次是自作主张(t_actuator_abort 同理) */
}
if (on) {
- return from_hal(rail_efuse_enable(c->rail, true));
+ const hal_err_t ee = rail_efuse_enable(c->rail, true);
+ if (ee == HAL_OK) {
+ note_intent(c->rail, true);
+ }
+ return from_hal(ee);
}
/* 关:与 `rail_all_off()` 同序 —— **先断 VBUS 再关 eFuse**。
VBUS 走 I²C 可能断不掉,eFuse 是 GPIO 永远关得掉:前者失败也照样关后者,
回应报 io,上位机据此知道「直供断了,VBUS 可能还通着」。 */
const hal_err_t ev = slot_vbus_enable(c->rail, false);
const hal_err_t ee = rail_efuse_enable(c->rail, false);
+ if (ee == HAL_OK) {
+ note_intent(c->rail, false); /* 直供确实断了:下次开机也别开它 */
+ }
return from_hal(ee != HAL_OK ? ee : ev);
}
diff --git a/firmware/main/evlog.c b/firmware/main/evlog.c
index 62c14e2..f5de915 100644
--- a/firmware/main/evlog.c
+++ b/firmware/main/evlog.c
@@ -437,7 +437,9 @@ static hal_err_t format_all(void)
return HAL_OK;
}
-hal_err_t evlog_init(void)
+hal_err_t evlog_init(void) { return evlog_init_ex(0); }
+
+hal_err_t evlog_init_ex(uint8_t boot_arg)
{
/* RAM 侧无条件清干净:真板上这里刚上电,宿主机上这条让 `sim_hal_reboot()`
之后的行为和真重启一致(statics 在进程里是活着的)。 */
@@ -465,7 +467,7 @@ hal_err_t evlog_init(void)
if (err != HAL_OK) {
/* 没有分区。**不 abort、不影响入队**:日志是排查手段,不是安全链
(evlog.h 上面那段)。事件照进 RAM,只是没人能把它们写下去。 */
- evlog_put(0, LOG_EV_BOOT, 0, 0, 0);
+ evlog_put(0, LOG_EV_BOOT, boot_arg, 0, 0);
return err;
}
@@ -474,7 +476,7 @@ hal_err_t evlog_init(void)
if (g.sector_bytes < 2u * sizeof(evlog_rec_t) ||
(g.sector_bytes % sizeof(evlog_rec_t)) != 0u || g.sectors < 2u) {
s_have_store = false;
- evlog_put(0, LOG_EV_BOOT, 0, 0, 0);
+ evlog_put(0, LOG_EV_BOOT, boot_arg, 0, 0);
return HAL_ERR_STATE;
}
s_sector_bytes = g.sector_bytes;
@@ -525,7 +527,7 @@ hal_err_t evlog_init(void)
/* §10.7「上电从 0 计」⇒ 时间戳会跳回去。没有这一条的话,重启前后的记录
在时间轴上互相穿插,而日志里看不出重启发生过。 */
- evlog_put(0, LOG_EV_BOOT, 0, 0, 0);
+ evlog_put(0, LOG_EV_BOOT, boot_arg, 0, 0);
return s_have_store ? HAL_OK : HAL_ERR_STATE;
}
@@ -569,6 +571,7 @@ const char *evlog_ev_str(log_ev_t ev)
case LOG_EV_MUX_LOST: return "mux失联全断";
case LOG_EV_SLOT_EMPTY: return "刀片不在位";
case LOG_EV_PD_LIMIT: return "PD限功率";
+ case LOG_EV_PG_TIMEOUT: return "上电等不到PG";
case LOG_EV_COUNT: break;
}
return "?";
diff --git a/firmware/main/evlog.h b/firmware/main/evlog.h
index 0f014d8..1437856 100644
--- a/firmware/main/evlog.h
+++ b/firmware/main/evlog.h
@@ -72,6 +72,11 @@ typedef enum {
LOG_EV_SLOT_EMPTY = 15,/* `PRSNT#` 为高:刀片不在位,这一路不许带电 */
LOG_EV_PD_LIMIT = 16, /* PD 档位换了 / 触发限功率降载,bus_mv = 上限 mW/100(R-6) */
+ /* A15 新增(需求 §10.9 第 6 步「等各路 PG,超时则记事件并保持该路关闭」)。
+ 与 `PG_CHANGE`(8)不是一回事:那是「PG 变了」,这是「开了电、PG 一直没来、已经关回去」。
+ arg:bit0 = 超时那一刻 FLT# 在报;bit1 = 这一片 PCA9555 读不到,改按 INA226 电压判的。 */
+ LOG_EV_PG_TIMEOUT = 17,
+
LOG_EV_COUNT,
} log_ev_t;
@@ -123,6 +128,11 @@ typedef struct {
这种情况下入队照常工作(事件留在 RAM 里),只是落不了盘。 */
hal_err_t evlog_init(void);
+/* 同上,只是 `LOG_EV_BOOT` 那一条的 arg 由调用方给(A15:ESP 上填 `esp_reset_reason()`,
+ §10.10「看门狗复位 … 记一条复位事件」就是这一条 —— 不另开事件号,
+ 因为「这次上电是怎么来的」本来就是 BOOT 的属性)。`evlog_init()` = `evlog_init_ex(0)`。 */
+hal_err_t evlog_init_ex(uint8_t boot_arg);
+
/* 入队一条。**不阻塞、不碰 flash、不会失败** —— 缓冲满了就盖掉最旧的一条
并记一次 `dropped`(下一次落盘时会补一条 LOST)。
任何任务、任何优先级都可以调,包括 t_safety 断电之后的那一下。 */
diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h
index 18f1c85..cb7bd4a 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-a14"
+#define PHONERAIL_FW_VERSION "0.1.0-a15"
/* 通道数。五路直供,见设计文档 §2。 */
#define PHONERAIL_RAIL_COUNT 5
diff --git a/firmware/main/rail.c b/firmware/main/rail.c
index f18e9eb..a5cac13 100644
--- a/firmware/main/rail.c
+++ b/firmware/main/rail.c
@@ -46,23 +46,28 @@ int32_t rail_code_to_vd_uv(uint16_t code)
return dac63204_code_to_uv(code);
}
+hal_err_t rail_volt_init_one(uint8_t addr)
+{
+ hal_err_t err = dac63204_probe(addr);
+ if (err != HAL_OK) {
+ return err;
+ }
+ /* init 自己就把用到的通道写成 safe 了(上电之前写,见 dac63204_init 的 ③)。 */
+ return dac63204_init(addr, rail_mv_to_code(RAIL_MV_SAFE));
+}
+
hal_err_t rail_volt_init(void)
{
- const uint16_t safe = rail_mv_to_code(RAIL_MV_SAFE);
-
+ /* ⚠️ 一片失败**不放弃另一片**(A15):U6 不应答时 U7 那两路照样该被初始化,
+ 而不是也停在 NVM 默认值上 —— 那样一片坏件就「降级」了五路。 */
+ hal_err_t first = HAL_OK;
for (int i = 0; i < DAC63204_COUNT; i++) {
- uint8_t addr = dac63204_all_addr[i];
- hal_err_t err = dac63204_probe(addr);
- if (err != HAL_OK) {
- return err;
- }
- /* init 自己就把用到的通道写成 safe 了(上电之前写,见 dac63204_init 的 ③)。 */
- err = dac63204_init(addr, safe);
- if (err != HAL_OK) {
- return err;
+ const hal_err_t err = rail_volt_init_one(dac63204_all_addr[i]);
+ if (err != HAL_OK && first == HAL_OK) {
+ first = err;
}
}
- return HAL_OK;
+ return first;
}
/* 五路设定值的影子(契约见 rail.h)。初值就是 DAC 的 NVM 出厂默认,
diff --git a/firmware/main/rail.h b/firmware/main/rail.h
index 2eb9e60..5e768e1 100644
--- a/firmware/main/rail.h
+++ b/firmware/main/rail.h
@@ -57,9 +57,13 @@ int32_t rail_code_to_vd_uv(uint16_t code); /* 码 → DAC 自己的输出 Vd(
/* ---- 器件操作 ------------------------------------------------------------ */
/* 认件 + 初始化两片 DAC,并把五路都置到 RAIL_MV_SAFE。
- 任何一片失败就返回它的错误码(降级矩阵在 A15 处理)。 */
+ 两片都会试;返回第一个错误码(降级矩阵在 bringup.c)。 */
hal_err_t rail_volt_init(void);
+/* 只做其中一片(`dac63204_all_addr[]` 里的地址)。A15 的启动序列按片调它,
+ 好分别记「哪一片没应答」(§10.10 DAC 那一行)。两片的失败互不影响。 */
+hal_err_t rail_volt_init_one(uint8_t addr);
+
/* rail 是 **1~5**(不是 0~4),越界 → HAL_ERR_ARG。
mv > 4010 → HAL_ERR_ARG(SR-13:越界即拒,让操作者早点知道自己写错了)。
mv < 3524 → HAL_ERR_ARG(DAC 到不了)。
diff --git a/firmware/test/test_bringup.c b/firmware/test/test_bringup.c
new file mode 100644
index 0000000..24e54b7
--- /dev/null
+++ b/firmware/test/test_bringup.c
@@ -0,0 +1,597 @@
+/* A15:启动顺序 + 降级矩阵(需求 §10.9 / §10.10,计划 A15 那一行)。
+
+ 验收原话:「逐个『拔掉』器件,行为与 §10.10 的表逐行对上;五路上电错开 100 ms;
+ `HUB_RST` 只脉冲一次」,外加 ⭐ 第一条用例必须是 `ina226_set_reversed()` 那一行。
+
+ ---- 这块假板子和别的测试有什么不一样 ------------------------------------
+ · **PGOOD 跟着 EN 走**:`sim_gpio_set_hook()` 让某一路 `EFUSE_EN` 抬高的那一刻,
+ 那一片 PCA9555 的 P1.3 跟着变高。没有这一条,「等 PG」要么永远等到(PG 静态拉高,
+ 测不出超时),要么永远等不到。
+ · 时刻一律从 `sim_gpio_rise_us()` 取(线上真实的边),**不信** `bringup_report_t`
+ 自己报的 `on_ms` —— 那是自己证明自己。
+ · 事件从 flash 读回来数(evlog 的真路径),不在被测代码里插探针。 */
+#include
+
+#include "board_pins.h"
+#include "bringup.h"
+#include "ch224a.h"
+#include "dac63204.h"
+#include "evlog.h"
+#include "hal_gpio.h"
+#include "i2cbus.h"
+#include "ina226.h"
+#include "pca9555.h"
+#include "rail.h"
+#include "rail_cfg.h"
+#include "sim_hal.h"
+#include "slot.h"
+#include "tca9548a.h"
+#include "unity.h"
+
+#define N PHONERAIL_RAIL_COUNT
+#define ALL ((uint8_t)((1u << N) - 1u))
+#define BIT(rail) ((uint8_t)(1u << ((rail) - 1)))
+
+/* Port1 静息:FLT# 高(无故障)、Buck PG 高、ALERT 高、**eFuse PG 低**(还没上电)。 */
+#define P1_IDLE ((uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_EFUSE_PG)))
+#define P1_PG ((uint8_t)0x0Fu)
+
+static bool g_pg_follows[N]; /* false = 这一路的 PG 永远不来 */
+static bool g_flt[N]; /* true = 这一路的 FLT# 在报 */
+static bool g_pca_gone[N]; /* 这一片被拔了:钩子别再去驱它 */
+
+static bringup_report_t g_r;
+static bool g_pd_on_trunk = true; /* false = board() 不在主干上挂 CH224A */
+
+static int ch_of(int rail) { return (int)rail_cfg(rail)->mux_ch; }
+
+/* 外部世界:EN 一抬高 PG 就来(真板上是 ~17 ms 之后;这里早来晚来只改变「等了几圈」)。 */
+static void world(int pin, int level)
+{
+ for (int rail = 1; rail <= N; rail++) {
+ if (pin != rail_cfg(rail)->efuse_en || g_pca_gone[rail - 1]) {
+ continue;
+ }
+ uint8_t v = (level != 0 && g_pg_follows[rail - 1]) ? P1_PG : P1_IDLE;
+ if (g_flt[rail - 1]) {
+ v &= (uint8_t)~(1u << PCA9555_BIT_EFUSE_FLT);
+ }
+ sim_pca9555_drive(rail - 1, 1, v);
+ }
+}
+
+static void put_ina_ids(int ch, uint8_t addr)
+{
+ if (ch < 0) {
+ sim_i2c_set_reg(addr, INA226_REG_MFG_ID, INA226_MFG_ID);
+ sim_i2c_set_reg(addr, INA226_REG_DIE_ID, INA226_DIE_ID);
+ } else {
+ sim_i2c_set_reg_ch(ch, addr, INA226_REG_MFG_ID, INA226_MFG_ID);
+ sim_i2c_set_reg_ch(ch, addr, INA226_REG_DIE_ID, INA226_DIE_ID);
+ }
+}
+
+/* 满配、五槽全插的假板子。各用例在 `bringup_devices()` 之前「拔掉」自己关心的那一个。 */
+static void board(void)
+{
+ sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N);
+
+ for (int i = 0; i < DAC63204_COUNT; i++) {
+ const uint8_t a = dac63204_all_addr[i];
+ sim_i2c_add(a, 2);
+ sim_i2c_set_reg(a, DAC63204_REG_GENERAL_STATUS, (uint16_t)(DAC63204_DEVICE_ID << 2));
+ sim_i2c_set_reg(a, DAC63204_REG_COMMON_CONFIG, 0x0FFF);
+ }
+ /* NVM 默认:五路都是 3.90 V(上电复位时读回来比对的就是它)。 */
+ for (int rail = 1; rail <= N; rail++) {
+ sim_i2c_set_reg(dac63204_rail_addr(rail), DAC63204_REG_DATA(dac63204_rail_ch(rail)),
+ DAC63204_CODE_TO_REG(rail_mv_to_code(RAIL_MV_SAFE)));
+ }
+
+ sim_i2c_add(INA226_ADDR_INPUT, 2);
+ put_ina_ids(-1, INA226_ADDR_INPUT);
+ sim_i2c_set_reg(INA226_ADDR_INPUT, INA226_REG_BUS_V, (uint16_t)(12000 * 4 / 5));
+
+ /* CH224A:实测在主干(PROGRESS 顶部),20 V / 3 A 档 = 60 W。 */
+ if (g_pd_on_trunk) {
+ sim_i2c_add(CH224A_ADDR_DEFAULT, 1);
+ sim_i2c_set_reg(CH224A_ADDR_DEFAULT, CH224A_REG_CURRENT, 3000 / 50);
+ }
+
+ for (int rail = 1; rail <= N; rail++) {
+ const int ch = ch_of(rail);
+ sim_i2c_add_ch(ch, rail_cfg(rail)->ina_addr, 2);
+ put_ina_ids(ch, rail_cfg(rail)->ina_addr);
+ sim_pca9555_attach_ch(rail - 1, ch, PCA9555_ADDR);
+ sim_pca9555_drive(rail - 1, 1, P1_IDLE);
+ sim_gpio_drive(rail_cfg(rail)->prsnt_n, 0); /* 插着:刀片把 PRSNT# 短到地 */
+ g_pg_follows[rail - 1] = true;
+ g_flt[rail - 1] = false;
+ g_pca_gone[rail - 1] = false;
+ }
+ sim_gpio_set_hook(world);
+}
+
+static void pull_slot(int rail) /* 整片刀片拔走:PRSNT# 高 + 两片器件都不在 */
+{
+ sim_gpio_drive(rail_cfg(rail)->prsnt_n, 1);
+ sim_i2c_set_present_ch(ch_of(rail), rail_cfg(rail)->ina_addr, false);
+ sim_i2c_set_present_ch(ch_of(rail), PCA9555_ADDR, false);
+ g_pca_gone[rail - 1] = true;
+}
+
+static void devices(bool por)
+{
+ const bringup_in_t in = { .power_on_reset = por };
+ TEST_ASSERT_EQUAL_INT(HAL_OK, bringup_devices(&in, &g_r));
+}
+
+/* 某类事件(可限定路号,-1 = 不限)在 flash 里有几条。 */
+static int count_ev(log_ev_t type, int rail)
+{
+ (void)evlog_flush(0);
+ evlog_stats_t st;
+ evlog_stats(&st);
+ int n = 0;
+ evlog_rec_t rec;
+ for (uint32_t seq = st.oldest_seq; seq < st.next_seq; seq++) {
+ if (evlog_read(seq, &rec, 1) != 1) {
+ break;
+ }
+ if (rec.type == (uint8_t)type && (rail < 0 || rec.rail == (uint8_t)rail)) {
+ n++;
+ }
+ }
+ return n;
+}
+
+static bool last_ev(log_ev_t type, evlog_rec_t *out)
+{
+ (void)evlog_flush(0);
+ evlog_stats_t st;
+ evlog_stats(&st);
+ bool hit = false;
+ evlog_rec_t rec;
+ for (uint32_t seq = st.oldest_seq; seq < st.next_seq; seq++) {
+ if (evlog_read(seq, &rec, 1) == 1 && rec.type == (uint8_t)type) {
+ *out = rec;
+ hit = true;
+ }
+ }
+ return hit;
+}
+
+void setUp(void)
+{
+ sim_hal_reset();
+ pca9555_shadow_reset();
+ rail_setpoint_reset();
+ ina226_set_reversed(INA226_ADDR_INPUT, false); /* 进程里是活的:清掉上一条用例留下的 */
+ board();
+ TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init_ex(0));
+}
+
+void tearDown(void) { TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); }
+
+/* ---- ⭐ 第一条:输入侧 INA226 的对调接 ------------------------------------ */
+
+/* 计划 A15 的 ⭐:这一行以前只在 `app_main.c` 里,唯一的拦网是人眼。
+ 板上 U13 的 IN+/IN− 对调 ⇒ 正常供电时芯片自己读出的电流是**负**的;
+ 启动之后驱动必须把它翻回来,否则整板功率与反灌判据全拿的是反号。
+ ⚠️ 断的是「读出来的电流是正的」这个**后果**,不只是那个标志位。 */
+static void test_first_input_ina_is_marked_reversed(void)
+{
+ /* 12 V × 2 A 正常供电,芯片按对调接读出 −2 A。 */
+ sim_i2c_set_reg(INA226_ADDR_INPUT, INA226_REG_CURRENT, (uint16_t)(int16_t)(-2000000 / 500));
+ TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL));
+
+ ina226_vi_t vi;
+ TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_vi(INA226_ADDR_INPUT, &vi));
+ TEST_ASSERT_TRUE_MESSAGE(vi.current_ua < 0, "前提:不登记对调时读出来是负的");
+
+ devices(true);
+
+ TEST_ASSERT_TRUE(ina226_is_reversed(INA226_ADDR_INPUT));
+ TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_vi(INA226_ADDR_INPUT, &vi));
+ TEST_ASSERT_EQUAL_INT32(2000000, vi.current_ua);
+ /* 刀片上那五片是正接的,别被一起翻了。 */
+ TEST_ASSERT_FALSE(ina226_is_reversed(INA226_ADDR_RAIL));
+}
+
+/* ---- 满配:什么都在 ------------------------------------------------------ */
+
+/* 六片 INA226 都写了 Config / Calibration。A14 之前这一步**根本没做**
+ (app_main 里那句「在那之前 t_safety 读回来的电流恒为 0」)。 */
+static void test_full_board_initialises_every_device(void)
+{
+ devices(true);
+
+ TEST_ASSERT_EQUAL_UINT32(0, g_r.deg);
+ TEST_ASSERT_EQUAL_UINT8(0, g_r.empty | g_r.blind | g_r.io_lost);
+ TEST_ASSERT_EQUAL_UINT16(INA226_CALIB, sim_i2c_get_reg(INA226_ADDR_INPUT, INA226_REG_CALIB));
+ TEST_ASSERT_EQUAL_UINT16(INA226_CONFIG, sim_i2c_get_reg(INA226_ADDR_INPUT, INA226_REG_CONFIG));
+ for (int rail = 1; rail <= N; rail++) {
+ TEST_ASSERT_EQUAL_UINT16(INA226_CALIB, sim_i2c_get_reg_ch(ch_of(rail), INA226_ADDR_RAIL,
+ INA226_REG_CALIB));
+ /* PCA9555 按板级方向字配好,锁存器全 0(键全松、VBUS 断)。 */
+ TEST_ASSERT_EQUAL_UINT8(PCA9555_CONFIG0_INIT, sim_pca9555_config(rail - 1, 0));
+ TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(rail - 1, 0));
+ TEST_ASSERT_EQUAL_UINT16(RAIL_MV_SAFE, (uint16_t)g_r.dac_nvm_mv[rail - 1]);
+ /* 第一段一路电都不上。 */
+ TEST_ASSERT_FALSE(rail_efuse_is_on(rail));
+ TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(rail)->efuse_en));
+ }
+ TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_unsafe_enable());
+ TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven());
+ TEST_ASSERT_EQUAL_UINT32(60000, g_r.pd_limit_mw);
+ TEST_ASSERT_EQUAL_INT(0, count_ev(LOG_EV_I2C_LOST, -1));
+ TEST_ASSERT_EQUAL_INT(1, count_ev(LOG_EV_PD_LIMIT, 0));
+}
+
+/* ⭐ §10.9 第 5 步:五路上电**错开 100 ms**,按 CH1→CH5 的顺序。
+ 时刻取线上的边,不取被测代码自报的 `on_ms`。上界 = 100 ms + 一次轮询 + 总线时间。 */
+static void test_five_rails_come_up_100ms_apart(void)
+{
+ devices(true);
+ bringup_power_restore(ALL, &g_r);
+
+ TEST_ASSERT_EQUAL_UINT8(ALL, g_r.on);
+ TEST_ASSERT_EQUAL_UINT8(0, g_r.pg_fail | g_r.unverified);
+ for (int rail = 2; rail <= N; rail++) {
+ const uint64_t a = sim_gpio_rise_us(rail_cfg(rail - 1)->efuse_en);
+ const uint64_t b = sim_gpio_rise_us(rail_cfg(rail)->efuse_en);
+ TEST_ASSERT_TRUE_MESSAGE(a != 0 && b > a, "后一路必须在前一路之后抬高");
+ TEST_ASSERT_TRUE_MESSAGE(b - a >= BRINGUP_STAGGER_MS * 1000u, "错开不足 100 ms");
+ TEST_ASSERT_TRUE_MESSAGE(b - a < (BRINGUP_STAGGER_MS + BRINGUP_PG_POLL_MS + 2u) * 1000u,
+ "错开拖得太久:前一路的 PG 等待不该拖住后一路");
+ TEST_ASSERT_TRUE(rail_efuse_is_on(rail));
+ }
+ /* 上电序列从不开 VBUS(那是命令的事)。 */
+ for (int rail = 1; rail <= N; rail++) {
+ TEST_ASSERT_FALSE(slot_vbus_is_on(rail));
+ }
+}
+
+/* 只恢复「上次开着的」那几路;跳过的路**不占** 100 ms 的格子。 */
+static void test_only_wanted_rails_come_up(void)
+{
+ devices(true);
+ bringup_power_restore(BIT(1) | BIT(4), &g_r);
+
+ TEST_ASSERT_EQUAL_UINT8(BIT(1) | BIT(4), g_r.on);
+ TEST_ASSERT_EQUAL_UINT64(0, sim_gpio_rise_us(rail_cfg(2)->efuse_en));
+ TEST_ASSERT_EQUAL_UINT64(0, sim_gpio_rise_us(rail_cfg(3)->efuse_en));
+ const uint64_t d = sim_gpio_rise_us(rail_cfg(4)->efuse_en) -
+ sim_gpio_rise_us(rail_cfg(1)->efuse_en);
+ TEST_ASSERT_TRUE(d >= 100000u && d < 110000u);
+}
+
+/* 新板(NVS 里什么都没有)= 0 = 一路都不开,而且一个时钟都不花。 */
+static void test_nothing_wanted_nothing_powered(void)
+{
+ devices(true);
+ const uint32_t delays = sim_time_delay_calls();
+ bringup_power_restore(0, &g_r);
+ TEST_ASSERT_EQUAL_UINT8(0, g_r.on);
+ TEST_ASSERT_EQUAL_UINT32(delays, sim_time_delay_calls());
+}
+
+/* ---- `HUB_RST#` 只脉冲一次 ----------------------------------------------- */
+
+/* 上电复位:正好一个低脉冲(写两次:0 再 1),而且第二段**一个字都不再写它**。 */
+static void test_hub_reset_pulses_exactly_once_on_power_on(void)
+{
+ devices(true);
+ bringup_power_restore(ALL, &g_r);
+ TEST_ASSERT_EQUAL_UINT32(2, sim_gpio_writes(BOARD_PIN_HUB_RST_N));
+ TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(BOARD_PIN_HUB_RST_N));
+}
+
+/* 其余复位原因(看门狗 / esptool 的 USB 复位 / esp_restart):**碰都不碰**,
+ 连「配成输出」都不做 —— 配成输出那一刻 HAL 两侧都是输出 0 = 就是一次复位。 */
+static void test_hub_reset_untouched_on_warm_reboot(void)
+{
+ devices(false);
+ bringup_power_restore(ALL, &g_r);
+ TEST_ASSERT_EQUAL_UINT32(0, sim_gpio_writes(BOARD_PIN_HUB_RST_N));
+ TEST_ASSERT_EQUAL_INT(-1, sim_gpio_level(BOARD_PIN_HUB_RST_N)); /* 没配过 */
+}
+
+/* ---- §10.10 逐行 --------------------------------------------------------- */
+
+/* 行 1:某片 INA226 无应答 ⇒ **保持供电**、标盲跑、记事件。PG 照样从 PCA9555 来。 */
+static void test_row_rail_ina_missing_keeps_power(void)
+{
+ sim_i2c_set_present_ch(ch_of(3), INA226_ADDR_RAIL, false);
+ devices(true);
+ bringup_power_restore(ALL, &g_r);
+
+ TEST_ASSERT_EQUAL_UINT8(BIT(3), g_r.blind);
+ TEST_ASSERT_EQUAL_UINT8(ALL, g_r.on);
+ TEST_ASSERT_TRUE(rail_efuse_is_on(3));
+ evlog_rec_t rec;
+ TEST_ASSERT_TRUE(last_ev(LOG_EV_I2C_LOST, &rec));
+ TEST_ASSERT_EQUAL_UINT8(3, rec.rail);
+ TEST_ASSERT_EQUAL_UINT8(INA226_ADDR_RAIL, rec.arg);
+ TEST_ASSERT_EQUAL_INT(1, count_ev(LOG_EV_I2C_LOST, -1));
+}
+
+/* 行 3:DAC 无应答 ⇒ **不关输出**;另一片照样初始化;影子回到 NVM 默认。 */
+static void test_row_dac_missing_does_not_stop_the_other_or_the_rails(void)
+{
+ rail_setpoint_reset();
+ sim_i2c_set_present(DAC63204_ADDR_U7, false);
+ devices(true);
+ bringup_power_restore(ALL, &g_r);
+
+ TEST_ASSERT_TRUE((g_r.deg & BRINGUP_DEG_DAC_U7) != 0);
+ TEST_ASSERT_FALSE((g_r.deg & BRINGUP_DEG_DAC_U6) != 0);
+ /* U6 真的被初始化了:内部基准开、用到的通道上电(不再是复位值 0x0FFF)。 */
+ TEST_ASSERT_EQUAL_UINT16(dac63204_cc_run(DAC63204_ADDR_U6),
+ sim_i2c_get_reg(DAC63204_ADDR_U6, DAC63204_REG_COMMON_CONFIG));
+ TEST_ASSERT_EQUAL_UINT8(ALL, g_r.on);
+ for (int rail = 1; rail <= N; rail++) {
+ TEST_ASSERT_EQUAL_INT(RAIL_MV_SAFE, rail_setpoint_mv(rail));
+ }
+ evlog_rec_t rec;
+ TEST_ASSERT_TRUE(last_ev(LOG_EV_I2C_LOST, &rec));
+ TEST_ASSERT_EQUAL_UINT8(0, rec.rail);
+ TEST_ASSERT_EQUAL_UINT8(DAC63204_ADDR_U7, rec.arg);
+}
+
+/* 行 4:某片 PCA9555 无应答 ⇒ **不关输出**,PG 改按 INA226 电压判。电压到了就算好。 */
+static void test_row_pca_missing_falls_back_to_voltage(void)
+{
+ sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, false);
+ g_pca_gone[1] = true;
+ sim_i2c_set_reg_ch(ch_of(2), INA226_ADDR_RAIL, INA226_REG_BUS_V, (uint16_t)(3900 * 4 / 5));
+ devices(true);
+ bringup_power_restore(ALL, &g_r);
+
+ TEST_ASSERT_EQUAL_UINT8(BIT(2), g_r.io_lost);
+ TEST_ASSERT_EQUAL_UINT8(ALL, g_r.on);
+ TEST_ASSERT_EQUAL_UINT8(0, g_r.pg_fail);
+ TEST_ASSERT_EQUAL_INT(1, count_ev(LOG_EV_I2C_LOST, 2));
+}
+
+/* 同一行的反面:PCA9555 看不见、电压也一直起不来 ⇒ 超时关回去,arg 标明是按电压判的。 */
+static void test_row_pca_missing_and_no_voltage_times_out(void)
+{
+ sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, false);
+ g_pca_gone[1] = true;
+ devices(true);
+ bringup_power_restore(ALL, &g_r);
+
+ TEST_ASSERT_EQUAL_UINT8(BIT(2), g_r.pg_fail);
+ TEST_ASSERT_FALSE(rail_efuse_is_on(2));
+ TEST_ASSERT_EQUAL_UINT8((uint8_t)(ALL & ~BIT(2)), g_r.on);
+ evlog_rec_t rec;
+ TEST_ASSERT_TRUE(last_ev(LOG_EV_PG_TIMEOUT, &rec));
+ TEST_ASSERT_EQUAL_UINT8(2, rec.rail);
+ TEST_ASSERT_EQUAL_UINT8(BRINGUP_ARG_BY_VOLT, rec.arg);
+}
+
+/* PCA9555 与 INA226 都看不见:**开着**、报「确认不了」,不当成坏了去断电。 */
+static void test_row_pca_and_ina_missing_stays_on_unverified(void)
+{
+ sim_i2c_set_present_ch(ch_of(5), PCA9555_ADDR, false);
+ sim_i2c_set_present_ch(ch_of(5), INA226_ADDR_RAIL, false);
+ g_pca_gone[4] = true;
+ devices(true);
+ bringup_power_restore(ALL, &g_r);
+
+ TEST_ASSERT_EQUAL_UINT8(BIT(5), g_r.unverified);
+ TEST_ASSERT_TRUE(rail_efuse_is_on(5));
+ TEST_ASSERT_EQUAL_INT(0, count_ev(LOG_EV_PG_TIMEOUT, -1));
+}
+
+/* 行 5:mux 选不中 ⇒ 先脉冲一次 `MUX_RST#`,仍不通 ⇒ **五路一路都不上电** + MUX_LOST。 */
+static void test_row_mux_missing_pulses_reset_then_powers_nothing(void)
+{
+ sim_i2c_set_present(TCA9548A_ADDR, false);
+ devices(true);
+ const uint32_t resets = sim_i2c_mux_resets();
+ bringup_power_restore(ALL, &g_r);
+
+ TEST_ASSERT_TRUE((g_r.deg & BRINGUP_DEG_MUX) != 0);
+ TEST_ASSERT_EQUAL_UINT32(1, i2cbus_recoveries()); /* 就一次,不是每试一次复位一次 */
+ TEST_ASSERT_EQUAL_UINT32(resets, sim_i2c_mux_resets()); /* 第二段不再动它 */
+ TEST_ASSERT_EQUAL_UINT8(0, g_r.on);
+ for (int rail = 1; rail <= N; rail++) {
+ TEST_ASSERT_EQUAL_UINT64(0, sim_gpio_rise_us(rail_cfg(rail)->efuse_en));
+ }
+ TEST_ASSERT_EQUAL_INT(1, count_ev(LOG_EV_MUX_LOST, 0));
+ /* 整板级记一条就够:不逐路刷十条,也不替够不着的主干器件刷 I2C_LOST
+ (主干要经 mux 写「全关」,mux 死了主干也够不着 —— bringup.c 的那段)。 */
+ TEST_ASSERT_EQUAL_INT(0, count_ev(LOG_EV_I2C_LOST, -1));
+ TEST_ASSERT_EQUAL_UINT32(60000, g_r.pd_limit_mw);
+ for (int rail = 1; rail <= N; rail++) {
+ TEST_ASSERT_EQUAL_INT(RAIL_MV_SAFE, rail_setpoint_mv(rail));
+ }
+}
+
+/* 同一行:复位**救回来了**(第一次认件失败、复位后应答)⇒ 照常起。 */
+static void test_row_mux_recovers_after_one_reset(void)
+{
+ sim_i2c_inject_nak(TCA9548A_ADDR, 1); /* 只 NAK 认件那一次 */
+ devices(true);
+ bringup_power_restore(ALL, &g_r);
+
+ TEST_ASSERT_FALSE((g_r.deg & BRINGUP_DEG_MUX) != 0);
+ TEST_ASSERT_EQUAL_UINT32(1, i2cbus_recoveries());
+ TEST_ASSERT_EQUAL_UINT8(ALL, g_r.on);
+ TEST_ASSERT_EQUAL_INT(0, count_ev(LOG_EV_MUX_LOST, -1));
+}
+
+/* 行 6:`PRSNT#` 高 ⇒ 不初始化(一次总线都不发)、不上电;想开的才记 SLOT_EMPTY。 */
+static void test_row_empty_slot_is_never_touched(void)
+{
+ pull_slot(4);
+ devices(true);
+ TEST_ASSERT_EQUAL_UINT8(BIT(4), g_r.empty);
+ TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_dev_ch(ch_of(4), PCA9555_ADDR)->n_write);
+ TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_dev_ch(ch_of(4), INA226_ADDR_RAIL)->n_write);
+ TEST_ASSERT_EQUAL_INT(0, count_ev(LOG_EV_I2C_LOST, -1)); /* 空槽不是失联 */
+
+ bringup_power_restore(ALL, &g_r);
+ TEST_ASSERT_EQUAL_UINT8((uint8_t)(ALL & ~BIT(4)), g_r.on);
+ TEST_ASSERT_EQUAL_UINT64(0, sim_gpio_rise_us(rail_cfg(4)->efuse_en));
+ TEST_ASSERT_EQUAL_INT(1, count_ev(LOG_EV_SLOT_EMPTY, 4));
+}
+
+static void test_row_empty_slot_not_wanted_is_silent(void)
+{
+ pull_slot(4);
+ devices(true);
+ bringup_power_restore((uint8_t)(ALL & ~BIT(4)), &g_r);
+ TEST_ASSERT_EQUAL_INT(0, count_ev(LOG_EV_SLOT_EMPTY, -1)); /* 三槽机器每次开机不刷事件 */
+}
+
+/* 行 7:CH224A 读不到 ⇒ **不关输出**,按 60 W。 */
+static void test_row_pd_missing_assumes_60w(void)
+{
+ sim_i2c_set_present(CH224A_ADDR_DEFAULT, false);
+ devices(true);
+ bringup_power_restore(ALL, &g_r);
+
+ TEST_ASSERT_TRUE((g_r.deg & BRINGUP_DEG_PD) != 0);
+ TEST_ASSERT_EQUAL_UINT32(60000, g_r.pd_limit_mw);
+ TEST_ASSERT_EQUAL_UINT8(ALL, g_r.on);
+ evlog_rec_t rec;
+ TEST_ASSERT_TRUE(last_ev(LOG_EV_PD_LIMIT, &rec));
+ TEST_ASSERT_EQUAL_UINT8(1, rec.arg);
+ TEST_ASSERT_EQUAL_INT32(600, rec.bus_mv); /* mW/100 */
+}
+
+/* 同一行:档位偏低 ⇒ 照读到的来(真相比保守值更可信),标 PD_LOW。 */
+static void test_row_pd_low_uses_what_it_reads(void)
+{
+ sim_i2c_set_reg(CH224A_ADDR_DEFAULT, CH224A_REG_CURRENT, 2250 / 50); /* 45 W */
+ devices(true);
+ TEST_ASSERT_TRUE((g_r.deg & BRINGUP_DEG_PD_LOW) != 0);
+ TEST_ASSERT_FALSE((g_r.deg & BRINGUP_DEG_PD) != 0);
+ TEST_ASSERT_EQUAL_UINT32(45000, g_r.pd_limit_mw);
+}
+
+/* 设计文档说 CH224A 在 mux 通道 5,实测在主干 —— 两处都得找得到。 */
+static void test_pd_found_behind_channel_5_too(void)
+{
+ sim_hal_reset();
+ pca9555_shadow_reset();
+ g_pd_on_trunk = false; /* 主干上不挂 —— 同址挂两处,sim 会先匹配到主干那颗 */
+ board();
+ g_pd_on_trunk = true;
+ TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init_ex(0));
+ sim_i2c_add_ch(TCA9548A_CH_PD, CH224A_ADDR_DEFAULT, 1);
+ sim_i2c_set_reg_ch(TCA9548A_CH_PD, CH224A_ADDR_DEFAULT, CH224A_REG_CURRENT, 5000 / 50);
+ devices(true);
+ TEST_ASSERT_EQUAL_UINT32(0, g_r.deg);
+ TEST_ASSERT_EQUAL_UINT32(100000, g_r.pd_limit_mw);
+}
+
+/* 行 8:看门狗复位 ⇒ BOOT 事件带复位原因;输出照 §10.9 恢复。 */
+static void test_row_watchdog_reset_is_recorded_in_boot(void)
+{
+ sim_hal_reboot();
+ pca9555_shadow_reset();
+ board();
+ TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init_ex(7)); /* ESP_RST_TASK_WDT = 7 */
+ devices(false);
+ bringup_power_restore(ALL, &g_r);
+
+ evlog_rec_t rec;
+ TEST_ASSERT_TRUE(last_ev(LOG_EV_BOOT, &rec));
+ TEST_ASSERT_EQUAL_UINT8(7, rec.arg);
+ TEST_ASSERT_EQUAL_UINT8(ALL, g_r.on);
+}
+
+/* ---- 等 PG --------------------------------------------------------------- */
+
+/* §10.9 第 6 步:PG 等不到 ⇒ 500 ms 后关回去 + 记事件(带 FLT#);别的路不受牵连。 */
+static void test_pg_timeout_turns_that_rail_back_off(void)
+{
+ g_pg_follows[2] = false;
+ g_flt[2] = true;
+ devices(true);
+ bringup_power_restore(ALL, &g_r);
+
+ TEST_ASSERT_EQUAL_UINT8(BIT(3), g_r.pg_fail);
+ TEST_ASSERT_FALSE(rail_efuse_is_on(3));
+ TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(3)->efuse_en));
+ TEST_ASSERT_EQUAL_UINT8((uint8_t)(ALL & ~BIT(3)), g_r.on);
+ evlog_rec_t rec;
+ TEST_ASSERT_TRUE(last_ev(LOG_EV_PG_TIMEOUT, &rec));
+ TEST_ASSERT_EQUAL_UINT8(3, rec.rail);
+ TEST_ASSERT_EQUAL_UINT8(BRINGUP_ARG_FLT, rec.arg);
+ /* 关回去的时刻 ≈ 抬高 + 500 ms(不早于)。 */
+ TEST_ASSERT_TRUE(hal_time_now_ms() - g_r.on_ms[2] >= BRINGUP_PG_TIMEOUT_MS);
+ /* 后面两路没有被它拖住:仍是 100 ms 一格。 */
+ const uint64_t d = sim_gpio_rise_us(rail_cfg(4)->efuse_en) -
+ sim_gpio_rise_us(rail_cfg(3)->efuse_en);
+ TEST_ASSERT_TRUE(d < 110000u);
+}
+
+/* ---- DAC 的 NVM 核对 ----------------------------------------------------- */
+
+/* 上电复位时读回的不是 3.90 V ⇒ 「DAC 不应答时停在安全值」这条前提不成立了,要报。 */
+static void test_dac_nvm_mismatch_is_flagged_on_power_on(void)
+{
+ sim_i2c_set_reg(dac63204_rail_addr(3), DAC63204_REG_DATA(dac63204_rail_ch(3)),
+ DAC63204_CODE_TO_REG(rail_mv_to_code(3600)));
+ devices(true);
+ TEST_ASSERT_TRUE((g_r.deg & BRINGUP_DEG_DAC_NVM) != 0);
+ TEST_ASSERT_EQUAL_UINT16(3600, g_r.dac_nvm_mv[2]);
+}
+
+/* 热重启时 DAC 没断过电,里面是上次命令设的值 —— 不许报 NVM 不对。 */
+static void test_dac_nvm_not_checked_on_warm_reboot(void)
+{
+ sim_i2c_set_reg(dac63204_rail_addr(3), DAC63204_REG_DATA(dac63204_rail_ch(3)),
+ DAC63204_CODE_TO_REG(rail_mv_to_code(3600)));
+ devices(false);
+ TEST_ASSERT_FALSE((g_r.deg & BRINGUP_DEG_DAC_NVM) != 0);
+ /* 而初始化照样把它写回安全值。 */
+ int mv = 0;
+ TEST_ASSERT_EQUAL_INT(HAL_OK, rail_get_mv(3, &mv));
+ TEST_ASSERT_EQUAL_INT(RAIL_MV_SAFE, mv);
+}
+
+/* ---- 意图掩码 ------------------------------------------------------------ */
+
+static void test_want_update(void)
+{
+ TEST_ASSERT_EQUAL_UINT8(BIT(3), bringup_want_update(0, 3, true));
+ TEST_ASSERT_EQUAL_UINT8(BIT(1), bringup_want_update(BIT(1) | BIT(3), 3, false));
+ TEST_ASSERT_EQUAL_UINT8(BIT(2), bringup_want_update(BIT(2), 0, true)); /* 越界不动 */
+ TEST_ASSERT_EQUAL_UINT8(BIT(2), bringup_want_update(BIT(2), N + 1, true));
+}
+
+int main(void)
+{
+ UNITY_BEGIN();
+ RUN_TEST(test_first_input_ina_is_marked_reversed);
+ RUN_TEST(test_full_board_initialises_every_device);
+ RUN_TEST(test_five_rails_come_up_100ms_apart);
+ RUN_TEST(test_only_wanted_rails_come_up);
+ RUN_TEST(test_nothing_wanted_nothing_powered);
+ RUN_TEST(test_hub_reset_pulses_exactly_once_on_power_on);
+ RUN_TEST(test_hub_reset_untouched_on_warm_reboot);
+ RUN_TEST(test_row_rail_ina_missing_keeps_power);
+ RUN_TEST(test_row_dac_missing_does_not_stop_the_other_or_the_rails);
+ RUN_TEST(test_row_pca_missing_falls_back_to_voltage);
+ RUN_TEST(test_row_pca_missing_and_no_voltage_times_out);
+ RUN_TEST(test_row_pca_and_ina_missing_stays_on_unverified);
+ RUN_TEST(test_row_mux_missing_pulses_reset_then_powers_nothing);
+ RUN_TEST(test_row_mux_recovers_after_one_reset);
+ RUN_TEST(test_row_empty_slot_is_never_touched);
+ RUN_TEST(test_row_empty_slot_not_wanted_is_silent);
+ RUN_TEST(test_row_pd_missing_assumes_60w);
+ RUN_TEST(test_row_pd_low_uses_what_it_reads);
+ RUN_TEST(test_pd_found_behind_channel_5_too);
+ RUN_TEST(test_row_watchdog_reset_is_recorded_in_boot);
+ 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_want_update);
+ return UNITY_END();
+}
diff --git a/firmware/test/test_cmd.c b/firmware/test/test_cmd.c
index 7fafe16..cdd791d 100644
--- a/firmware/test/test_cmd.c
+++ b/firmware/test/test_cmd.c
@@ -524,6 +524,47 @@ static void test_power_off_drops_vbus_and_always_drops_the_efuse(void)
TEST_ASSERT_TRUE(slot_vbus_is_on(2)); /* 影子如实留在 on(slot.h:倒向安全的一侧) */
}
+/* A15:只有「真的开成 / 关成了」才改意图(下次开机按它恢复);被拒的、判重命中的都不改。 */
+static int g_intent_n;
+static int g_intent_rail;
+static bool g_intent_on;
+static void intent_spy(void *ctx, int rail, bool on)
+{
+ (void)ctx;
+ g_intent_n++;
+ g_intent_rail = rail;
+ g_intent_on = on;
+}
+
+static void test_power_records_intent_only_when_it_took_effect(void)
+{
+ g_intent_n = 0;
+ const cmd_env_t env = { .intent = intent_spy };
+ cmd_init(&env);
+
+ ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":3,\"on\":true}"), "\"ok\":true");
+ TEST_ASSERT_EQUAL_INT(1, g_intent_n);
+ TEST_ASSERT_EQUAL_INT(3, g_intent_rail);
+ TEST_ASSERT_TRUE(g_intent_on);
+
+ send("{\"seq\":1,\"cmd\":\"power\",\"rail\":3,\"on\":true}"); /* 判重命中:不再执行 */
+ TEST_ASSERT_EQUAL_INT(1, g_intent_n);
+
+ set_present(4, false);
+ ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"power\",\"rail\":4,\"on\":true}"), "\"err\":\"empty\"");
+ TEST_ASSERT_EQUAL_INT(1, g_intent_n);
+
+ ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"power\",\"rail\":3,\"on\":false}"), "\"ok\":true");
+ TEST_ASSERT_EQUAL_INT(2, g_intent_n);
+ TEST_ASSERT_FALSE(g_intent_on);
+
+ /* 硬重启不是意图(那是事故处理),不许碰它。 */
+ ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"power\",\"rail\":1,\"on\":true}"), "\"ok\":true");
+ ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"power_cycle\",\"rail\":1}"), "\"ok\":true");
+ TEST_ASSERT_EQUAL_INT(3, g_intent_n);
+ cmd_init(NULL);
+}
+
/* 风扇:手动值只是下限,0 交回自动。 */
static void test_fan_floor(void)
{
@@ -649,6 +690,7 @@ int main(void)
RUN_TEST(test_reboots_are_logged_with_kind_and_link);
RUN_TEST(test_vbus_needs_the_rail_powered);
RUN_TEST(test_power_off_drops_vbus_and_always_drops_the_efuse);
+ RUN_TEST(test_power_records_intent_only_when_it_took_effect);
RUN_TEST(test_fan_floor);
RUN_TEST(test_events_pages_with_contiguous_seqs);
RUN_TEST(test_status_fits_and_reports_unknowns_as_null);
diff --git a/firmware/test/test_evlog.c b/firmware/test/test_evlog.c
index f4a7e10..62a83a5 100644
--- a/firmware/test/test_evlog.c
+++ b/firmware/test/test_evlog.c
@@ -84,7 +84,8 @@ static void test_flash_format_is_frozen(void)
TEST_ASSERT_EQUAL_INT(14, LOG_EV_MUX_LOST);
TEST_ASSERT_EQUAL_INT(15, LOG_EV_SLOT_EMPTY);
TEST_ASSERT_EQUAL_INT(16, LOG_EV_PD_LIMIT);
- TEST_ASSERT_EQUAL_INT(17, LOG_EV_COUNT);
+ TEST_ASSERT_EQUAL_INT(17, LOG_EV_PG_TIMEOUT); /* A15 */
+ TEST_ASSERT_EQUAL_INT(18, LOG_EV_COUNT);
}
/* §10.7 的「必记事件」有八类,A10 只产生其中两类。剩下的号先占住,
diff --git a/rack/backend/prrack/protocol.py b/rack/backend/prrack/protocol.py
index 1702a97..e525189 100644
--- a/rack/backend/prrack/protocol.py
+++ b/rack/backend/prrack/protocol.py
@@ -34,10 +34,11 @@ EVENT_TYPES: Dict[int, Tuple[str, str]] = {
14: ("MUX_LOST", "mux 失联全断"),
15: ("SLOT_EMPTY", "刀片不在位"),
16: ("PD_LIMIT", "PD 限功率"),
+ 17: ("PG_TIMEOUT", "上电等不到 PG"), # A15:arg bit0 = FLT# 在报,bit1 = 按电压判
}
# 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。
-EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14}
+EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14, 17}
# cmd_id_t(firmware/main/cmd.h),CMD_REJECT 事件的 arg 就是它。
CMD_NAMES = {1: "status", 2: "set_voltage", 3: "power_cycle", 4: "key", 5: "edl", 6: "vbus",
diff --git a/固件开发计划.md b/固件开发计划.md
index 2c2fb9d..ce6a8c9 100644
--- a/固件开发计划.md
+++ b/固件开发计划.md
@@ -528,7 +528,7 @@ R-3 依赖 R-1+R-2;R-4 依赖 R-3;R-5 / R-6 随时可插。
| 里程碑 | 判据 | 依赖硬件 |
|---|---|---|
| **M1 逻辑跑通** | A1~A16 + R-0~R-6 全绿;模拟器上能完成一次完整的「崩溃 → 软重启 → 硬重启」 | ❌ 不需要 |
-| └ 进度 | **R-0~R-6 ✅ 完成**(2026-09-16,351 例);A1~A11 ✅;**A12 ✅**(2026-09-27,380 例);**A13 ✅**(同日,390 例,已上板);**A14 + C1 ✅**(同日,400 例,已上板真连网);**还欠 A15 / A16** | |
+| └ 进度 | **R-0~R-6 ✅ 完成**(2026-09-16,351 例);A1~A11 ✅;**A12 ✅**(2026-09-27,380 例);**A13 ✅**(同日,390 例,已上板);**A14 + C1 ✅**(同日,400 例,已上板真连网);**A15 ✅**(同日,425 例,已上板:输入电流 +98.5 mA、BOOT 带复位原因);**还欠 A16** | |
| **M2 板子活了** | §12.1 的 17 步 + B1b / B1c / B8b 全过,尤其 B3 / B4 | ✅ |
| **M3 交付** | 五台 24 h 无重启无告警;双链路验收通过 | ✅ |
diff --git a/需求文档.md b/需求文档.md
index 18e95a0..f05644d 100644
--- a/需求文档.md
+++ b/需求文档.md
@@ -864,6 +864,20 @@ base = `phonerail/`,id = 板子 STA MAC 的 12 位小写十六进制(板
> 叠在 12 V 输入上,而输入预算本来就是 8.3 A(PR-2)。错开 100 ms 之后
> 各路的斜坡(11.5 ms)完全不重叠。
+**A15 定稿的落地(2026-09-27,`firmware/main/bringup.{h,c}`,宿主机 24 例)**:
+- 分两段:`bringup_devices()` = 第 1~4 步;**`t_safety` 在两段之间起来**,再 `bringup_power_restore()` = 第 5~6 步
+ —— 上电那一刻巡检已经在看(原文第 7 步没说巡检何时起,等五路开完才起就有近 1 s 的盲区)。
+- 第 2 步 R2 实际是 15 片:主干 mux / 两片 DAC / 输入侧 INA226 / CH224A,五片刀片各 INA226 + PCA9555。
+ 六片 INA226 都在这一步写 Config / Calibration(A14 之前没做,电流恒读 0);输入侧 0x43 在这一步登记对调接。
+- 第 3 步 DAC 读回比对**只在上电复位时做**:热重启时 DAC 没断过电,里面是上次命令设的值。
+- 第 4 步「上次的每路使能状态」= **意图**:只有 `power` 命令改它(NVS 键 `phonerail/rail_want`,1 字节);
+ 判据跳闸、硬重启、mux 全断都不改。新板没有这个键 = 一路都不开。
+ ⚠️ 设定电压**不**持久化:重启后五路回到 3.90 V,上位机要的话自己重下。
+- 第 5 步错开按「上一路 EN 抬高的时刻」算(不等上一路 PG),ESP 上实际 100~110 ms。
+- 第 6 步每路各等 500 ms(= 硬重启的 PG 上限);等不到就关回去并记 **`LOG_EV_PG_TIMEOUT`(17,新增)**。
+ PCA9555 读不到时改按 INA226 电压(≥ 设定 − 100 mV)判;两样都读不到 = **开着、报「确认不了」**,不当成坏了。
+- `HUB_RST#` 只在上电复位时脉冲,其余复位原因连配置都不做(配成输出那一刻就是一次复位)。
+
### 10.10 降级矩阵
| 失效 | 检测方式 | 行为 |
@@ -879,6 +893,14 @@ base = `phonerail/`,id = 板子 STA MAC 的 12 位小写十六进制(板
**降级模式**的定义:只保留「关断能力」与「事件记录」,不再自动上电、不接受调压命令,
持续告警。宁可五台手机不开机,也不要在盲态下带电跑。
+**A15 落地时补的三条(2026-09-27,逐行用例在 `firmware/test/test_bringup.c`):**
+- **mux 失联时主干也够不着**:`i2cbus_xact(主干)` 要先给 mux 写「全关」,这一步失败回调就不跑。
+ 所以启动时 mux 复位后仍不通 ⇒ 不去碰 DAC / 输入侧 / CH224A(不刷三条误导性的 I2C_LOST),
+ 设定值影子回 NVM 默认、功率按 60 W,只记一条 MUX_LOST。
+- **看门狗复位**的「复位事件」就是 `LOG_EV_BOOT`,arg = `esp_reset_reason()`(1 上电 / 4 panic / 6 任务看门狗 / 11 USB …)。
+- **I²C 总线挂死**那一行(9 个时钟恢复 + 复位外设)**还没做**,在 HAL 层,待办。
+- CH224A:设计上挂 mux 通道 5,**板 B 实测在主干应答**(0x22/0x23);启动时两处都找。
+
### 10.11 关于 FR-12「模拟电量曲线」的一个说明
这条需求的实现方式和它的字面意思不一样,值得在架构层写清楚:**手机的电量计是从