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「模拟电量曲线」的一个说明 这条需求的实现方式和它的字面意思不一样,值得在架构层写清楚:**手机的电量计是从