From b8ad69681fa69308f7451e1ebe72af8000981174 Mon Sep 17 00:00:00 2001 From: zhou yang Date: Sun, 27 Sep 2026 10:26:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E4=BB=B6=EF=BC=9AR2=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=20R-0=20~=20R-6=EF=BC=88=E5=BA=95=E6=9D=BF=20+=205=20?= =?UTF-8?q?=E7=89=87=E5=90=8C=E6=9E=84=E5=88=80=E7=89=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-09-16 完成、当时未提交,这里补提交(原样,不含 09-27 上板的修改)。 - 器件层 / 拓扑层整体从 R1 换到 R2:TCA9548A 分通道、五路同址 INA226 0x40 + 输入侧 0x43、 刀片 PCA9555(VBUS_EN 挪到 P0.4)、CH224A 只读驱动、风扇闭环 t_thermal - 新增 i2cbus(mux 原子事务)、slot(刀片 I/O 门面)、host/qtest.sh - 74HC595 链退役(代码与测试保留) - 判据阈值与时间常量未动;19 个 ctest 全绿,idf.py build 通过 Co-Authored-By: Claude Opus 5.5 --- firmware/.gitignore | 1 + firmware/HANDOFF.md | 162 +- firmware/components/drv/CMakeLists.txt | 24 +- firmware/components/drv/ch224a.c | 66 + firmware/components/drv/ina226.c | 67 +- firmware/components/drv/include/ch224a.h | 73 + firmware/components/drv/include/ina226.h | 48 +- firmware/components/drv/include/pca9555.h | 187 +- firmware/components/drv/include/sr595.h | 278 +-- firmware/components/drv/include/tca9548a.h | 79 + firmware/components/drv/pca9555.c | 207 +- firmware/components/drv/tca9548a.c | 75 + .../phonerail_hal/include/hal_lock.h | 172 +- .../phonerail_hal/sim/hal_gpio_sim.c | 190 +- .../phonerail_hal/sim/hal_i2c_sim.c | 181 +- .../phonerail_hal/sim/hal_pwm_sim.c | 127 +- .../components/phonerail_hal/sim/sim_hal.h | 100 +- .../phonerail_hal/sim/sim_pca9555.c | 172 +- firmware/host/CMakeLists.txt | 5 + firmware/host/qtest.sh | 50 + firmware/main/CMakeLists.txt | 3 + firmware/main/app_main.c | 132 +- firmware/main/board_pins.h | 93 +- firmware/main/evlog.c | 1145 +++++----- firmware/main/evlog.h | 326 +-- firmware/main/i2cbus.c | 152 ++ firmware/main/i2cbus.h | 92 + firmware/main/rail_cfg.c | 197 +- firmware/main/rail_cfg.h | 124 +- firmware/main/slot.c | 143 ++ firmware/main/slot.h | 100 + firmware/main/t_actuator.c | 59 +- firmware/main/t_actuator.h | 417 ++-- firmware/main/t_safety.c | 702 +++--- firmware/main/t_safety.h | 375 ++-- firmware/main/t_thermal.c | 233 ++ firmware/main/t_thermal.h | 243 ++ firmware/test/test_ch224a.c | 321 +++ firmware/test/test_evlog.c | 131 +- firmware/test/test_hal_i2c_sim.c | 107 +- firmware/test/test_i2cbus.c | 612 +++++ firmware/test/test_ina226.c | 196 +- firmware/test/test_pca9555.c | 811 +++++-- firmware/test/test_rail_cfg.c | 823 ++++--- firmware/test/test_slot.c | 464 ++++ firmware/test/test_t_actuator.c | 755 +++++-- firmware/test/test_t_safety.c | 671 ++++-- firmware/test/test_t_safety_rules.c | 1962 ++++++++++------- firmware/test/test_t_thermal.c | 466 ++++ firmware/test/test_tca9548a.c | 329 +++ 固件开发计划.md | 502 ++++- 51 files changed, 10882 insertions(+), 4068 deletions(-) create mode 100644 firmware/components/drv/ch224a.c create mode 100644 firmware/components/drv/include/ch224a.h create mode 100644 firmware/components/drv/include/tca9548a.h create mode 100644 firmware/components/drv/tca9548a.c create mode 100644 firmware/host/qtest.sh create mode 100644 firmware/main/i2cbus.c create mode 100644 firmware/main/i2cbus.h create mode 100644 firmware/main/slot.c create mode 100644 firmware/main/slot.h create mode 100644 firmware/main/t_thermal.c create mode 100644 firmware/main/t_thermal.h create mode 100644 firmware/test/test_ch224a.c create mode 100644 firmware/test/test_i2cbus.c create mode 100644 firmware/test/test_slot.c create mode 100644 firmware/test/test_t_thermal.c create mode 100644 firmware/test/test_tca9548a.c diff --git a/firmware/.gitignore b/firmware/.gitignore index ffd7366..28e1fc2 100644 --- a/firmware/.gitignore +++ b/firmware/.gitignore @@ -4,3 +4,4 @@ sdkconfig sdkconfig.old managed_components/ dependencies.lock +host/.q/ diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index eb805e1..23ab4d3 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -1,125 +1,69 @@ # 交接 -更新:2026-09-07 完成任务包 **A11**(事件日志:环形缓冲 + 落盘) +更新:2026-09-16 完成 **R-0 ~ R-6**(整个 R2 迁移,计划 v2.0 §6.2) ## 一句话现状 -事件从 `evlog_put*()` 进一个 256 条的 RAM 环(临界区里两条指令,**不阻塞、不碰 flash**), -由最低优先级的 `t_log` 攒够 16 条或最老那条等满 1 s 时批量写进 **`evlog` 分区** -(17 个扇区 × 4 KB,**4335 条**)。序号 = 记录在 flash 里的位置(`epoch × 255 + 槽号`), -所以掉电重启后接着数、`evlog_read(起始序号)` 直接给 A12 的 `events` 命令用。 -A10 的事件出口已经接上(`app_main.c` 里 `evlog_put_safety`),**判据那一层一个字没动**。 -14 条 ctest(240 个用例)全绿,`idf.py build` 通过,**23 条注错跑过 21 条变红**。 +固件已经整体从 R1(单板 / 六片不同地址 INA226 / 74HC595 键链 / `VBUS_EN` 是 GPIO) +迁到 **R2**(底板 + 5 片同构刀片 / 全是 0x40 与 0x20 / TCA9548A 分通道 / +`VBUS_EN` 在刀片 PCA9555 的 P0.4 上)。**19 个 ctest、351 个用例全绿**,`idf.py build` 通过。 +器件层与拓扑层全部换掉,**判据阈值与时间常量一个没动**(它们来自电路计算,R2 没改电路)。 ## 下一步 -**A12**:命令处理器(语义 + 序号判重 + 限幅)。验收是「同序号发两遍只执行一次; -越界命令被拒且记事件」。A11 给它备好的: -- `evlog_read(from_seq, out, max)` 就是 `events 起始序号` 那条命令的实现 - —— ⚠️ **先 `evlog_flush(0)` 再读**,没落盘的事件还没有序号(见下面「几条约定」)。 -- 「越界命令被拒」记 `LOG_EV_CMD_REJECT`(号已经占住,`arg` 放命令号)。 -- `evlog_stats()` 里的 `oldest_seq` / `next_seq` 是上位机的游标两端, - `dropped` / `io_errors` 该进 `status` 的回复里。 +**A12**:命令处理器(语义 + 序号判重 + 限幅)。R2 多一条验收: +**空槽(`PRSNT#` 为高)的命令一律拒**(`slot_present()` 已备好)。 +另外 A15 开工前先把启动序列从 `app_main.c` 拆成宿主机可编的函数 —— 见下面「还没有拦网的」。 ## 本次新增/修改的文件 -- `main/evlog.h` / `evlog.c` — 环形缓冲 + 落盘 + 按序号读回(约 500 行,一半是注释) - · `log_ev_t` 十四种事件(**数字就是 flash 格式**,只许往末尾加) - · `evlog_rec_t` 16 B(§10.7 的「单条」)+ 每条一个 **CRC-8** - · `evlog_init/put/put_safety/due/flush/read/stats/capacity` -- `components/phonerail_hal/include/hal_store.h` + `sim/hal_store_sim.c` + - `esp/hal_store_esp.c` — **HAL 的第六样**:掉电不丢的一块字节区(擦扇区 / 写字节 / 读)。 - sim 那份按 **NOR 的规矩**来:写是按位与、擦是整扇区、可以被「掉电」切成半条。 -- `components/phonerail_hal/include/hal_lock.h` + 两份实现 — 新增 - `hal_crit_enter/exit()`。**这不是第三把锁**,是关中断的临界区(ESP 侧是 portMUX, - 双核上两个核同时入队是真会发生的)。sim 侧会数「在临界区里干了会阻塞的事」的次数。 -- `sim/sim_hal.{h,c}` — 新增 **`sim_hal_reboot()`**(掉电重启:除 flash 内容外全清) - 与假 flash 的控制面(`sim_store_tear_next_write()` 等) -- `main/app_main.c` — `evlog_init()`(失败不 abort)、事件出口接 `evlog_put_safety`、 - `task_log` 填上(100 ms 醒一次、`evlog_due()` 说要写才写、丢过事件就告警) -- `partitions.csv`(新)+ `sdkconfig.defaults` — 自定义分区表,多一块 `evlog` 68 KB -- `test/test_evlog.c` — 27 个用例(新文件) -- `main/phonerail.h` — 版本 `0.1.0-a11` -- `../需求文档.md` §10.7 — 记 A11 落地 + 八条实现约定 +- `main/i2cbus.{h,c}` **新** — mux 原子事务:拿锁 → 选通道 → 跑回调 → 放锁;通道影子 + 失败作废 +- `main/slot.{h,c}` **新** — 一片刀片的 I/O 门面:路号 → mux 通道 → 影子下标只在这里换算一次 +- `components/drv/tca9548a.{h,c}` / `ch224a.{h,c}` **新** — I²C 八路多路器 / PD 受电(只读) +- `main/t_thermal.{h,c}` **新** — 风扇闭环(一路 PWM 两只风扇 + `FAN_EN` 总开关 + 两路 TACH) +- `main/board_pins.h` — 整表重写(R2 的 GPIO 与 R1 **一个都不一样**) +- `components/drv/pca9555.{h,c}` — 重写:P0.0~4 是输出、五实例影子、初始化顺序是安全本身 +- `components/drv/ina226.{h,c}` — 五路同址 0x40 + 输入侧 0x43 +- `main/rail_cfg.{h,c}` — 换列:+`mux_ch` +`prsnt_n`,−`vbus_en`;新增 `RAIL_SELFTEST_MUX` +- `main/t_safety.{h,c}` — 一圈改成「五次 `xact` + 主干一次」;+整圈锁预算;+mux 失联/在位两条判据 +- `main/t_actuator.{h,c}` — 595 链 → PCA9555 Port0;本文件现在**要拿 I²C 锁** +- `main/app_main.c` — R2 启动顺序(含 `HUB_RST` 只脉冲一次、`ina226_set_reversed(0x43,true)`) +- `main/evlog.h` — 末尾追加 `LOG_EV_MUX_LOST=14 / SLOT_EMPTY=15 / PD_LIMIT=16` +- sim:假 TCA9548A(关着的支路一律 NAK)、五实例假 PCA9555、假风扇 +- `host/qtest.sh` **新** — 单个测试的快编快跑,迁移期并行开工用(DoD 仍认 `host/test.sh`) +- `components/drv/include/sr595.h` — 顶部加「⛔ R2 已退役」横幅,代码与测试保留不动 ## 验收命令与结果 ``` $ bash host/test.sh -100% tests passed, 0 tests failed out of 14 # +test_evlog(27),共 240 例 +100% tests passed, 0 tests failed out of 19 # 351 个用例 -$ .\build_idf.ps1 # PowerShell -phonerail.bin binary size 0x3ec30 bytes. ... 0xc13d0 bytes (75%) free. -Project build complete. - -$ python .../gen_esp32part.py build/partition_table/partition-table.bin -evlog,data,undefined,0x110000,68K, # 17 个扇区 - -# 23 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库): -# 环形: 满了丢最新而不是盖最旧 / 丢了不补 LOST / 补记 LOST 之后不清零 / -# 攒批计时从最新一条算起 -# 校验: 退回异或和 / 记录从扇区头那一槽开始写 / 假 flash 写成覆盖而不是按位与 -# 落盘: 换扇区时不擦 / 绕圈后不更新 oldest_seq / 写失败后不放弃这个扇区 / -# 落盘时不补记 LOST / flush 不守预算 / 攒批没有超时兜底 / 攒够一批也不写 -# 重启: 每次上电都重新格式化 / 扫描取 epoch 最小的扇区 / 扫描停在上一条 -# 读回: 读到洞就跳过 / 读不 clamp 到 oldest_seq -# 其它: 事件时间戳改成记账时刻 / 映射写成加个偏移 -# → 21 条变红。剩下 2 条见下面「抓不到的那两条」。 +$ .\build_idf.ps1 +Project build complete. # phonerail.bin 0x3fa90,分区剩 75% ``` ## 踩到的坑(只写会再犯的) -- ⚠️⚠️ **校验和用异或是错的,必须用 CRC。** 掉电把一次写切在第 8 个字节上时, - 后 8 个字节留在 0xFF —— 而 **8 个 0xFF 的异或恰好是 0**。于是一条 - `bus_mv`/`current_ua` 都是 0 的记录(`LOG_EV_BOOT` 就长这样), - **半条的异或和与整条一模一样**,扫描把半条当成一条真事件,接着往后写。 - 这条错在正常运行里一点痕迹都没有,只在掉电之后出现。 - 拦网是 `test_a_torn_write_with_zero_payload_is_still_rejected()`。 -- ⚠️ **「掉电重启」的测试不能用 `sim_hal_reset()`**:那是「换了一块新板子」, - flash 一起擦掉,于是测的是「刚擦干净的 flash 读不出东西」—— 永远绿。 - 为此专门加了 `sim_hal_reboot()`(CPU 侧全清、flash 一个字节不动)。 - 凡是测「掉电后还在不在」的,先问一句:这个假实现里,什么东西该活下来。 -- ⚠️ **「读到洞就跳过」这个错被 `static` 数组的零初始化掩盖过一次。** - `evlog_read()` 遇到坏槽要放一条空记录**占住位置**,跳过的话上位机游标就对不上; - 但测试用的 `static evlog_rec_t buf[]` 本来就是全 0,而「空记录」也是全 0, - 于是注错之后测试照样绿。**读之前先把缓冲填成非零**(现在填 0xAA)。 -- **攒批的计时起点是环里最老那一条,不是最新那一条。** 取最新的话,事件慢慢地 - 一条一条来(真板上就是这个样子)时窗口每次被重置,**永远不 due** —— - 现象是「日志一直不落盘」,而每一条断言都是绿的。 - 拦网是那条「一条 + 600 ms + 一条 + 600 ms」的用例。 -- **扫描要取 epoch 最大的扇区**,而这件事在「只写过一个扇区」的用例里 - 一点区别都没有(min == max)。测重启必须先写**超过 255 条**跨过扇区边界, - 否则整段绕圈代码一行都没跑到。 -- **NOR 的写是按位与,不是赋值。** 假 flash 写成 `=` 的话,「忘了先擦」 - 这个错在宿主机上一次也复现不出来,而真板上的现象是「记录读出来是两条的与」。 -- **`t_safety` 的事件出口是在断电之后、不持任何锁时被调的**,所以入队里 - `ESP_LOGW` 那一行必须排在 `evlog_put_safety()` **后面** —— 抢串口是会等的。 -- **抓不到的那两条**(注进去仍然全绿,理由留在代码注释里): - ① `rec_valid()` 里的「全 0xFF 直接判空」:当前这个 CRC 的初值/多项式下, - 擦干净的槽本来就校验不过,那一行是冗余的一道保险 —— 它只在**有人改了 - CRC 的多项式或初值**时才有区别,而那种改动没有任何测试拦得住。 - ② `hdr_valid()` 里的 `epoch % 扇区数 == 扇区号`:它保的是「扇区号由 epoch - 算出来」这条被后面所有代码假定的不变量,但要造出反例得让分区几何 - 在两次上电之间变掉,而 sim 的几何是编译期常量。 - 两条都留着 —— 它们保的是别人改代码时的假设,不是当前这一版的行为。 +- ⛔⛔ **PCA9555 的初始化顺序就是安全本身。** Output Port 复位值是 **0xFF**, + 所以必须 `OUTPUT=0 → POLINV=0 → CONFIG1 → CONFIG0` ——**方向最后改**。 + 反过来的那一刻 P0.0~P0.4 一起输出 1 = 三个键全按下 + EDL 短接 + VBUS 上电。 + 注错实测:把顺序调过来,33 条用例里**只有 1 条变红**,终态完全正确 —— + 这就是为什么它在真板上查不出来。拦网是 `sim_pca9555_unsafe_enable()`。 +- ⛔ **`check_mux()` 的两段式不能拿「复位计数器和自己的快照比」做状态**: + 复位之后把后者同步成前者,两个数永远相等 ⇒ 每圈都走「先复位」分支, + `MUX_RST#` 每 20 ms 脉冲一次而 `MUX_LOST` 一次也不出。状态要单独存(`mux_step`)。 +- ⛔ **R2 一圈要拿六次锁**(R1 只拿一次)。每次 5 ms 超时看着没问题, + 六次加起来 **30 ms > 20 ms 周期** ⇒ 总线繁忙时巡检成片丢拍,而每次 `xact` 都按时返回了。 + 现在有 `T_SAFETY_LOCK_BUDGET_MS`(10 ms)这条整圈的闸。 +- ⚠️ **「断 VBUS 在锁内、关 eFuse 在锁外」这条,光断言「写的时候持着锁、通道对」拦不住**: + 把它挪到锁外之后 `slot_vbus_enable()` 自己也会拿锁、也会选对通道,两条断言照样过。 + 真正的硬凭据是**「这一写发生在本圈第几次拿锁里」**(对 = 第 rail 次,错 = 第 7 次)。 +- ⚠️ **`slot_present()` 读的是缓存**,`t_safety` 每圈要先 `slot_prsnt_poll()`。 + 不 poll 的现象是「拔掉刀片永远判不出来」,而快照里那五个 bool 看着完全正常。 +- ⚠️ 假总线上 `hal_i2c_write_read()` 会让 `n_write` 和 `n_read` **各 +1**, + 所以「巡检有没有写刀片」不能看 `n_write`,要看最后一次写的寄存器号。 +- ⚠️ `i2cbus_init()` **必定留下一次 `MUX_RST#` 低脉冲**(先 config(OUTPUT) 再写 1), + 测试里所有复位断言都要走增量。 -## 这一层的几条约定(写 A12/A15/A16 之前看一眼) -- **要读最新的事件,先 `evlog_flush(0)`。** 序号是 flash 里的位置算出来的, - 所以还在 RAM 里的事件**还没有序号**,`evlog_read()` 看不见它们。 - 这不是疏漏:入队时发号会在缓冲溢出时留下永远补不上的空号。 -- **产生事件的人只调 `evlog_put()`,不碰 flash、不调 `evlog_flush()`。** - `t_log` 是唯一碰 flash 的任务。在 t_safety / t_actuator 里 flush, - 等于把一次几毫秒的 flash 写事务塞进 20 ms 的节拍里。 -- **新事件类型只能往 `log_ev_t` 末尾加。** 中间插一个,flash 里跑了半年的 - 历史记录会整体挪位,**而且读出来仍然是合法记录**,没有一处会报错。 - `test_flash_format_is_frozen()` 把每一个数字都钉死了 —— 那条用例变红时, - 要改的是你的枚举,不是那条用例。 -- **§10.7 的必记事件里还有六类没人产生**,号已经占好:`LOG_EV_EFUSE_FAULT` / - `PG_CHANGE` / `I2C_LOST`(A15 的降级矩阵)、`THERMAL_DERATE` / `FAN_STALL`(A16)、 - `REBOOT` / `CMD_REJECT`(A9 的序列 + A12 的命令)。填的时候顺手把 - `evlog_ev_str()` 的名字和 `test_every_event_type_has_a_name()` 一起看一眼。 -- **改了 `partitions.csv` 里 `evlog` 的大小,要跟着改 `sim_hal.h` 的 - `SIM_STORE_SECTORS`。** 两边不一致时宿主机测试仍然全绿,板子上却是另一个容量。 - `test_capacity_meets_the_requirement()` 把它们钉在一起了,但它钉的是 sim 那一侧 - —— csv 那一侧只有人眼。 -- **`hal_crit_enter/exit()` 不是第三把锁**(§10.3 的共享资源只有两个)。 - 它是关中断的临界区:不阻塞、没有持有者、不参与「不许嵌套两把锁」那条规则。 - 里面**只许改内存** —— sim 的 `sim_crit_blocking()` 会数「在临界区里等锁或睡」 - 的次数,`test_evlog.c` 的 `tearDown()` 每个用例都查一次,必须恒为 0。 -- **`evlog_init()` 失败不许 abort。** 日志是排查手段,不是安全链的一环(§10.2): - 没有分区时事件照进 RAM,只是落不了盘,五路照供电。这与 `selftest_or_die()` - 的区别在于:那里错的是「命令会发到哪一路」,这里错的只是「记不下来」。 +## 还没有拦网的(不是遗漏,是已知边界) +- **启动序列一行也测不到**:`app_main.c` 只进 IDF 目标。像 `ina226_set_reversed(0x43,true)` + 这种「漏了就把正常供电判成反灌」的单行,现在唯一的拦网是人眼。A15 先拆函数。 +- **mux 通道整体循环错位**(0→1→2→3→4→0)自检全过:五路互不重复、都不越界、都不等于 5。 + 现象是「每一路读到的都是隔壁那台手机」。唯一判据是上板 **B1c 五槽逐一单插**。 +- **选通道「写下去不报错但没生效」**:假器件写了就生效,宿主机造不出来。见计划 §11。 +- **真并发**:sim 单线程,`i2cbus_xact` 把「拿锁 + 选通道」绑成原子的价值只能间接证明。 diff --git a/firmware/components/drv/CMakeLists.txt b/firmware/components/drv/CMakeLists.txt index 8222581..31f7dde 100644 --- a/firmware/components/drv/CMakeLists.txt +++ b/firmware/components/drv/CMakeLists.txt @@ -1,11 +1,13 @@ -# 板上器件驱动(ESP-IDF 侧)。宿主机侧由 host/CMakeLists.txt 的 PHONERAIL_DRV_SRCS 收, -# 新增一个驱动两边都要加 —— 漏一处 = 「宿主机绿了但上板编不过」,反之亦然。 -# -# 驱动只准依赖 phonerail_hal,不准直接包含 IDF 的头。 -idf_component_register( - SRCS "ina226.c" - "dac63204.c" - "sr595.c" - "pca9555.c" - INCLUDE_DIRS "include" - REQUIRES phonerail_hal) +# 板上器件驱动(ESP-IDF 侧)。宿主机侧由 host/CMakeLists.txt 的 PHONERAIL_DRV_SRCS 收, +# 新增一个驱动两边都要加 —— 漏一处 = 「宿主机绿了但上板编不过」,反之亦然。 +# +# 驱动只准依赖 phonerail_hal,不准直接包含 IDF 的头。 +idf_component_register( + SRCS "ina226.c" + "dac63204.c" + "sr595.c" + "pca9555.c" + "tca9548a.c" + "ch224a.c" + INCLUDE_DIRS "include" + REQUIRES phonerail_hal) diff --git a/firmware/components/drv/ch224a.c b/firmware/components/drv/ch224a.c new file mode 100644 index 0000000..520c556 --- /dev/null +++ b/firmware/components/drv/ch224a.c @@ -0,0 +1,66 @@ +/* CH224A 驱动(只读)。只经 hal_i2c_*,不碰任何平台头文件。契约见 ch224a.h。 + ⚠️ 本文件里**没有任何写寄存器的函数**,那是有意的(头文件那个框)。 */ +#include "ch224a.h" + +#include + +#include "hal_i2c.h" + +/* 0x50 的单位是 50 mA/LSB(R2 §36.2)。 */ +uint32_t ch224a_ma_from_reg(uint8_t reg) { return (uint32_t)reg * 50u; } + +uint32_t ch224a_power_limit_mw(uint32_t ma) +{ + /* 20 V × ma(mA) = mW。20000 mV × mA / 1000 = 20 × mA。 */ + return (uint32_t)((uint64_t)CH224A_FIXED_MV * ma / 1000u); +} + +bool ch224a_addr_ok(uint8_t addr) +{ + /* 主干上的器件 + 刀片支路上的两个。任何一个撞上都会让「选到通道 5」 + 之后的读写打到错的器件上。 */ + static const uint8_t others[] = { 0x20, 0x40, 0x43, 0x4A, 0x4B, 0x70 }; + + if (addr > 0x7f) { + return false; + } + for (size_t i = 0; i < sizeof(others); i++) { + if (others[i] == addr) { + return false; + } + } + return true; +} + +static hal_err_t read_reg(uint8_t addr, uint8_t reg, uint8_t *val) +{ + return hal_i2c_write_read(addr, ®, 1, val, 1); +} + +hal_err_t ch224a_probe(uint8_t addr) +{ + uint8_t st = 0; + return read_reg(addr, CH224A_REG_STATUS, &st); +} + +hal_err_t ch224a_read_status(uint8_t addr, uint8_t *status) +{ + if (status == NULL) { + return HAL_ERR_ARG; + } + return read_reg(addr, CH224A_REG_STATUS, status); +} + +hal_err_t ch224a_read_max_ma(uint8_t addr, uint32_t *ma) +{ + if (ma == NULL) { + return HAL_ERR_ARG; + } + uint8_t reg = 0; + const hal_err_t err = read_reg(addr, CH224A_REG_CURRENT, ®); + if (err != HAL_OK) { + return err; /* 读失败时不动调用方的值:旧的上限比一个 0 可信 */ + } + *ma = ch224a_ma_from_reg(reg); + return HAL_OK; +} diff --git a/firmware/components/drv/ina226.c b/firmware/components/drv/ina226.c index 5451e97..19bf3b2 100644 --- a/firmware/components/drv/ina226.c +++ b/firmware/components/drv/ina226.c @@ -6,10 +6,14 @@ #include "hal_i2c.h" -/* 设计文档 §8.2:CH1..CH5。⚠️ 不是 0x40 起顺排,CH3/CH4/CH5 三路都不是。 */ -static const uint8_t s_rail_addr[INA226_RAIL_COUNT] = { 0x40, 0x41, 0x44, 0x45, 0x42 }; +/* R2:五片刀片完全同构 ⇒ 五路同址。仍然写成表而不是直接 `return 0x40`, + 理由和 mux 通道那张表一样:**同址是当下的事实,不是不变量**。 + 哪天某一片改了 strap,改这里一处,`rail_selftest()` 会立刻发现两边分家。 */ +static const uint8_t s_rail_addr[INA226_RAIL_COUNT] = { + INA226_ADDR_RAIL, INA226_ADDR_RAIL, INA226_ADDR_RAIL, INA226_ADDR_RAIL, INA226_ADDR_RAIL, +}; -const uint8_t ina226_all_addr[INA226_COUNT] = { 0x40, 0x41, 0x44, 0x45, 0x42, INA226_ADDR_INPUT }; +const uint8_t ina226_addr_list[INA226_ADDR_COUNT] = { INA226_ADDR_RAIL, INA226_ADDR_INPUT }; uint8_t ina226_rail_addr(int rail) { @@ -19,27 +23,28 @@ uint8_t ina226_rail_addr(int rail) bool ina226_addr_table_ok(void) { - /* 同一条 I²C 上的其它器件(§8.2):撞上任何一个都是硬件表被改坏了。 */ - static const uint8_t others[] = { 0x4A, 0x4B, 0x20, 0x48 }; + /* 会与之撞车的其它器件:主干上的 DAC63204 / TCA9548A,刀片支路上的 PCA9555。 + ⚠️ 0x48(R1 载板上的 LM75)**不在这张表里** —— R2 没有载板,那片不存在。 */ + static const uint8_t others[] = { 0x4A, 0x4B, 0x70, 0x20 }; - for (int i = 0; i < INA226_COUNT; i++) { - if (ina226_all_addr[i] > 0x7f) { + for (int i = 0; i < INA226_ADDR_COUNT; i++) { + if (ina226_addr_list[i] > 0x7f) { return false; } - for (int j = i + 1; j < INA226_COUNT; j++) { - if (ina226_all_addr[i] == ina226_all_addr[j]) { - return false; + for (int j = i + 1; j < INA226_ADDR_COUNT; j++) { + if (ina226_addr_list[i] == ina226_addr_list[j]) { + return false; /* 刀片那片与输入侧那片必须不同址:它们同时可见 */ } } for (size_t k = 0; k < sizeof(others); k++) { - if (ina226_all_addr[i] == others[k]) { + if (ina226_addr_list[i] == others[k]) { return false; } } } - /* 前五个必须和 rail 表逐个一致 —— 两张表分家的那天就是错一路的那天。 */ + /* 五路那张表里的每一个都必须在扫描表里 —— 分家的那天就是错一路的那天。 */ for (int i = 0; i < INA226_RAIL_COUNT; i++) { - if (ina226_all_addr[i] != s_rail_addr[i]) { + if (s_rail_addr[i] != ina226_addr_list[0]) { return false; } } @@ -133,7 +138,30 @@ hal_err_t ina226_check(uint8_t addr) return cal == INA226_CALIB ? HAL_OK : HAL_ERR_BUS; } -#define READ_ONE(fn, conv, reg) \ +/* ---- Kelvin 对调接的片子 -------------------------------------------------- + IN+ / IN− 在板上对调接(IN+ 接分流电阻下游)的片子,分流电压 / 电流读出来是负的。 + 驱动在这里取反,调用方看到的永远是「正 = 正常供电方向」—— SW-8 反灌判据靠这个符号。 + 母线电压、功率寄存器是无符号量,不受影响。 + R1 全部正接(默认);R2 底板的输入片 U13(0x43)为了让两条 Kelvin 在 PCB 上不交叉 + 而对调(2026-09-11),R2 固件启动时要 ina226_set_reversed(INA226_ADDR_INPUT, true)。 */ +static uint16_t s_reversed; /* bit (addr − 0x40);INA226 的地址只有 0x40~0x4F */ + +void ina226_set_reversed(uint8_t addr, bool reversed) +{ + if (addr < 0x40 || addr > 0x4F) { + return; + } + uint16_t bit = (uint16_t)(1u << (addr - 0x40)); + s_reversed = reversed ? (uint16_t)(s_reversed | bit) : (uint16_t)(s_reversed & ~bit); +} + +bool ina226_is_reversed(uint8_t addr) +{ + return addr >= 0x40 && addr <= 0x4F && (s_reversed & (1u << (addr - 0x40))) != 0; +} + +/* pol_ = 1:有符号量(分流电压 / 电流),按登记取反 */ +#define READ_ONE(fn, conv, reg, pol_) \ hal_err_t fn(uint8_t addr, int32_t *out) \ { \ if (out == NULL) { \ @@ -144,14 +172,15 @@ hal_err_t ina226_check(uint8_t addr) if (err != HAL_OK) { \ return err; \ } \ - *out = conv(raw); \ + int32_t v = conv(raw); \ + *out = ((pol_) && ina226_is_reversed(addr)) ? -v : v; \ return HAL_OK; \ } -READ_ONE(ina226_read_bus_mv, bus_mv_of, INA226_REG_BUS_V) -READ_ONE(ina226_read_shunt_uv, shunt_uv_of, INA226_REG_SHUNT_V) -READ_ONE(ina226_read_current_ua, current_ua_of, INA226_REG_CURRENT) -READ_ONE(ina226_read_power_mw, power_mw_of, INA226_REG_POWER) +READ_ONE(ina226_read_bus_mv, bus_mv_of, INA226_REG_BUS_V, 0) +READ_ONE(ina226_read_shunt_uv, shunt_uv_of, INA226_REG_SHUNT_V, 1) +READ_ONE(ina226_read_current_ua, current_ua_of, INA226_REG_CURRENT, 1) +READ_ONE(ina226_read_power_mw, power_mw_of, INA226_REG_POWER, 0) hal_err_t ina226_read_vi(uint8_t addr, ina226_vi_t *out) { diff --git a/firmware/components/drv/include/ch224a.h b/firmware/components/drv/include/ch224a.h new file mode 100644 index 0000000..ec0eed3 --- /dev/null +++ b/firmware/components/drv/include/ch224a.h @@ -0,0 +1,73 @@ +/* CH224A —— 底板的 USB-PD 受电芯片(`U36`,R2 方案 D16)。 + + 板上它只干一件事:用 `R7` 120 k 这一颗电阻**硬件**请求 20 V, + 降压 `U37` 再把 20 V 变成 12 V 母线。固件**不参与协商**,只做一件事: + **读出当前这个充电器在 20 V 档最多能给多少电流**,换算成整板功率上限。 + + ---- 为什么需要这个上限(计划 §5.9) ------------------------------------ + R2 的输入从 DC 头改成了 USB-C PD。单口上限 20 V × 5 A = 100 W(5 A 还要 + e-marker 线;普通 3 A 线只有 60 W),而整板最坏工况约 108 W。 + 常态 10~25 W 怎么都够,但**最坏工况要靠固件限功率**(R1 §8.3 的降载)。 + 充电器插的是哪一个,只有运行时才知道 —— 就是读这颗芯片。 + + ┌─ ⛔ 一条禁令 ────────────────────────────────────────────────────────────┐ + │ **本驱动不提供写 `0x0A` 的接口,一个都不提供。** │ + │ `0x0A = 5` 是「请求 28 V EPR」。板上 `D3` 是 SMBJ22A(VRWM 22 V), │ + │ `U37` 的 EN 分压按 20 V 设计 —— 28 V 会把 TVS 打到雪崩。 │ + │ 这跟 `sr595.h` 不提供「抬 OE#」、`pca9555.h` 不提供「把接地脚配成输出」 │ + │ 是同一条规矩:**能烧板子的动作不给接口,而不是给了再叮嘱别用。** │ + └──────────────────────────────────────────────────────────────────────────┘ + + 挂在 TCA9548A 的**通道 5**(`TCA9548A_CH_PD`)上,不在主干。 */ +#ifndef CH224A_H +#define CH224A_H + +#include +#include + +#include "hal_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ⚠️⚠️ **这个地址还没有被实物证实**(计划 §11 的待定项): + R2 §36.2 只写了寄存器号,没写从机地址。所以: + · 所有接口都**收一个 addr 参数**,不写死; + · B1 上板扫描时把通道 5 上扫到的那个地址填回这里,并同步改计划 §5.2。 + 在那之前,用这个值跑出来的「扫不到」是**预期结果**,不是固件 bug。 */ +#define CH224A_ADDR_DEFAULT 0x23 + +/* 寄存器(R2 §36.2)。 */ +#define CH224A_REG_STATUS 0x09 /* 协商状态 / 当前档位 */ +#define CH224A_REG_CURRENT 0x50 /* 当前档位的最大电流,**单位 50 mA** */ + +/* 板上硬件固定请求的档位。`R7` 120 k 一颗电阻定死,固件改不了也不该改。 */ +#define CH224A_FIXED_MV 20000 + +/* 0x50 那个字节 → mA。 */ +uint32_t ch224a_ma_from_reg(uint8_t reg); + +/* mA → 整板可用功率(mW),按 20 V 档算。 + 60 W / 65 W / 100 W 三档在这里分别是 3000 / 3250 / 5000 mA。 */ +uint32_t ch224a_power_limit_mw(uint32_t ma); + +/* 地址不与主干上的器件撞车。⚠️ 它挂在通道 5 上,与刀片支路的 0x40 / 0x20 + 本来就不冲突,这里查的是「万一它和主干某个器件同址」—— + 那种情况下选到通道 5 时主干那位会被它盖住。 */ +bool ch224a_addr_ok(uint8_t addr); + +/* 认件:读状态寄存器。不应答 → HAL_ERR_NAK。 + ⚠️ **不校验内容**:这颗芯片没有 ID 寄存器,状态字的每一个值都是合法的。 */ +hal_err_t ch224a_probe(uint8_t addr); + +hal_err_t ch224a_read_status(uint8_t addr, uint8_t *status); + +/* 当前档位最大电流(mA)。读失败时 `*ma` 不动。 */ +hal_err_t ch224a_read_max_ma(uint8_t addr, uint32_t *ma); + +#ifdef __cplusplus +} +#endif + +#endif /* CH224A_H */ diff --git a/firmware/components/drv/include/ina226.h b/firmware/components/drv/include/ina226.h index 4b9f3b3..51edb1e 100644 --- a/firmware/components/drv/include/ina226.h +++ b/firmware/components/drv/include/ina226.h @@ -47,20 +47,48 @@ extern "C" { 不是任何一种错误。tasks.c 的表自检拿它和 20 ms 对一次。 */ #define INA226_CONV_US 8800 -/* ---- 地址表(设计文档 §8.2) ------------------------------------------- */ +/* ---- 地址表(**R2**:五片同址 + 输入侧一片,计划 §5.2) ----------------- -#define INA226_RAIL_COUNT 5 -#define INA226_COUNT 6 -#define INA226_ADDR_INPUT 0x43 /* U13,输入侧 V+I,整板功率预算用 */ + ⚠️⚠️ **R1 的「六片六个地址」在 R2 不成立了。** R2 的五片 INA226 各在一片刀片 + 上(`U3`),五片刀片完全同构 ⇒ **地址全是 0x40**,靠底板 TCA9548A 的通道区分 + (方案 D8)。板上出处:`.claude/r2/_blade_pcb_nets.json` 的 `U3` pin1/pin2 + (A1/A0)都接 GND。 -/* CH1~CH5 → U8~U12 的地址。rail 是 1~5,越界直接 abort。 - ⚠️ **`0x40 + ch` 是错的**,CH3/CH4/CH5 三路全错(0x44 / 0x45 / 0x42)。 - 地址按布线难度重排过(§8.2 那张 A1/A0 表),永远查表,不要算。 */ + 于是「查表不要算」这条规矩换了对象:**要查的不再是地址,而是 mux 通道** + (`rail_cfg.h` 的 `mux_ch`)。选错通道读到的是另一台手机的电压电流, + 而量纲、范围、变化趋势全都正常 —— 比 R1 的「地址算错」更难发现。 */ + +#define INA226_RAIL_COUNT 5 +#define INA226_COUNT 6 /* 物理片数:五片刀片 + 一片输入侧 */ + +/* 五片刀片上那一片的地址(**五片相同**)。 */ +#define INA226_ADDR_RAIL 0x40 + +/* U13,底板输入侧 V+I,整板功率预算与 PD 限功率用(计划 §5.9)。 + 挂**主干**,不在任何 mux 通道后面。R2 底板上 Kelvin 对调接,见 ina226_set_reversed()。 */ +#define INA226_ADDR_INPUT 0x43 + +/* 扫描用:总线上出现过的 INA226 地址只有这两个(不是六个)。 */ +#define INA226_ADDR_COUNT 2 + +/* 登记「这片的 IN+ / IN− 在板上对调接了」(IN+ 接分流电阻下游):之后驱动对它的 + 分流电压 / 电流取反,调用方看到的永远是「正 = 正常供电方向」。母线电压 / 功率不受影响。 + 默认全部正接(R1)。R2 底板的 U13(0x43)为了让两条 Kelvin 在 PCB 上不交叉而对调 + (2026-09-11),R2 固件启动时必须调 ina226_set_reversed(INA226_ADDR_INPUT, true), + 否则 SW-8 反灌判据会把正常供电当成反灌。地址不在 0x40~0x4F 的调用被忽略。 */ +void ina226_set_reversed(uint8_t addr, bool reversed); +bool ina226_is_reversed(uint8_t addr); + +/* CH1~CH5 那一片的地址。rail 是 1~5,越界直接 abort。 + ⚠️ **R2 里它对五路返回同一个值(0x40)**,这不是 bug。 + 真正区分是哪一路的是 `rail_cfg(rail)->mux_ch` —— 光有地址是选不中的, + 所以任何「拿到地址就直接读」的代码在 R2 上都读的是**上一次选中的那一路**。 */ uint8_t ina226_rail_addr(int rail); -extern const uint8_t ina226_all_addr[INA226_COUNT]; /* CH1..CH5 + 输入侧,扫描用 */ +extern const uint8_t ina226_addr_list[INA226_ADDR_COUNT]; /* {0x40, 0x43},扫描用 */ -/* 六个地址互不重复、且不与 DAC63204(0x4A/0x4B) / PCA9555(0x20) / LM75(0x48) 撞车。 +/* 两个地址不重复、都合法、且不与主干上的 DAC63204(0x4A/0x4B) / TCA9548A(0x70) + 撞车,也不与刀片支路上的 PCA9555(0x20) 撞车。 启动自检调一次;返回 false 说明表被改坏了。 */ bool ina226_addr_table_ok(void); @@ -68,7 +96,7 @@ bool ina226_addr_table_ok(void); typedef struct { int32_t bus_mv; /* 母线电压,1.25 mV/LSB */ - int32_t current_ua; /* **有符号** —— 反灌时为负(SW-8 靠这个判据) */ + int32_t current_ua; /* **有符号** —— 反灌时为负(SW-8 靠这个判据);对调接的片子已由驱动取反 */ } ina226_vi_t; typedef struct { diff --git a/firmware/components/drv/include/pca9555.h b/firmware/components/drv/include/pca9555.h index 7e29827..725fded 100644 --- a/firmware/components/drv/include/pca9555.h +++ b/firmware/components/drv/include/pca9555.h @@ -1,20 +1,33 @@ -/* PCA9555(U23,地址 0x20)—— 板上唯一的 I/O 扩展器,**16 个脚全部是输入**。 +/* PCA9555 —— **每片刀片上一颗**(`U4`,五片全是 0x20),R2 D9 取代 R1 的 + 74HC595 链。五片靠底板 TCA9548A 分通道区分(D8),所以从驱动看它们**完全一样**。 - 挂的是五路 eFuse 的 FLT#/PGOOD、Buck 的线或 PWRGD、六片 INA226 的线或 ALERT, - 共 12 个信号 + 4 个直接接地的备用脚(设计文档 §17.2)。 + ⚠️⚠️ **R1 那版驱动「16 个脚全是输入」的前提在 R2 不成立了。** + R2 的 P0.0~P0.4 是**输出**:三个侧键 + EDL + VBUS 开关。 + 拿 R1 的记忆改这个文件会在板子上冒烟,原因见下面那个框。 - ⚠️ **本驱动不提供「把某个脚配成输出」的接口,一个都不提供。** - 四个备用脚 P0_7 / P1_5~7 在板上**直接接地、不串限流电阻**(§17.2 的注, - 图纸上已标)。而 PCA9555 的 Output Port 寄存器**复位值是 0xFF**—— - 所以只要把其中一个配成输出,它下一刻就输出高电平,**直接对地短路**。 - 配错既不会报错也不会 NAK,只会发烫。所以方向字一律走 pca9555_dir_safe(), - 不安全直接 abort。这跟 sr595.h 不提供「抬 OE#」是同一条规矩。 + 位分配从 `.claude/r2/_blade_pcb_nets.json` 的 `U4` 逐脚读回(2026-09-16): - §10.10 的降级行为:这片不应答 = 失去 FAULT / PG 可见性,**不关输出**, - 改用 INA226 的电压判据兜底并持续告警。所以本驱动的读失败一律只是返回错误码, - 不做任何动作。 + Port0 bit0 KEY_PWR bit1 KEY_VUP bit2 KEY_VDN bit3 EDL_EN bit4 VBUS_EN + bit5~7 **直接接地** + Port1 bit0 EFUSE_FLT# bit1 BUCK_PG bit2 INA_ALERT bit3 EFUSE_PG + bit4~7 **直接接地** - 本驱动只经 hal_i2c_*,宿主机上由 sim 的假 PCA9555(`sim_pca9555_*`)全部测完。 */ + ┌─ ⛔⛔ 初始化顺序是这块板子上最危险的一处 ──────────────────────────────┐ + │ PCA9555 的 **Output Port 复位值是 0xFF**,Config 复位值也是 0xFF(全输入)。│ + │ 所以**必须先把输出锁存器写成 0,最后才改方向**: │ + │ OUTPUT0=0 → OUTPUT1=0 → POLINV=0 → CONFIG1=0xFF → CONFIG0=0xE0 │ + │ 顺序反过来的那一刻,P0.0~P0.4 一起输出 1 │ + │ = 三个键全按下 + EDL 短接 + VBUS 上电,五台手机一起被按住电源键。 │ + │ 而 P0.5~7 / P1.4~7 七个脚在刀片上**直接接地、不串限流电阻** —— │ + │ 方向写错一位就是对地短路:不报错、不 NAK,只会发烫。 │ + │ ⇒ `pca9555_dir_safe()` 是硬闸,走到 false 直接 abort。 │ + └──────────────────────────────────────────────────────────────────────────┘ + + §10.10 的降级行为:某片不应答 = 失去那一路的 FAULT / PG 可见性,**不关输出**, + 改用 INA226 的电压判据兜底并持续告警。所以本驱动的读失败一律只返回错误码。 + ⚠️ R2 新增的代价:这片不应答时**那一路的 VBUS 也控制不了了**(计划 §5.4)。 + + 本驱动只经 hal_i2c_*,**不碰 mux**:通道由调用方(`slot.c`)先选好。 */ #ifndef PCA9555_H #define PCA9555_H @@ -27,17 +40,13 @@ extern "C" { #endif -/* ---- 地址与引脚 ---------------------------------------------------------- */ +/* ---- 地址与实例 ---------------------------------------------------------- */ -/* A0 = A1 = A2 = GND(U23 的 pin21 / pin2 / pin3,已按 PCB 焊盘网络核过)。 */ +/* A0 = A1 = A2 = GND(`U4` 的 pin21 / pin2 / pin3,已按 PCB 焊盘网络核过)。 + **五片同址** —— 这是 D8 的全部意义,不是笔误。 */ #define PCA9555_ADDR 0x20 -/* `IOEXP_INT#`:开漏、低有效、板上 R608 10 kΩ 上拉,直连 ESP32 IO38(§8.1)。 - ⚠️ **中断只是加速路径,不是主路径**:§10.3 的 `t_safety` 每 20 ms 无条件轮询 - 读一次两个输入端口。原因是 PCA9555 的 INT 要**读走输入端口才会释放**,而且 - 输入自己变回原值时它也会释放 —— 只靠中断的话,漏掉一次读就再也等不到下一个沿。 - A6 只留这个宏,读它是 A8/A10 的活。 */ -#define PCA9555_PIN_INT_N 38 +#define PCA9555_SLOT_COUNT 5 /* ---- 寄存器(全部 8 位) ------------------------------------------------- */ @@ -50,86 +59,124 @@ extern "C" { #define PCA9555_REG_CONFIG0 0x06 #define PCA9555_REG_CONFIG1 0x07 -/* 方向寄存器:**1 = 输入**(复位值 0xFF)。本板全输入,所以只会写这一个值。 */ +/* 方向寄存器:**1 = 输入**(复位值 0xFF)。 */ #define PCA9555_DIR_ALL_INPUT 0xFFu -/* 极性反转寄存器必须是 0。写成非 0 的现象是「五路同时报故障」或者更糟的 - 「五路故障全都看不见」,而 I²C 一切正常 —— init 会写 0 并回读校验。 */ +/* 本板的两个方向字。Port0 只有 bit0~4 是输出。 */ +#define PCA9555_CONFIG0_INIT 0xE0u +#define PCA9555_CONFIG1_INIT 0xFFu + +/* 极性反转必须是 0。写成非 0 的现象是「故障全都看不见」,而 I²C 一切正常。 */ #define PCA9555_POLINV_NONE 0x00u -/* ---- 位分配(从 `.claude/pcb1-parts.json` 的真实焊盘网络逐脚核对) ------- +/* ---- Port0:五个输出位 --------------------------------------------------- */ - Port0 bit0~4 = EFUSE_FLT1~5 bit5 = BUCK_PG bit6 = INA_ALERT bit7 = 接地 - Port1 bit0~4 = EFUSE_PG1~5 bit5~7 = 接地 +/* 三个侧键的逻辑键位。**上层一律用这个枚举,不许写 `1 << 0`。** + (R1 时这个枚举在 `sr595.h` 里,链退役之后搬到这里。) */ +typedef enum { + PCA9555_KEY_PWR = 0, + PCA9555_KEY_VUP, + PCA9555_KEY_VDN, + PCA9555_KEY_COUNT, +} pca9555_key_t; - 这一次两张表**确实是顺排的**(`bit = rail - 1`),和 INA226 地址、595 位号 - 那两张表不一样。所以这里最容易写出来的错不是「算式」,而是 - **把 FLT 和 PG 的端口搞反** —— 两个端口的 bit0~4 长得一模一样, - 搞反之后「有故障」会被读成「电源良好」,方向正好相反。 - 所以取位号一律走下面两个函数,不要在别处写 `1 << (rail - 1)`。 */ +#define PCA9555_BIT_KEY_PWR 0 +#define PCA9555_BIT_KEY_VUP 1 +#define PCA9555_BIT_KEY_VDN 2 +#define PCA9555_BIT_EDL_EN 3 +#define PCA9555_BIT_VBUS_EN 4 -#define PCA9555_RAIL_COUNT 5 +#define PCA9555_P0_OUT_MASK 0x1Fu /* bit0~4:唯一允许配成输出的五位 */ +#define PCA9555_P0_GND_MASK 0xE0u /* bit5~7 焊死在地上 */ -#define PCA9555_BIT_BUCK_PG 5 /* Port0,五路 Buck 的 PWRGD 线或(都是开漏) */ -#define PCA9555_BIT_INA_ALERT 6 /* Port0,六片 INA226 的 ALERT 线或 */ +/* ---- Port1:四个输入位(全是开漏 + 上拉) -------------------------------- */ -/* 接地的备用脚。**永远、永远是输入。** */ -#define PCA9555_P0_GND_MASK 0x80u /* P0_7 */ -#define PCA9555_P1_GND_MASK 0xE0u /* P1_5 P1_6 P1_7 */ +#define PCA9555_BIT_EFUSE_FLT 0 +#define PCA9555_BIT_BUCK_PG 1 +#define PCA9555_BIT_INA_ALERT 2 +#define PCA9555_BIT_EFUSE_PG 3 -/* rail 是 **1~5**,越界 abort。返回的是**位号**(0~4),不是掩码。 */ -int pca9555_flt_bit(int rail); /* Port0 */ -int pca9555_pg_bit(int rail); /* Port1 */ +#define PCA9555_P1_IN_MASK 0x0Fu +#define PCA9555_P1_GND_MASK 0xF0u /* bit4~7 焊死在地上 */ -/* 12 个信号位互不重复、都没落到接地脚上、FLT 与 PG 分属两个端口。 - 启动自检调一次(A7,和 ina226_addr_table_ok() / sr595_map_ok() 一起)。 */ +/* 键 → Port0 位号。**不带路号**:五片刀片同构,位号与路号无关(计划 §5.7)。 + 越界 abort。 */ +int pca9555_key_bit(pca9555_key_t key); + +/* 一路上三个键的掩码(Port0)。`t_actuator` 的「防御性松键」用它。 */ +uint8_t pca9555_key_mask(void); + +/* 位分配自检:五个输出位互不重复、四个输入位互不重复、 + **一个信号都没落到接地脚上**、输出位与输入位分属两个端口。 + 启动自检调一次(和 `ina226_addr_table_ok()` 一起)。 */ bool pca9555_map_ok(void); -/* 0x20 不与 INA226(0x40/41/44/45/42/43) / DAC63204(0x4A/0x4B) / LM75(0x48) 撞车。 */ +/* 0x20 不与主干上的 TCA9548A(0x70) / INA226(0x43) / DAC63204(0x4A/0x4B) 撞车。 + ⚠️ 刀片上的 INA226 也是 0x40,与 0x20 本来就不同,两者同在一条支路上没问题。 */ bool pca9555_addr_ok(void); -/* dir 里 1 = 输入。返回 false = 这个方向字会把某个**接地脚**配成输出。 +/* `dir` 里 1 = 输入。返回 false = 这个方向字会把某个**接地脚**配成输出, + 或者把 Port1 上那四个开漏信号源配成输出(与 eFuse 的 FLT# 打架)。 驱动内部走到 false 直接 abort:悄悄放过去的后果是板子发烫,不是功能不好用。 */ bool pca9555_dir_safe(int port, uint8_t dir); -/* ---- 采样 ---------------------------------------------------------------- */ +/* ---- 器件操作(当前 mux 通道上那一片) ----------------------------------- */ +/* 认件:读回两个方向寄存器,全 0xFF(复位值)才算是它。 + 不应答 → HAL_ERR_NAK;应答了但方向寄存器不是复位值 → HAL_ERR_BUS。 + ⚠️ 已经被 `pca9555_init()` 配过的片子会返回 HAL_ERR_BUS —— 这是对的: + probe 的语义是「这是一片刚上电的 PCA9555 吗」。 */ +hal_err_t pca9555_probe(void); + +/* 按文件头那个框里的顺序初始化,并**逐个回读校验**。 + `slot` 是 0~`PCA9555_SLOT_COUNT-1`,**只用来记影子**,不参与总线操作 —— + 通道必须由调用方先选好(本驱动不碰 mux)。 */ +hal_err_t pca9555_init(int slot); + +/* 巡检主路径:**一次事务**读两个输入端口(PCA9555 的读指针在 0x00 / 0x01 之间 + toggle,这两个字节是 datasheet 保证的)。 */ typedef struct { uint8_t port0; uint8_t port1; } pca9555_in_t; -/* 认件:读回两个方向寄存器,全 0xFF(复位值)才算是它。 - 不应答 → HAL_ERR_NAK;应答了但方向寄存器不是复位值 → HAL_ERR_BUS - (这个地址上有东西,但不是一片刚上电的 PCA9555)。 */ -hal_err_t pca9555_probe(void); - -/* 写方向(全输入)与极性反转(全 0)并**逐个回读校验**,回读不一致 → HAL_ERR_BUS。 - ⚠️ Output Port 一个字节都不写:写它没有意义(没有输出脚),而写错方向的 - 后果见文件头。 */ -hal_err_t pca9555_init(void); - -/* 巡检主路径:**一次事务**读两个输入端口(§10.3 的 0.12 ms 预算就是按一次算的; - PCA9555 的读指针在 0x00 / 0x01 之间 toggle,这两个字节是 datasheet 保证的)。 */ hal_err_t pca9555_read_inputs(pca9555_in_t *out); -/* ---- 语义判读 ------------------------------------------------------------ - 四个信号**三种极性**,这是本文件第二个容易写反的地方。极性只在下面四个函数里 - 各出现一次(和 sr595_set_hub_reset() 里那个反相同一个道理:多一处就多一处写反 - 的机会)。上层一律问「有没有故障」,不许自己去 & 位。 */ +/* ---- Port0 输出:影子 + 唯一写入点 --------------------------------------- */ -/* TPS25940A 的 `FLT#`:开漏、板上 R113 47 kΩ 上拉 ⇒ **低 = 有故障**。 */ -bool pca9555_rail_fault(const pca9555_in_t *in, int rail); +/* 改 `mask` 指定的那几位为 `value` 里的值,其余位保持影子里的值,整字节写下去。 + 组合键的两位落在**同一次写**里(§10.6:分两次写会先单按一个键)。 -/* TPS25940A 的 `PGOOD`:开漏、R115 47 kΩ 上拉,输出 2.97 V 以上才释放 - ⇒ **高 = 电源良好**。 */ -bool pca9555_rail_pg(const pca9555_in_t *in, int rail); + ⚠️ **`slot` 必须与当前 mux 通道对应的那一路一致。** 驱动没法自己验证这件事, + 传错的后果是影子记在了另一片上 —— 下一次写会把这一片的键"顺手"改掉。 + 这就是 `slot.c` 存在的理由:别在 `slot.c` 之外调这个函数。 -/* 五路 Buck 的 `PWRGD` 线或(开漏)⇒ **高 = 五路都正常**;低只说明「有某一路 - 不正常」,看不出是哪一路(§16.1)。 */ + `mask` 里出现输出位以外的位 → HAL_ERR_ARG(不是 abort:那可能是上层算错了 + 掩码,返回错误码能让它记一条事件)。写失败时**影子不更新**。 */ +hal_err_t pca9555_write_out0(int slot, uint8_t mask, uint8_t value); + +uint8_t pca9555_shadow(int slot); /* 该片 Port0 输出锁存器的影子(init 后是 0) */ + +/* 把影子全部清成 0,**不碰总线**。给测试的 setUp() 与 A15 的降级路径用 + (「现在生效的是上电默认值」)。 */ +void pca9555_shadow_reset(void); + +/* ---- 语义判读(Port1) --------------------------------------------------- + 四个信号**两种极性**,这是本文件第二个容易写反的地方。极性只在下面四个函数里 + 各出现一次。上层一律问「有没有故障」,不许自己去 & 位。 */ + +/* TPS25940A 的 `FLT#`:开漏、上拉 ⇒ **低 = 有故障**。 */ +bool pca9555_efuse_fault(const pca9555_in_t *in); + +/* TPS25940A 的 `PGOOD`:开漏、上拉,输出过 2.97 V 才释放 ⇒ **高 = 电源良好**。 */ +bool pca9555_efuse_pg(const pca9555_in_t *in); + +/* 本路 Buck 的 `PWRGD` ⇒ 高 = 正常。 + ⚠️ R1 里这一位是**五路线或**,R2 里它只代表**本路**(一片刀片一个 Buck)。 */ bool pca9555_buck_pg(const pca9555_in_t *in); -/* 六片 INA226 的 `ALERT` 线或:开漏、低有效 ⇒ **低 = 有片子在告警**。 */ +/* 本片 INA226 的 `ALERT`:开漏、低有效 ⇒ **低 = 在告警**。 + ⚠️ R1 里是六片线或,R2 里刀片上的 ALERT 不跨板,只进本片的 P1.2。 */ bool pca9555_ina_alert(const pca9555_in_t *in); #ifdef __cplusplus diff --git a/firmware/components/drv/include/sr595.h b/firmware/components/drv/include/sr595.h index 79c9a43..c40dca7 100644 --- a/firmware/components/drv/include/sr595.h +++ b/firmware/components/drv/include/sr595.h @@ -1,133 +1,145 @@ -/* 74HC595 ×3 —— 一条 24 位移位链,带全固件唯一的影子寄存器(需求 §10.6)。 - - 链上挂着 **15 路侧键 + 5 路 EDL_EN + HUB_RST**,共 21 位有效、3 位空脚。 - 写任何一位都要把整条链重发一遍,所以: - - ⚠️ **内存里只有一份 24 位影子值,只有本文件的 `sr595_flush()` 会碰移位链。** - 任何任务改按键 / EDL 都是「`sr595_modify()` 改影子 → `sr595_flush()` 请求刷新」。 - 多处直接操作移位链的症状是**另一台手机的按键被顺手清掉**,而且要两个动作 - 正好撞在一起才复现 —— 属于会拖很久的那类 bug。 - - `RCLK` 打点之前输出不变 ⇒ 整帧刷新是原子的,组合键(VOL− + PWR)必须 - **一次 `sr595_write()` 两位一起给**,不能先后两次。 - - ⚠️ **不许用拉高 `OE#` 的方式「临时全松开」**(需求 §10.6):那会让 15 个栅极 - 同时进高阻,靠的是每个栅极自己的 100 kΩ 下拉 —— 那是**复位路径**,不是正常 - 控制路径。所以本头文件根本不提供「抬 OE#」的接口。 - - 本驱动只经 hal_gpio_*,宿主机上由 sim 的移位链观测器(`sim_sr595_*`)全部测完。 */ -#ifndef SR595_H -#define SR595_H - -#include -#include - -#include "hal_err.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* ---- 四根线(ESP32-S3 引脚,设计文档 §8.1 的表,§26.18 重排后) --------- */ - -#define SR595_PIN_SER 13 /* IO13 模组脚 21 → U21.DS(14) */ -#define SR595_PIN_SRCLK 12 /* IO12 模组脚 20 → 三片的 SRCLK(11) */ -#define SR595_PIN_RCLK 9 /* IO9 模组脚 17 → 三片的 RCLK(12) */ -#define SR595_PIN_OE_N 21 /* IO21 模组脚 23 → 三片的 OE#(13),板上另有 R609 100 kΩ 上拉 */ - -/* `MR#`(10) 三片都直接接 +3V3 —— 没有复位线,上电后移位寄存器内容是**不确定**的。 - 所以 `sr595_init()` 必须先把安全影子锁存进去、**再**驱动 `OE#`。 */ - -/* ---- 24 位的编号 --------------------------------------------------------- */ - -/* 位号 = **链上的物理位置**:0 = U21.QA(离 ESP32 最近的第一级),23 = U33.QH(链尾)。 - 一次移位 = 每一级往编号大的方向走一格,所以**先移入的位停在编号最大处**。 */ -#define SR595_CHIP_COUNT 3 -#define SR595_BITS 24 -#define SR595_MASK_ALL ((uint32_t)0x00FFFFFFu) - -#define SR595_CHIP_U21 0 /* 影子的 bit0~7 ;P3B 页,板上 y≈50(中)*/ -#define SR595_CHIP_U22 1 /* 影子的 bit8~15 ;P3B 页,板上 y≈35(南)*/ -#define SR595_CHIP_U33 2 /* 影子的 bit16~23;**P6 页**,板上 y≈70(北)*/ - -/* 位分配(**2026-09-06 按 PCB 几何重排**,设计文档 §26.18 C; - 此处的值是从 `.claude/pcb1-parts.json` 的真实焊盘网络逐脚核对出来的): - - U21 QA=EDL_EN4 QB=K1_PWR QC=K1_VUP QD=K1_VDN QE=空 QF=EDL_EN5 QG=EDL_EN3 QH=EDL_EN2 - U22 QA=空 QB=K4_PWR QC=K4_VUP QD=K4_VDN QE=空 QF=K5_VDN QG=K5_VUP QH=K5_PWR - U33 QA=EDL_EN1 QB=K2_VUP QC=K2_VDN QD=K2_PWR QE=K3_VDN QF=K3_VUP QG=K3_PWR QH=HUB_RST - - ⚠️ **「K1/K2/K3 顺排在 U21、U33 是 EDL 专用片」是旧版**(设计文档 §17.2 的 - 09-04 那张表),15 路里有 12 路对不上。三片本来就是一条链,谁在哪一位只是移位 - 的字节序 ⇒ **搞错了板子照样正常工作,只是按 CH1 的电源键按到了 CH4 的音量键**, - 功能测试完全测不出来。查表,不要算。 */ -#define SR595_BIT_HUB_RST 23 - -/* 空脚(U21.QE / U22.QA / U22.QE)。写它们没有意义,`sr595_modify()` 直接 abort。 */ -#define SR595_MASK_UNUSED (((uint32_t)1u << 4) | ((uint32_t)1u << 8) | ((uint32_t)1u << 12)) -#define SR595_MASK_VALID (SR595_MASK_ALL & ~SR595_MASK_UNUSED) - -typedef enum { - SR595_KEY_PWR = 0, - SR595_KEY_VUP, - SR595_KEY_VDN, - SR595_KEY_COUNT, -} sr595_key_t; - -/* rail 是 **1~5**,key 越界或 rail 越界一律 abort(那是调用方算错了位号)。 */ -int sr595_key_bit(int rail, sr595_key_t key); -uint32_t sr595_key_mask(int rail); /* 该路三键的掩码,「一路全松开」用得上 */ -int sr595_edl_bit(int rail); - -/* 15 路侧键的总掩码。安全巡检与「全松开」用。 */ -uint32_t sr595_all_keys_mask(void); - -/* 21 个有效位互不重复、都落在 SR595_MASK_VALID 里、空脚一个都没被占。 - 启动自检调一次(A7);返回 false = 位表被改坏了。 */ -bool sr595_map_ok(void); - -/* ---- 上电安全影子 -------------------------------------------------------- */ - -/* ⚠️ **`HUB_RST` 是低有效**(USB2517I 的 `RESET_N`(43),设计文档 §21.7, - 板上 R716 10 kΩ 上拉)。影子位 = 1 才是「Hub 正常跑」,= 0 是**按住复位**。 - 全 0 的影子会把 Hub 一直摁在复位里 —— 现象是「插上枚举不出来」, - 而五路手机的电压电流全都正常,看起来一点毛病没有。 - 所以上电安全值不是 0,是这个: */ -#define SR595_SHADOW_SAFE ((uint32_t)1u << SR595_BIT_HUB_RST) - -/* 复位是低有效,反相只在 `sr595_set_hub_reset()` 里出现这一次 - (和 `rail_mv_to_code()` 里那个减法同一个道理:多一处就多一处写反的机会)。 */ -void sr595_set_hub_reset(bool in_reset); - -/* ---- 器件操作 ------------------------------------------------------------ */ - -/* 三步,顺序不能换(上电时移位寄存器内容不确定,`MR#` 又接死在 +3V3): - ① SER / SRCLK / RCLK 配成输出并置低;**`OE#` 这时还是输入**, - 靠板上 R609 上拉着 ⇒ 三片输出保持高阻,15 个栅极被各自的 100 kΩ 拉低 - ② 把 `SR595_SHADOW_SAFE` 移进去并锁存 - ③ 这才把 `OE#` 配成输出并拉低,输出使能 - ②③ 换个个儿 = 上电瞬间把移位寄存器里的随机值放到 15 个栅极上, - 也就是**五台手机的电源键可能一起被按下去**。 */ -hal_err_t sr595_init(void); - -uint32_t sr595_shadow(void); /* 当前影子(未必已经刷下去,看 sr595_dirty()) */ -bool sr595_dirty(void); /* 影子和链上的值不一致 */ - -/* 影子 = (影子 & ~mask) | (value & mask),并置脏。**不碰总线**。 - mask 越出 SR595_MASK_VALID(含碰到空脚)直接 abort。 */ -void sr595_modify(uint32_t mask, uint32_t value); - -/* 把影子整帧发下去:U33 字节 → U22 字节 → U21 字节,每个字节 QH 先发, - 最后一个 RCLK 上升沿统一生效。不脏就直接返回 HAL_OK(不空发一帧)。 - 没 init 过 → HAL_ERR_STATE。 */ -hal_err_t sr595_flush(void); - -/* `sr595_modify()` + `sr595_flush()`。**组合键必须走它**—— - VOL− + PWR 要落在同一帧里,分两次写会先单按一个键。 */ -hal_err_t sr595_write(uint32_t mask, uint32_t value); - -#ifdef __cplusplus -} -#endif - -#endif /* SR595_H */ +/* ⛔⛔ **R2 已退役:这条 24 位链在现在的板子上不存在。** + (2026-09-16。R2 方案 D9 用「每片刀片一颗 PCA9555」取代了它, + 少 4 根跨板信号,而且复位态天然安全 —— PCA9555 上电是高阻输入, + 配栅极 100 k 下拉 ⇒ 按键必然松开;595 那套要靠 `KEY_OE#` 上拉才做得到同一件事。) + + 文件和测试**保留**(它自洽、测试全绿、不碰任何板级事实),但: + · 没有任何生产代码 include 它,`rail_cfg` / `t_actuator` 都已经改走 `pca9555.h`; + · 下面那些 `SR595_PIN_*` 的 GPIO 号是 **R1 底板**的,与 R2 的 `board_pins.h` 冲突 + (IO9/12/13/21 在 R2 上分别是 EFUSE_EN1/EN4/EN5 与 FAN_TACH_IN)—— + **别拿它们去查重、也别照着改 board_pins.h**; + · 三个逻辑键位(PWR/VUP/VDN)已经搬到 `pca9555.h` 的 `pca9555_key_t`。 + 要改 R2 的按键,去 `pca9555.h` 与 `main/slot.h`,不要动这个文件。 */ +/* 74HC595 ×3 —— 一条 24 位移位链,带全固件唯一的影子寄存器(需求 §10.6)。 + + 链上挂着 **15 路侧键 + 5 路 EDL_EN + HUB_RST**,共 21 位有效、3 位空脚。 + 写任何一位都要把整条链重发一遍,所以: + + ⚠️ **内存里只有一份 24 位影子值,只有本文件的 `sr595_flush()` 会碰移位链。** + 任何任务改按键 / EDL 都是「`sr595_modify()` 改影子 → `sr595_flush()` 请求刷新」。 + 多处直接操作移位链的症状是**另一台手机的按键被顺手清掉**,而且要两个动作 + 正好撞在一起才复现 —— 属于会拖很久的那类 bug。 + + `RCLK` 打点之前输出不变 ⇒ 整帧刷新是原子的,组合键(VOL− + PWR)必须 + **一次 `sr595_write()` 两位一起给**,不能先后两次。 + + ⚠️ **不许用拉高 `OE#` 的方式「临时全松开」**(需求 §10.6):那会让 15 个栅极 + 同时进高阻,靠的是每个栅极自己的 100 kΩ 下拉 —— 那是**复位路径**,不是正常 + 控制路径。所以本头文件根本不提供「抬 OE#」的接口。 + + 本驱动只经 hal_gpio_*,宿主机上由 sim 的移位链观测器(`sim_sr595_*`)全部测完。 */ +#ifndef SR595_H +#define SR595_H + +#include +#include + +#include "hal_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- 四根线(ESP32-S3 引脚,设计文档 §8.1 的表,§26.18 重排后) --------- */ + +#define SR595_PIN_SER 13 /* IO13 模组脚 21 → U21.DS(14) */ +#define SR595_PIN_SRCLK 12 /* IO12 模组脚 20 → 三片的 SRCLK(11) */ +#define SR595_PIN_RCLK 9 /* IO9 模组脚 17 → 三片的 RCLK(12) */ +#define SR595_PIN_OE_N 21 /* IO21 模组脚 23 → 三片的 OE#(13),板上另有 R609 100 kΩ 上拉 */ + +/* `MR#`(10) 三片都直接接 +3V3 —— 没有复位线,上电后移位寄存器内容是**不确定**的。 + 所以 `sr595_init()` 必须先把安全影子锁存进去、**再**驱动 `OE#`。 */ + +/* ---- 24 位的编号 --------------------------------------------------------- */ + +/* 位号 = **链上的物理位置**:0 = U21.QA(离 ESP32 最近的第一级),23 = U33.QH(链尾)。 + 一次移位 = 每一级往编号大的方向走一格,所以**先移入的位停在编号最大处**。 */ +#define SR595_CHIP_COUNT 3 +#define SR595_BITS 24 +#define SR595_MASK_ALL ((uint32_t)0x00FFFFFFu) + +#define SR595_CHIP_U21 0 /* 影子的 bit0~7 ;P3B 页,板上 y≈50(中)*/ +#define SR595_CHIP_U22 1 /* 影子的 bit8~15 ;P3B 页,板上 y≈35(南)*/ +#define SR595_CHIP_U33 2 /* 影子的 bit16~23;**P6 页**,板上 y≈70(北)*/ + +/* 位分配(**2026-09-06 按 PCB 几何重排**,设计文档 §26.18 C; + 此处的值是从 `.claude/pcb1-parts.json` 的真实焊盘网络逐脚核对出来的): + + U21 QA=EDL_EN4 QB=K1_PWR QC=K1_VUP QD=K1_VDN QE=空 QF=EDL_EN5 QG=EDL_EN3 QH=EDL_EN2 + U22 QA=空 QB=K4_PWR QC=K4_VUP QD=K4_VDN QE=空 QF=K5_VDN QG=K5_VUP QH=K5_PWR + U33 QA=EDL_EN1 QB=K2_VUP QC=K2_VDN QD=K2_PWR QE=K3_VDN QF=K3_VUP QG=K3_PWR QH=HUB_RST + + ⚠️ **「K1/K2/K3 顺排在 U21、U33 是 EDL 专用片」是旧版**(设计文档 §17.2 的 + 09-04 那张表),15 路里有 12 路对不上。三片本来就是一条链,谁在哪一位只是移位 + 的字节序 ⇒ **搞错了板子照样正常工作,只是按 CH1 的电源键按到了 CH4 的音量键**, + 功能测试完全测不出来。查表,不要算。 */ +#define SR595_BIT_HUB_RST 23 + +/* 空脚(U21.QE / U22.QA / U22.QE)。写它们没有意义,`sr595_modify()` 直接 abort。 */ +#define SR595_MASK_UNUSED (((uint32_t)1u << 4) | ((uint32_t)1u << 8) | ((uint32_t)1u << 12)) +#define SR595_MASK_VALID (SR595_MASK_ALL & ~SR595_MASK_UNUSED) + +typedef enum { + SR595_KEY_PWR = 0, + SR595_KEY_VUP, + SR595_KEY_VDN, + SR595_KEY_COUNT, +} sr595_key_t; + +/* rail 是 **1~5**,key 越界或 rail 越界一律 abort(那是调用方算错了位号)。 */ +int sr595_key_bit(int rail, sr595_key_t key); +uint32_t sr595_key_mask(int rail); /* 该路三键的掩码,「一路全松开」用得上 */ +int sr595_edl_bit(int rail); + +/* 15 路侧键的总掩码。安全巡检与「全松开」用。 */ +uint32_t sr595_all_keys_mask(void); + +/* 21 个有效位互不重复、都落在 SR595_MASK_VALID 里、空脚一个都没被占。 + 启动自检调一次(A7);返回 false = 位表被改坏了。 */ +bool sr595_map_ok(void); + +/* ---- 上电安全影子 -------------------------------------------------------- */ + +/* ⚠️ **`HUB_RST` 是低有效**(USB2517I 的 `RESET_N`(43),设计文档 §21.7, + 板上 R716 10 kΩ 上拉)。影子位 = 1 才是「Hub 正常跑」,= 0 是**按住复位**。 + 全 0 的影子会把 Hub 一直摁在复位里 —— 现象是「插上枚举不出来」, + 而五路手机的电压电流全都正常,看起来一点毛病没有。 + 所以上电安全值不是 0,是这个: */ +#define SR595_SHADOW_SAFE ((uint32_t)1u << SR595_BIT_HUB_RST) + +/* 复位是低有效,反相只在 `sr595_set_hub_reset()` 里出现这一次 + (和 `rail_mv_to_code()` 里那个减法同一个道理:多一处就多一处写反的机会)。 */ +void sr595_set_hub_reset(bool in_reset); + +/* ---- 器件操作 ------------------------------------------------------------ */ + +/* 三步,顺序不能换(上电时移位寄存器内容不确定,`MR#` 又接死在 +3V3): + ① SER / SRCLK / RCLK 配成输出并置低;**`OE#` 这时还是输入**, + 靠板上 R609 上拉着 ⇒ 三片输出保持高阻,15 个栅极被各自的 100 kΩ 拉低 + ② 把 `SR595_SHADOW_SAFE` 移进去并锁存 + ③ 这才把 `OE#` 配成输出并拉低,输出使能 + ②③ 换个个儿 = 上电瞬间把移位寄存器里的随机值放到 15 个栅极上, + 也就是**五台手机的电源键可能一起被按下去**。 */ +hal_err_t sr595_init(void); + +uint32_t sr595_shadow(void); /* 当前影子(未必已经刷下去,看 sr595_dirty()) */ +bool sr595_dirty(void); /* 影子和链上的值不一致 */ + +/* 影子 = (影子 & ~mask) | (value & mask),并置脏。**不碰总线**。 + mask 越出 SR595_MASK_VALID(含碰到空脚)直接 abort。 */ +void sr595_modify(uint32_t mask, uint32_t value); + +/* 把影子整帧发下去:U33 字节 → U22 字节 → U21 字节,每个字节 QH 先发, + 最后一个 RCLK 上升沿统一生效。不脏就直接返回 HAL_OK(不空发一帧)。 + 没 init 过 → HAL_ERR_STATE。 */ +hal_err_t sr595_flush(void); + +/* `sr595_modify()` + `sr595_flush()`。**组合键必须走它**—— + VOL− + PWR 要落在同一帧里,分两次写会先单按一个键。 */ +hal_err_t sr595_write(uint32_t mask, uint32_t value); + +#ifdef __cplusplus +} +#endif + +#endif /* SR595_H */ diff --git a/firmware/components/drv/include/tca9548a.h b/firmware/components/drv/include/tca9548a.h new file mode 100644 index 0000000..b921204 --- /dev/null +++ b/firmware/components/drv/include/tca9548a.h @@ -0,0 +1,79 @@ +/* TCA9548A —— 底板上的 I²C 八路多路器(U35,0x70),R2 新增(方案 D8)。 + + 它的存在让五片刀片可以**完全同构**:每片上都是 0x40 的 INA226 + 0x20 的 + PCA9555,零 strap 电阻、五片可互换。代价是**每一次访问刀片都先得选通道**。 + + ---- 这颗器件的三件反直觉的事 -------------------------------------------- + + 1. **它没有寄存器地址。** 整个器件只有一个 8 位控制寄存器,写就是「发一个 + 字节」、读就是「收一个字节」,**不带寄存器指针**。所以这里用的是 + `hal_i2c_write(addr, &byte, 1)` 而不是常见的「写 reg + 写 val」。 + 按后者写会把通道选成 `reg` 那个字节的值 —— 而且**不会报错**。 + + 2. **控制字是掩码不是编号。** bit n = 1 表示通道 n 接通,**可以同时开多路**。 + 同时开两路的后果是两片同地址的 INA226 一起应答,读回来的是两片的**线与** + (量纲正常、数值偏小、还很稳)。所以本驱动只接受「恰好一路」或「全关」, + 多路那种用法在这块板上永远是 bug。 + + 3. **`RESET#` 只复位它自己**(把通道全关),不影响挂在支路上的器件。 + 复位脉冲很短(datasheet 最小 6 ns),但复位之后**通道是全关的** —— + 所以调用方的通道影子必须一起作废,见 `i2cbus.h`。 + + ⚠️ 本驱动**不做加锁,也不管影子**。「选通道 + 事务」的原子性是 `i2cbus` + 那一层的事(计划 §4.3)—— 混在这里会让驱动没法单独测。 */ +#ifndef TCA9548A_H +#define TCA9548A_H + +#include +#include + +#include "hal_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* A0 = A1 = A2 = GND(U35 的 pin1 / pin2 / pin21,已按 PCB 焊盘网络核过)。 */ +#define TCA9548A_ADDR 0x70 + +#define TCA9548A_CH_COUNT 8 + +/* 全关。`tca9548a_select()` 传这个值 = 断开所有支路,只剩主干可见 + (主干上是 DAC63204 ×2 + 输入侧 INA226 + CH224A 以外的东西)。 */ +#define TCA9548A_CH_NONE (-1) + +/* 通道 → 去哪(`.claude/r2/_base_pcb_nets.json` 的 `U35` 逐脚读回,2026-09-16)。 + ⚠️ 槽 1~5 这次**确实是顺排的**,但通道号仍然从 `rail_cfg` 那张表里查 —— + 顺排是当下的事实,不是不变量。 */ +#define TCA9548A_CH_SLOT1 0 +#define TCA9548A_CH_SLOT5 4 +#define TCA9548A_CH_PD 5 /* SDA6/SCL6 → U36 CH224A(PD 受电芯片) */ +/* 通道 6 / 7 悬空。 */ + +/* 0x70 不与 INA226(0x40/0x43) / DAC63204(0x4A/0x4B) / PCA9555(0x20) 撞车。 + 启动自检调一次。 */ +bool tca9548a_addr_ok(void); + +/* 选通道。`ch` 是 0~7 或 `TCA9548A_CH_NONE`;其它值 → HAL_ERR_ARG。 + **一次只开一路**(见文件头第 2 条)。 */ +hal_err_t tca9548a_select(uint8_t addr, int ch); + +/* 读回控制字(一个字节,不带寄存器指针)。 + 用来验证「选通道真的生效了」—— 写下去不报错但没生效是这颗器件最难查的失效 + (比如上拉太弱、支路被某个器件拉死)。 */ +hal_err_t tca9548a_read_mask(uint8_t addr, uint8_t *mask); + +/* 认件:全关之后读回控制字,必须是 0。 + 不应答 → HAL_ERR_NAK;应答了但读回不是 0 → HAL_ERR_BUS(地址上有东西, + 但它不是一颗听话的 TCA9548A)。 */ +hal_err_t tca9548a_probe(uint8_t addr); + +/* 掩码 → 通道号。恰好一位才返回 0~7;全 0 返回 `TCA9548A_CH_NONE`; + **多于一位返回 -2**(见文件头第 2 条:那是 bug,不是一种用法)。 */ +int tca9548a_mask_to_ch(uint8_t mask); + +#ifdef __cplusplus +} +#endif + +#endif /* TCA9548A_H */ diff --git a/firmware/components/drv/pca9555.c b/firmware/components/drv/pca9555.c index 58b653c..af7b6fc 100644 --- a/firmware/components/drv/pca9555.c +++ b/firmware/components/drv/pca9555.c @@ -1,4 +1,5 @@ -/* PCA9555 驱动。只经 hal_i2c_*,不碰任何平台头文件。 */ +/* PCA9555 驱动(R2:每片刀片一颗,P0.0~P0.4 是输出)。 + 只经 hal_i2c_*,不碰 mux、不碰任何平台头文件。契约见 pca9555.h。 */ #include "pca9555.h" #include @@ -6,47 +7,83 @@ #include "hal_i2c.h" -/* 位表见 pca9555.h。这两张表**这次确实是顺排的**,但仍然写成表: - 哪天硬件改一根线,改这里一处就够,而不是满工程找 `1 << (rail - 1)`。 */ -static const uint8_t s_flt_bit[PCA9555_RAIL_COUNT] = { 0, 1, 2, 3, 4 }; /* Port0 */ -static const uint8_t s_pg_bit[PCA9555_RAIL_COUNT] = { 0, 1, 2, 3, 4 }; /* Port1 */ +/* 键 → Port0 位号。写成表而不是 `key + 0`:哪天硬件改一根线,改这里一处就够。 */ +static const uint8_t s_key_bit[PCA9555_KEY_COUNT] = { + PCA9555_BIT_KEY_PWR, + PCA9555_BIT_KEY_VUP, + PCA9555_BIT_KEY_VDN, +}; -int pca9555_flt_bit(int rail) +/* 五片各自的 Port0 输出影子。**写成功才更新**(见 pca9555_write_out0)。 */ +static uint8_t s_out0[PCA9555_SLOT_COUNT]; + +int pca9555_key_bit(pca9555_key_t key) { - assert(rail >= 1 && rail <= PCA9555_RAIL_COUNT); /* 越界是调用方算错了,不许悄悄返回 */ - return s_flt_bit[rail - 1]; + assert(key >= 0 && key < PCA9555_KEY_COUNT); /* 越界是调用方算错了 */ + return s_key_bit[key]; } -int pca9555_pg_bit(int rail) +uint8_t pca9555_key_mask(void) { - assert(rail >= 1 && rail <= PCA9555_RAIL_COUNT); - return s_pg_bit[rail - 1]; + uint8_t m = 0; + for (int k = 0; k < PCA9555_KEY_COUNT; k++) { + m |= (uint8_t)(1u << s_key_bit[k]); + } + return m; } bool pca9555_map_ok(void) { - uint8_t p0 = 0, p1 = 0; + uint8_t p0 = 0; - for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) { - const uint8_t f = (uint8_t)(1u << s_flt_bit[rail - 1]); - const uint8_t g = (uint8_t)(1u << s_pg_bit[rail - 1]); - if ((p0 & f) != 0 || (p1 & g) != 0) { - return false; /* 两路占了同一位 */ + /* 三个键互不重复。 */ + for (int k = 0; k < PCA9555_KEY_COUNT; k++) { + const uint8_t b = (uint8_t)(1u << s_key_bit[k]); + if ((p0 & b) != 0) { + return false; } - p0 |= f; - p1 |= g; + p0 |= b; + } + /* EDL 与 VBUS 也在 Port0,同样不许撞。 */ + const uint8_t edl = (uint8_t)(1u << PCA9555_BIT_EDL_EN); + const uint8_t vbus = (uint8_t)(1u << PCA9555_BIT_VBUS_EN); + if ((p0 & edl) != 0 || (p0 & vbus) != 0 || edl == vbus) { + return false; + } + p0 |= (uint8_t)(edl | vbus); + + /* 五个输出位必须正好等于「允许配成输出」的那五位: + 少一位 = 有个输出永远发不出去;多一位 = 方向字会把接地脚驱起来。 */ + if (p0 != PCA9555_P0_OUT_MASK) { + return false; + } + if ((p0 & PCA9555_P0_GND_MASK) != 0) { + return false; } - const uint8_t buck = (uint8_t)(1u << PCA9555_BIT_BUCK_PG); - const uint8_t alrt = (uint8_t)(1u << PCA9555_BIT_INA_ALERT); - if ((p0 & buck) != 0 || (p0 & alrt) != 0 || buck == alrt) { - return false; /* 线或的两根撞到某路 FLT 上,或者撞到一起 */ + /* Port1 的四个输入位互不重复、都不落在接地脚上。 */ + const uint8_t in_bits[] = { + (uint8_t)(1u << PCA9555_BIT_EFUSE_FLT), + (uint8_t)(1u << PCA9555_BIT_BUCK_PG), + (uint8_t)(1u << PCA9555_BIT_INA_ALERT), + (uint8_t)(1u << PCA9555_BIT_EFUSE_PG), + }; + uint8_t p1 = 0; + for (size_t i = 0; i < sizeof(in_bits); i++) { + if ((p1 & in_bits[i]) != 0) { + return false; + } + p1 |= in_bits[i]; + } + if (p1 != PCA9555_P1_IN_MASK || (p1 & PCA9555_P1_GND_MASK) != 0) { + return false; } - p0 |= (uint8_t)(buck | alrt); - /* 一个信号都不许落到接地脚上 —— 落上去就等于永远读到 0(FLT 永远报故障, - PG 永远报「不良」),而 I²C 一切正常。 */ - if ((p0 & PCA9555_P0_GND_MASK) != 0 || (p1 & PCA9555_P1_GND_MASK) != 0) { + /* 两个方向字必须与位分配自洽:Port0 上「不是输入」的正好是那五个输出位。 */ + if ((uint8_t)~PCA9555_CONFIG0_INIT != PCA9555_P0_OUT_MASK) { + return false; + } + if (PCA9555_CONFIG1_INIT != PCA9555_DIR_ALL_INPUT) { return false; } return true; @@ -54,8 +91,8 @@ bool pca9555_map_ok(void) bool pca9555_addr_ok(void) { - /* 同一条 I²C 上的其它器件(§8.2)。 */ - static const uint8_t others[] = { 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x48, 0x4A, 0x4B }; + /* 主干上的器件(计划 §5.2)。刀片支路上另有 0x40 的 INA226,与 0x20 不冲突。 */ + static const uint8_t others[] = { 0x40, 0x43, 0x4A, 0x4B, 0x70 }; if (PCA9555_ADDR > 0x7f) { return false; @@ -70,19 +107,22 @@ bool pca9555_addr_ok(void) bool pca9555_dir_safe(int port, uint8_t dir) { - if (port != 0 && port != 1) { - return false; + /* 1 = 输入。 */ + if (port == 0) { + /* 接地脚必须是输入,一位都不能少。bit0~4 随意(它们就是要当输出的)。 */ + return (dir & PCA9555_P0_GND_MASK) == PCA9555_P0_GND_MASK; } - /* 1 = 输入。接地脚对应的位必须是 1,一位都不能少。 */ - const uint8_t gnd = (port == 0) ? PCA9555_P0_GND_MASK : PCA9555_P1_GND_MASK; - return (dir & gnd) == gnd; + if (port == 1) { + /* Port1 **一位都不许输出**:bit0~3 接的是 eFuse / Buck / INA 的开漏输出, + 配成输出就是两个驱动器对打;bit4~7 焊死在地上。 */ + return dir == PCA9555_DIR_ALL_INPUT; + } + return false; } /* ---- 寄存器读写 --------------------------------------------------------- - PCA9555 的寄存器是 8 位的,命令字节在**成对**的寄存器之间 toggle - (0/1、2/3、4/5、6/7),不是无限自增。本驱动只在一对之内连读 2 字节, - 落在 datasheet 保证的范围里;跨对连读(比如从 0x06 读 4 字节)行为不同, - 别那么写。 */ + PCA9555 的命令字节在**成对**的寄存器之间 toggle(0/1、2/3、4/5、6/7), + 不是无限自增。本驱动只在一对之内连读 2 字节,落在 datasheet 保证的范围里。 */ static hal_err_t read_reg(uint8_t reg, uint8_t *val) { @@ -121,34 +161,53 @@ hal_err_t pca9555_probe(void) if (err != HAL_OK) { return err; } - /* 刚上电的 PCA9555 两个方向寄存器都是 0xFF。不是的话,要么这地址上蹲着别的 - 器件,要么它已经被谁配过了 —— 两种都要在启动时就叫出来。 */ return (c0 == PCA9555_DIR_ALL_INPUT && c1 == PCA9555_DIR_ALL_INPUT) ? HAL_OK : HAL_ERR_BUS; } -hal_err_t pca9555_init(void) +hal_err_t pca9555_init(int slot) { - /* 方向必须先于一切:这一步写错就是四个接地脚被驱动成高电平。 - 全输入是唯一合法值,assert 拦的是「哪天有人顺手改了这个常量」。 */ - assert(pca9555_dir_safe(0, PCA9555_DIR_ALL_INPUT)); - assert(pca9555_dir_safe(1, PCA9555_DIR_ALL_INPUT)); + if (slot < 0 || slot >= PCA9555_SLOT_COUNT) { + return HAL_ERR_ARG; + } + /* 方向字的合法性是编译期就能判的事,assert 拦的是「哪天有人顺手改了常量」。 */ + assert(pca9555_dir_safe(0, PCA9555_CONFIG0_INIT)); + assert(pca9555_dir_safe(1, PCA9555_CONFIG1_INIT)); - hal_err_t err = write_verify(PCA9555_REG_CONFIG0, PCA9555_DIR_ALL_INPUT); + /* ⛔ 顺序就是安全本身(头文件那个框):**输出锁存器先清零,方向最后改**。 + 复位值 0xFF 的输出锁存器 + 先改方向 = 三个键全按下 + VBUS 上电。 */ + hal_err_t err = write_verify(PCA9555_REG_OUTPUT0, 0x00); if (err != HAL_OK) { return err; } - err = write_verify(PCA9555_REG_CONFIG1, PCA9555_DIR_ALL_INPUT); + err = write_verify(PCA9555_REG_OUTPUT1, 0x00); if (err != HAL_OK) { return err; } /* 极性反转清零。复位值本来就是 0,但「本来就是」不值得赌 —— 看门狗复位 - 只复位 ESP32,PCA9555 是不掉电的,上一条命的残留值会原样留着。 */ + 只复位 ESP32,刀片上的 PCA9555 是不掉电的,上一条命的残留值会原样留着。 */ err = write_verify(PCA9555_REG_POLINV0, PCA9555_POLINV_NONE); if (err != HAL_OK) { return err; } - return write_verify(PCA9555_REG_POLINV1, PCA9555_POLINV_NONE); + err = write_verify(PCA9555_REG_POLINV1, PCA9555_POLINV_NONE); + if (err != HAL_OK) { + return err; + } + + /* Port1 先(它本来就该保持全输入),Port0 最后 —— 这一句之后五个输出脚 + 才真的开始驱动,而它们的锁存器在上面已经是 0 了。 */ + err = write_verify(PCA9555_REG_CONFIG1, PCA9555_CONFIG1_INIT); + if (err != HAL_OK) { + return err; + } + err = write_verify(PCA9555_REG_CONFIG0, PCA9555_CONFIG0_INIT); + if (err != HAL_OK) { + return err; + } + + s_out0[slot] = 0; + return HAL_OK; } hal_err_t pca9555_read_inputs(pca9555_in_t *out) @@ -167,28 +226,60 @@ hal_err_t pca9555_read_inputs(pca9555_in_t *out) return HAL_OK; } -/* ---- 语义判读(极性各出现一次,见头文件) -------------------------------- */ - -bool pca9555_rail_fault(const pca9555_in_t *in, int rail) +hal_err_t pca9555_write_out0(int slot, uint8_t mask, uint8_t value) { - assert(in != NULL); - return (in->port0 & (uint8_t)(1u << pca9555_flt_bit(rail))) == 0; /* FLT# 低有效 */ + if (slot < 0 || slot >= PCA9555_SLOT_COUNT) { + return HAL_ERR_ARG; + } + if ((mask & ~PCA9555_P0_OUT_MASK) != 0) { + /* 掩码碰到了接地脚或者别的端口。不 abort:这多半是上层算错了掩码, + 返回错误码能让它记一条事件;真正会冒烟的是**方向**,那条走 abort。 */ + return HAL_ERR_ARG; + } + + const uint8_t next = (uint8_t)((s_out0[slot] & ~mask) | (value & mask)); + const hal_err_t err = write_reg(PCA9555_REG_OUTPUT0, next); + if (err == HAL_OK) { + s_out0[slot] = next; /* 影子只在写成功之后更新 */ + } + return err; } -bool pca9555_rail_pg(const pca9555_in_t *in, int rail) +uint8_t pca9555_shadow(int slot) +{ + assert(slot >= 0 && slot < PCA9555_SLOT_COUNT); + return s_out0[slot]; +} + +void pca9555_shadow_reset(void) +{ + for (int i = 0; i < PCA9555_SLOT_COUNT; i++) { + s_out0[i] = 0; + } +} + +/* ---- 语义判读(极性各出现一次,见头文件) -------------------------------- */ + +bool pca9555_efuse_fault(const pca9555_in_t *in) { assert(in != NULL); - return (in->port1 & (uint8_t)(1u << pca9555_pg_bit(rail))) != 0; /* PGOOD 高 = 良好 */ + return (in->port1 & (uint8_t)(1u << PCA9555_BIT_EFUSE_FLT)) == 0; /* FLT# 低有效 */ +} + +bool pca9555_efuse_pg(const pca9555_in_t *in) +{ + assert(in != NULL); + return (in->port1 & (uint8_t)(1u << PCA9555_BIT_EFUSE_PG)) != 0; /* 高 = 良好 */ } bool pca9555_buck_pg(const pca9555_in_t *in) { assert(in != NULL); - return (in->port0 & (uint8_t)(1u << PCA9555_BIT_BUCK_PG)) != 0; /* 高 = 五路都正常 */ + return (in->port1 & (uint8_t)(1u << PCA9555_BIT_BUCK_PG)) != 0; /* 高 = 正常 */ } bool pca9555_ina_alert(const pca9555_in_t *in) { assert(in != NULL); - return (in->port0 & (uint8_t)(1u << PCA9555_BIT_INA_ALERT)) == 0; /* ALERT 低有效 */ + return (in->port1 & (uint8_t)(1u << PCA9555_BIT_INA_ALERT)) == 0; /* 低有效 */ } diff --git a/firmware/components/drv/tca9548a.c b/firmware/components/drv/tca9548a.c new file mode 100644 index 0000000..8c89675 --- /dev/null +++ b/firmware/components/drv/tca9548a.c @@ -0,0 +1,75 @@ +/* TCA9548A 驱动。只经 hal_i2c_*,不碰任何平台头文件。契约见 tca9548a.h。 */ +#include "tca9548a.h" + +#include + +#include "hal_i2c.h" + +bool tca9548a_addr_ok(void) +{ + /* 同一条主干上的其它器件。撞上任何一个都是硬件表被改坏了。 + ⚠️ 0x40 / 0x20 **不在**这张表里:它们在 mux 的支路上,与主干不冲突 —— + 这正是 D8 的全部意义。 */ + static const uint8_t others[] = { 0x43, 0x4A, 0x4B }; + + if (TCA9548A_ADDR > 0x7f) { + return false; + } + for (size_t i = 0; i < sizeof(others); i++) { + if (others[i] == TCA9548A_ADDR) { + return false; + } + } + return true; +} + +hal_err_t tca9548a_select(uint8_t addr, int ch) +{ + if (ch < TCA9548A_CH_NONE || ch >= TCA9548A_CH_COUNT) { + return HAL_ERR_ARG; + } + /* 一个字节,**没有寄存器指针**(文件头第 1 条)。 */ + const uint8_t mask = (ch == TCA9548A_CH_NONE) ? 0u : (uint8_t)(1u << ch); + return hal_i2c_write(addr, &mask, 1); +} + +hal_err_t tca9548a_read_mask(uint8_t addr, uint8_t *mask) +{ + if (mask == NULL) { + return HAL_ERR_ARG; + } + return hal_i2c_read(addr, mask, 1); +} + +hal_err_t tca9548a_probe(uint8_t addr) +{ + hal_err_t err = tca9548a_select(addr, TCA9548A_CH_NONE); + if (err != HAL_OK) { + return err; + } + uint8_t mask = 0xFF; + err = tca9548a_read_mask(addr, &mask); + if (err != HAL_OK) { + return err; + } + /* 刚被写成全关的器件读回来必须是 0。不是的话要么这地址上蹲着别的东西, + 要么写根本没生效 —— 后者在真板上是「所有刀片都读不到」, + 而这里能在启动自检时就叫出来。 */ + return (mask == 0) ? HAL_OK : HAL_ERR_BUS; +} + +int tca9548a_mask_to_ch(uint8_t mask) +{ + if (mask == 0) { + return TCA9548A_CH_NONE; + } + if ((mask & (uint8_t)(mask - 1u)) != 0) { + return -2; /* 多于一位:见文件头第 2 条 */ + } + for (int ch = 0; ch < TCA9548A_CH_COUNT; ch++) { + if (mask == (uint8_t)(1u << ch)) { + return ch; + } + } + return -2; /* 到不了 */ +} diff --git a/firmware/components/phonerail_hal/include/hal_lock.h b/firmware/components/phonerail_hal/include/hal_lock.h index a269226..be5de39 100644 --- a/firmware/components/phonerail_hal/include/hal_lock.h +++ b/firmware/components/phonerail_hal/include/hal_lock.h @@ -1,82 +1,90 @@ -/* 互斥锁 —— HAL 的第五样东西,而且**只有两把**(需求 §10.3)。 - - 计划 §4.1 写着「HAL 只抽象四样:I²C / GPIO / PWM-定时 / 单调时钟,不要抽象得更多」。 - 这里多出来的第五样是有理由的:§10.3 的五个任务共享的资源正好是两个 - —— I²C 总线(10 个器件在同一条上)和 74HC595 的 24 位链 —— 而「谁在用总线」 - 这件事没法用前四样表达。 - - 为了不让它长成一个通用的锁库,这里**没有创建接口**:两把锁是枚举出来的, - 编译期就这么多,也不许再加第三把。要加第三把之前先回头看一眼 §10.3 - ——「共享资源只有两个」是架构结论,不是当时懒得数。 - - ⚠️ **两把锁不许嵌套持有。** 595 那条链是纯 GPIO(SER/SRCLK/RCLK), - 和 I²C 没有任何交集,所以「拿着 A 去拿 B」这件事在这块板子上永远是写错了, - 而不是一个需要小心处理的情况。`hal_lock_take()` 直接把它判成 HAL_ERR_STATE - —— 与其定一套加锁顺序然后指望后面每个人都记得,不如让它根本不可能发生。 */ -#ifndef HAL_LOCK_H -#define HAL_LOCK_H - -#include -#include - -#include "hal_err.h" - -#ifdef __cplusplus -extern "C" { -#endif - -typedef enum { - HAL_LOCK_I2C = 0, /* I²C 总线:6×INA226 + 2×DAC63204 + PCA9555(+ 载板 LM75) */ - HAL_LOCK_SR595, /* 74HC595 的 24 位链,全板共享(§10.6 的影子寄存器) */ - HAL_LOCK_COUNT, -} hal_lock_id_t; - -/* 幂等。任何任务起来之前调一次;没 init 就 take 会返回 HAL_ERR_STATE - (FreeRTOS 上那是个空句柄,不拦就是随机崩)。 */ -hal_err_t hal_lock_init(void); - -/* 拿锁。timeout_ms = HAL_LOCK_FOREVER 表示一直等。 - HAL_ERR_TIMEOUT —— 等超时了,**调用方必须自己处理**:巡检任务应当跳过这一周期 - 并计一次数,不要死等(死等的现象是 t_safety 整个停摆)。 - HAL_ERR_STATE —— 没 init,或者违反了「不许嵌套持有两把锁」。 - 同一个任务重复拿同一把锁也是 HAL_ERR_STATE:这两把都不是递归锁。 */ -#define HAL_LOCK_FOREVER 0xFFFFFFFFu -hal_err_t hal_lock_take(hal_lock_id_t id, uint32_t timeout_ms); - -/* 放锁。不是自己拿的、或者根本没人拿,都会 abort —— - 那是代码写错了,悄悄放掉会让下一个持有者莫名其妙地丢掉互斥。 */ -void hal_lock_give(hal_lock_id_t id); - -/* **当前任务**是不是正持着这把锁。给断言和 sim 的强制检查用, - 不要拿它做流程控制(「没拿就拿一下」正是双重加锁的写法)。 */ -bool hal_lock_held(hal_lock_id_t id); - -/* 累计等锁失败次数(超时)。遥测里报出去,用来看总线是不是排队了。 */ -uint32_t hal_lock_timeouts(hal_lock_id_t id); - -/* ---- 临界区(A11)——**这不是第三把锁** ---------------------------------- - - 事件环形缓冲的入队要在几条指令里改完两个下标,而入队方与出队方是 - 优先级两端的两个任务:`t_safety`(最高,而且是在**断电之后**调的) - 与 `t_log`(最低)。这里不能用互斥锁,理由有两条,都不是风格问题: - - · **不许阻塞。** t_safety.h 写着「接进来的东西不许阻塞」—— - 入队要是可能停在一把 t_log 持着的锁上,巡检的节拍就被最低优先级的任务 - 决定了。优先级继承能救回来,但那是「让阻塞变短」,不是「不阻塞」。 - · **§10.3 的共享资源只有两个**(I²C 与 595 链),这条架构结论不该被 - 一个环形缓冲的下标改写。 - - 所以给的是关中断的临界区:不阻塞、没有持有者、不参与「不许嵌套两把锁」 - 那条规则,也不出现在 `hal_lock_id_t` 里。 - - ⚠️ **里面只许改内存。** 关着中断去等锁、睡、跑 I²C 事务,都是错的 - —— sim 里 `sim_crit_blocking()` 会计数,`test_evlog.c` 拿它当断言。 - 可以嵌套(按层数计),但没有理由这么写。 */ -void hal_crit_enter(void); -void hal_crit_exit(void); - -#ifdef __cplusplus -} -#endif - -#endif /* HAL_LOCK_H */ +/* 互斥锁 —— HAL 的第五样东西,而且**只有两把**(需求 §10.3)。 + + 计划 §4.1 写着「HAL 只抽象四样:I²C / GPIO / PWM-定时 / 单调时钟,不要抽象得更多」。 + 这里多出来的第五样是有理由的:§10.3 的五个任务共享的资源正好是两个 + —— I²C 总线(10 个器件在同一条上)和 74HC595 的 24 位链 —— 而「谁在用总线」 + 这件事没法用前四样表达。 + + 为了不让它长成一个通用的锁库,这里**没有创建接口**:两把锁是枚举出来的, + 编译期就这么多,也不许再加第三把。要加第三把之前先回头看一眼 §10.3 + ——「共享资源只有两个」是架构结论,不是当时懒得数。 + + ⚠️ **两把锁不许嵌套持有。** 595 那条链是纯 GPIO(SER/SRCLK/RCLK), + 和 I²C 没有任何交集,所以「拿着 A 去拿 B」这件事在这块板子上永远是写错了, + 而不是一个需要小心处理的情况。`hal_lock_take()` 直接把它判成 HAL_ERR_STATE + —— 与其定一套加锁顺序然后指望后面每个人都记得,不如让它根本不可能发生。 */ +#ifndef HAL_LOCK_H +#define HAL_LOCK_H + +#include +#include + +#include "hal_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + /* I²C 总线。**R2 之后这是全系统唯一还在用的一把锁**: + TCA9548A + 输入侧 INA226 + 2×DAC63204(主干)、五片刀片各自的 + INA226 + PCA9555(mux 支路)。按键 / EDL / VBUS 也都在这条线上了。 */ + HAL_LOCK_I2C = 0, + /* ⛔ **R2 已退役**(2026-09-16,方案 D9:74HC595 链换成刀片上的 PCA9555)。 + 枚举留着是因为 `sim_lock_*` 的那套「两把锁不许嵌套」的检查仍要有第二把锁 + 才测得动;生产代码里**没有任何一处再拿它**。 + R2 只剩一把锁,于是「嵌套」在物理上不可能 —— 这是 D9 顺手带来的好处, + 代价是执行器与巡检开始争用同一把锁(计划 §5.6)。 */ + HAL_LOCK_SR595, + HAL_LOCK_COUNT, +} hal_lock_id_t; + +/* 幂等。任何任务起来之前调一次;没 init 就 take 会返回 HAL_ERR_STATE + (FreeRTOS 上那是个空句柄,不拦就是随机崩)。 */ +hal_err_t hal_lock_init(void); + +/* 拿锁。timeout_ms = HAL_LOCK_FOREVER 表示一直等。 + HAL_ERR_TIMEOUT —— 等超时了,**调用方必须自己处理**:巡检任务应当跳过这一周期 + 并计一次数,不要死等(死等的现象是 t_safety 整个停摆)。 + HAL_ERR_STATE —— 没 init,或者违反了「不许嵌套持有两把锁」。 + 同一个任务重复拿同一把锁也是 HAL_ERR_STATE:这两把都不是递归锁。 */ +#define HAL_LOCK_FOREVER 0xFFFFFFFFu +hal_err_t hal_lock_take(hal_lock_id_t id, uint32_t timeout_ms); + +/* 放锁。不是自己拿的、或者根本没人拿,都会 abort —— + 那是代码写错了,悄悄放掉会让下一个持有者莫名其妙地丢掉互斥。 */ +void hal_lock_give(hal_lock_id_t id); + +/* **当前任务**是不是正持着这把锁。给断言和 sim 的强制检查用, + 不要拿它做流程控制(「没拿就拿一下」正是双重加锁的写法)。 */ +bool hal_lock_held(hal_lock_id_t id); + +/* 累计等锁失败次数(超时)。遥测里报出去,用来看总线是不是排队了。 */ +uint32_t hal_lock_timeouts(hal_lock_id_t id); + +/* ---- 临界区(A11)——**这不是第三把锁** ---------------------------------- + + 事件环形缓冲的入队要在几条指令里改完两个下标,而入队方与出队方是 + 优先级两端的两个任务:`t_safety`(最高,而且是在**断电之后**调的) + 与 `t_log`(最低)。这里不能用互斥锁,理由有两条,都不是风格问题: + + · **不许阻塞。** t_safety.h 写着「接进来的东西不许阻塞」—— + 入队要是可能停在一把 t_log 持着的锁上,巡检的节拍就被最低优先级的任务 + 决定了。优先级继承能救回来,但那是「让阻塞变短」,不是「不阻塞」。 + · **§10.3 的共享资源只有两个**(I²C 与 595 链),这条架构结论不该被 + 一个环形缓冲的下标改写。 + + 所以给的是关中断的临界区:不阻塞、没有持有者、不参与「不许嵌套两把锁」 + 那条规则,也不出现在 `hal_lock_id_t` 里。 + + ⚠️ **里面只许改内存。** 关着中断去等锁、睡、跑 I²C 事务,都是错的 + —— sim 里 `sim_crit_blocking()` 会计数,`test_evlog.c` 拿它当断言。 + 可以嵌套(按层数计),但没有理由这么写。 */ +void hal_crit_enter(void); +void hal_crit_exit(void); + +#ifdef __cplusplus +} +#endif + +#endif /* HAL_LOCK_H */ diff --git a/firmware/components/phonerail_hal/sim/hal_gpio_sim.c b/firmware/components/phonerail_hal/sim/hal_gpio_sim.c index fce9dc4..e69f8d9 100644 --- a/firmware/components/phonerail_hal/sim/hal_gpio_sim.c +++ b/firmware/components/phonerail_hal/sim/hal_gpio_sim.c @@ -1,92 +1,98 @@ -/* GPIO 的假实现。宿主机专用。 */ -#include -#include - -#include "sim_hal.h" - -typedef struct { - bool configured; - hal_gpio_mode_t mode; - int level; - uint32_t writes; - uint32_t seq; /* 最后一次被写时的全局序号,0 = 没写过 */ -} pin_t; - -static pin_t s_pin[SIM_GPIO_PINS]; -static uint32_t s_seq; /* 全局写计数,从 1 开始发号 */ - -void sim_gpio_reset_internal(void); /* 只给 sim_hal_reset() 用 */ - -/* 假 74HC595 链的窃听口(sim_sr595.c)。没 attach 过就是个空动作。 - 放在这里而不是让测试自己数边:移位链要看的是**电平的跳变**, - 而 hal_gpio_write() 写同一个值也会计一次 writes。 */ -void sim_sr595_gpio_hook(int pin, int level); - -void sim_gpio_reset_internal(void) -{ - memset(s_pin, 0, sizeof(s_pin)); - s_seq = 0; -} - -static bool valid(int pin) { return pin >= 0 && pin < SIM_GPIO_PINS; } - -hal_err_t hal_gpio_config(int pin, hal_gpio_mode_t mode) -{ - if (!valid(pin)) { - return HAL_ERR_ARG; - } - pin_t *p = &s_pin[pin]; - p->configured = true; - p->mode = mode; - /* 上拉输入没人驱动时读回高电平 —— 五路 FLT/PG 都是开漏 + 上拉, - 「没有故障」在假板子上也必须是 1,不然测试会把默认 0 当成一直有故障。 */ - p->level = (mode == HAL_GPIO_INPUT_PULLUP) ? 1 : 0; - /* 配成输出的那一刻引脚就开始驱动了(ESP-IDF 的 gpio_config 也是这个行为, - 输出寄存器复位值 0)。`KEY_OE#` 正是靠这一条:配成输出 = 输出立刻使能, - 所以 sr595_init() 必须等安全值锁存完了再配它。 */ - if (mode == HAL_GPIO_OUTPUT) { - sim_sr595_gpio_hook(pin, p->level); - } - return HAL_OK; -} - -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; - } - s_pin[pin].level = level ? 1 : 0; - s_pin[pin].writes++; - s_pin[pin].seq = ++s_seq; - sim_sr595_gpio_hook(pin, s_pin[pin].level); - return HAL_OK; -} - -hal_err_t hal_gpio_read(int pin, int *level) -{ - if (!valid(pin) || !s_pin[pin].configured || level == NULL) { - return HAL_ERR_ARG; - } - *level = s_pin[pin].level; - return HAL_OK; -} - -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; -} - -int sim_gpio_level(int pin) -{ - if (!valid(pin) || !s_pin[pin].configured) { - return -1; - } - return s_pin[pin].level; -} - -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; } +/* GPIO 的假实现。宿主机专用。 */ +#include +#include + +#include "sim_hal.h" + +typedef struct { + bool configured; + hal_gpio_mode_t mode; + int level; + uint32_t writes; + uint32_t seq; /* 最后一次被写时的全局序号,0 = 没写过 */ +} pin_t; + +static pin_t s_pin[SIM_GPIO_PINS]; +static uint32_t s_seq; /* 全局写计数,从 1 开始发号 */ + +void sim_gpio_reset_internal(void); /* 只给 sim_hal_reset() 用 */ + +/* 假 74HC595 链的窃听口(sim_sr595.c)。没 attach 过就是个空动作。 + 放在这里而不是让测试自己数边:移位链要看的是**电平的跳变**, + 而 hal_gpio_write() 写同一个值也会计一次 writes。 */ +void sim_sr595_gpio_hook(int pin, int level); + +/* 假 TCA9548A 的 `MUX_RST#` 窃听口(hal_i2c_sim.c)。拉低 = 通道全关。 + 没挂 mux 就是个空动作。 */ +void sim_mux_gpio_hook(int pin, int level); + +void sim_gpio_reset_internal(void) +{ + memset(s_pin, 0, sizeof(s_pin)); + s_seq = 0; +} + +static bool valid(int pin) { return pin >= 0 && pin < SIM_GPIO_PINS; } + +hal_err_t hal_gpio_config(int pin, hal_gpio_mode_t mode) +{ + if (!valid(pin)) { + return HAL_ERR_ARG; + } + pin_t *p = &s_pin[pin]; + p->configured = true; + p->mode = mode; + /* 上拉输入没人驱动时读回高电平 —— 五路 FLT/PG 都是开漏 + 上拉, + 「没有故障」在假板子上也必须是 1,不然测试会把默认 0 当成一直有故障。 */ + p->level = (mode == HAL_GPIO_INPUT_PULLUP) ? 1 : 0; + /* 配成输出的那一刻引脚就开始驱动了(ESP-IDF 的 gpio_config 也是这个行为, + 输出寄存器复位值 0)。`KEY_OE#` 正是靠这一条:配成输出 = 输出立刻使能, + 所以 sr595_init() 必须等安全值锁存完了再配它。 */ + if (mode == HAL_GPIO_OUTPUT) { + sim_sr595_gpio_hook(pin, p->level); + sim_mux_gpio_hook(pin, p->level); + } + return HAL_OK; +} + +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; + } + s_pin[pin].level = level ? 1 : 0; + s_pin[pin].writes++; + s_pin[pin].seq = ++s_seq; + sim_sr595_gpio_hook(pin, s_pin[pin].level); + sim_mux_gpio_hook(pin, s_pin[pin].level); + return HAL_OK; +} + +hal_err_t hal_gpio_read(int pin, int *level) +{ + if (!valid(pin) || !s_pin[pin].configured || level == NULL) { + return HAL_ERR_ARG; + } + *level = s_pin[pin].level; + return HAL_OK; +} + +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; +} + +int sim_gpio_level(int pin) +{ + if (!valid(pin) || !s_pin[pin].configured) { + return -1; + } + return s_pin[pin].level; +} + +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; } diff --git a/firmware/components/phonerail_hal/sim/hal_i2c_sim.c b/firmware/components/phonerail_hal/sim/hal_i2c_sim.c index c9dfdbe..2d94b34 100644 --- a/firmware/components/phonerail_hal/sim/hal_i2c_sim.c +++ b/firmware/components/phonerail_hal/sim/hal_i2c_sim.c @@ -1,4 +1,14 @@ -/* I²C 的假实现:一张寄存器表 + 可注入的 NAK。宿主机专用。 */ +/* I²C 的假实现:一张寄存器表 + 可注入的 NAK + **一颗假 TCA9548A**。宿主机专用。 + + ---- R2 为什么要在假总线里做 mux ------------------------------------------ + R2 的五片刀片**完全同构**:五片上都是 0x40 的 INA226 和 0x20 的 PCA9555。 + 在真板上区分它们的唯一手段是底板那颗 TCA9548A 的通道选择。 + 假总线如果只按地址查表,五片就会塌成一片 —— 于是「选错通道」这一类 + R2 最危险的错误(计划 §4.3)在宿主机上**一次也复现不出来**。 + + 所以这里的器件带一个 `bus`:-1 = 主干,0~7 = 挂在 mux 的某个通道后面。 + 只有**当前打开的那个通道**上的器件看得见;关着的通道上的器件一律 NAK + (真器件就是这个行为:支路被断开,从机根本收不到地址)。 */ #include #include @@ -9,6 +19,14 @@ static int s_n; static bool s_inited; static bool s_bus_time = true; +/* 假 mux 的状态。`s_mux_addr < 0` = 板上没挂 mux(A3~A6 那批驱动测试就是这样, + 它们直接挂器件到主干上)。 */ +static int s_mux_addr = -1; +static int s_mux_rst_pin = -1; +static uint8_t s_mux_mask; /* 控制字:bit n = 通道 n 通 */ +static uint32_t s_mux_resets; +static uint32_t s_ambiguous; /* 同一地址在当前可见范围里出现了不止一次 */ + void sim_i2c_reset_internal(void); /* 只给 sim_hal_reset() 用 */ void sim_i2c_reset_internal(void) @@ -17,33 +35,132 @@ void sim_i2c_reset_internal(void) s_n = 0; s_inited = false; s_bus_time = true; + s_mux_addr = -1; + s_mux_rst_pin = -1; + s_mux_mask = 0; + s_mux_resets = 0; + s_ambiguous = 0; } +/* 挂在**主干**上的那一个(bus < 0)。A3~A6 的驱动测试用的就是这个语义。 */ sim_i2c_dev_t *sim_i2c_dev(uint8_t addr) { for (int i = 0; i < s_n; i++) { - if (s_dev[i].addr == addr) { + if (s_dev[i].addr == addr && s_dev[i].bus < 0) { return &s_dev[i]; } } return NULL; } -sim_i2c_dev_t *sim_i2c_add(uint8_t addr, uint8_t reg_width) +sim_i2c_dev_t *sim_i2c_dev_ch(int ch, uint8_t addr) +{ + for (int i = 0; i < s_n; i++) { + if (s_dev[i].addr == addr && s_dev[i].bus == ch) { + return &s_dev[i]; + } + } + return NULL; +} + +/* 当前**看得见**的那一个:主干上的永远看得见,支路上的要那个通道开着。 + 同一地址看得见不止一片时记一次 `ambiguous` —— 真板上那是两片一起应答, + 读回来的是两片的线与(数值正常、还很稳),这里让它可被断言。 */ +static sim_i2c_dev_t *visible(uint8_t addr) +{ + sim_i2c_dev_t *hit = NULL; + int n = 0; + + for (int i = 0; i < s_n; i++) { + if (s_dev[i].addr != addr) { + continue; + } + const bool on = (s_dev[i].bus < 0) || + (s_mux_addr >= 0 && (s_mux_mask & (uint8_t)(1u << s_dev[i].bus)) != 0); + if (!on) { + continue; + } + n++; + if (hit == NULL) { + hit = &s_dev[i]; + } + } + if (n > 1) { + s_ambiguous++; + } + return hit; +} + +static sim_i2c_dev_t *add(int bus, uint8_t addr, uint8_t reg_width) { assert(addr <= 0x7f); assert(reg_width == 1 || reg_width == 2); - assert(sim_i2c_dev(addr) == NULL); /* 地址撞车:测试写错了 */ + /* 地址撞车只在**同一条支路上**才算错 —— 五片刀片同址正是 R2 的设计。 */ + assert(sim_i2c_dev_ch(bus, addr) == NULL); assert(s_n < SIM_I2C_MAX_DEV); sim_i2c_dev_t *d = &s_dev[s_n++]; memset(d, 0, sizeof(*d)); d->addr = addr; + d->bus = bus; d->reg_width = reg_width; d->present = true; return d; } +sim_i2c_dev_t *sim_i2c_add(uint8_t addr, uint8_t reg_width) { return add(-1, addr, reg_width); } + +sim_i2c_dev_t *sim_i2c_add_ch(int ch, uint8_t addr, uint8_t reg_width) +{ + assert(ch >= 0 && ch < SIM_I2C_MUX_CH); + return add(ch, addr, reg_width); +} + +void sim_i2c_add_mux(uint8_t addr, int rst_pin) +{ + assert(s_mux_addr < 0); /* 板上只有一颗 */ + sim_i2c_dev_t *d = add(-1, addr, 1); + d->is_mux = true; + s_mux_addr = addr; + s_mux_rst_pin = rst_pin; + s_mux_mask = 0; /* 上电全关 */ +} + +/* 当前打开的是哪一路:全关 -1,恰好一路 0~7,**多于一路 -2**(那是 bug + 而不是一种用法,见 tca9548a.h 文件头第 2 条)。 */ +int sim_i2c_mux_ch(void) +{ + if (s_mux_mask == 0) { + return -1; + } + if ((s_mux_mask & (uint8_t)(s_mux_mask - 1u)) != 0) { + return -2; + } + for (int ch = 0; ch < SIM_I2C_MUX_CH; ch++) { + if (s_mux_mask == (uint8_t)(1u << ch)) { + return ch; + } + } + return -2; +} + +uint8_t sim_i2c_mux_mask(void) { return s_mux_mask; } +uint32_t sim_i2c_mux_resets(void) { return s_mux_resets; } +uint32_t sim_i2c_ambiguous(void) { return s_ambiguous; } + +/* `MUX_RST#` 的窃听口(低有效)。真器件复位之后**通道全关** —— + 固件那一侧的通道影子必须跟着作废,否则下一次事务会发到断开的支路上。 */ +void sim_mux_gpio_hook(int pin, int level) +{ + if (s_mux_addr < 0 || pin != s_mux_rst_pin) { + return; + } + if (level == 0) { + s_mux_mask = 0; + s_mux_resets++; + } +} + static sim_i2c_dev_t *must(uint8_t addr) { sim_i2c_dev_t *d = sim_i2c_dev(addr); @@ -51,14 +168,39 @@ static sim_i2c_dev_t *must(uint8_t addr) return d; } +static sim_i2c_dev_t *must_ch(int ch, uint8_t addr) +{ + sim_i2c_dev_t *d = sim_i2c_dev_ch(ch, addr); + assert(d != NULL); + return d; +} + void sim_i2c_set_reg(uint8_t addr, uint8_t reg, uint16_t val) { must(addr)->regs[reg] = val; } uint16_t sim_i2c_get_reg(uint8_t addr, uint8_t reg) { return must(addr)->regs[reg]; } void sim_i2c_inject_nak(uint8_t addr, int count) { must(addr)->nak_left = count; } void sim_i2c_set_present(uint8_t addr, bool present) { must(addr)->present = present; } + +void sim_i2c_set_reg_ch(int ch, uint8_t addr, uint8_t reg, uint16_t val) +{ + must_ch(ch, addr)->regs[reg] = val; +} +uint16_t sim_i2c_get_reg_ch(int ch, uint8_t addr, uint8_t reg) +{ + return must_ch(ch, addr)->regs[reg]; +} +void sim_i2c_inject_nak_ch(int ch, uint8_t addr, int count) +{ + must_ch(ch, addr)->nak_left = count; +} +void sim_i2c_set_present_ch(int ch, uint8_t addr, bool present) +{ + must_ch(ch, addr)->present = present; +} + void sim_i2c_set_bus_time(bool on) { s_bus_time = on; } /* 每次事务(成功或 NAK)都记账并把虚拟时钟花掉。NAK 也占总线:地址字节已经发出去了。 - 同时查一次加锁纪律 —— I²C 是 §10.3 两个共享资源之一。 */ + 同时查一次加锁纪律 —— I²C 是 §10.3 的共享资源。 */ static void spend(size_t wr_len, size_t rd_len) { sim_lock_check(HAL_LOCK_I2C); @@ -78,7 +220,7 @@ hal_err_t hal_i2c_init(const hal_i2c_cfg_t *cfg) return HAL_OK; } -/* 取器件并把「这次事务要不要 NAK」判掉。 */ +/* 取**当前看得见的**器件并把「这次事务要不要 NAK」判掉。 */ static hal_err_t take(uint8_t addr, sim_i2c_dev_t **out) { if (!s_inited) { @@ -87,9 +229,9 @@ static hal_err_t take(uint8_t addr, sim_i2c_dev_t **out) if (addr > 0x7f) { return HAL_ERR_ARG; } - sim_i2c_dev_t *d = sim_i2c_dev(addr); + sim_i2c_dev_t *d = visible(addr); if (d == NULL || !d->present) { - return HAL_ERR_NAK; + return HAL_ERR_NAK; /* 不存在,或者它那条支路现在是断开的 */ } if (d->nak_left != 0) { if (d->nak_left > 0) { @@ -101,12 +243,26 @@ static hal_err_t take(uint8_t addr, sim_i2c_dev_t **out) return HAL_OK; } -/* data[0] 是寄存器指针,其后按 reg_width 一格一格写,指针自增(uint8_t 自然回绕)。 */ +/* data[0] 是寄存器指针,其后按 reg_width 一格一格写,指针自增(uint8_t 自然回绕)。 + ⚠️ **mux 除外**:它整颗器件只有一个控制寄存器,写就是一个裸字节 + (tca9548a.h 文件头第 1 条)。按「寄存器 + 值」写它会把通道选成寄存器号, + 而且不会报错 —— 这里如实照做,让那种写法在宿主机上就现形。 */ static hal_err_t do_write(sim_i2c_dev_t *d, const uint8_t *data, size_t len) { if (len != 0 && data == NULL) { return HAL_ERR_ARG; } + if (d->is_mux) { + d->n_write++; + if (len == 0) { + return HAL_OK; /* 只发地址 = 探测 */ + } + d->last_write_len = 1; + d->last_write[0] = data[0]; + s_mux_mask = data[0]; /* 裸字节就是控制字 */ + d->regs[0] = data[0]; + return HAL_OK; + } if (len > 1 && ((len - 1) % d->reg_width) != 0) { return HAL_ERR_ARG; /* 半个寄存器:真器件上是未定义行为,这里直接报错 */ } @@ -137,6 +293,13 @@ static hal_err_t do_read(sim_i2c_dev_t *d, uint8_t *data, size_t len) if (len != 0 && data == NULL) { return HAL_ERR_ARG; } + if (d->is_mux) { + d->n_read++; + for (size_t i = 0; i < len; i++) { + data[i] = s_mux_mask; /* 读它永远只回控制字 */ + } + return HAL_OK; + } if ((len % d->reg_width) != 0) { return HAL_ERR_ARG; } diff --git a/firmware/components/phonerail_hal/sim/hal_pwm_sim.c b/firmware/components/phonerail_hal/sim/hal_pwm_sim.c index f597220..aa6de12 100644 --- a/firmware/components/phonerail_hal/sim/hal_pwm_sim.c +++ b/firmware/components/phonerail_hal/sim/hal_pwm_sim.c @@ -12,17 +12,139 @@ typedef struct { static ch_t s_ch[HAL_PWM_CH_MAX]; +/* ---- 假风扇(模型说明见 sim_hal.h)--------------------------------------- */ + +typedef struct { + bool attached; + int pwm_ch; + int en_pin; + uint32_t rpm_full; + uint32_t rpm_min; + uint8_t ppr; + bool gated; /* 关断期不吐脉冲 */ + bool stalled; + uint64_t last_us; /* 上一次结算的时刻 */ + uint64_t frac; /* 边沿的小数部分,按 1e6 缩放(整数积分,不引浮点) */ + uint32_t edges; +} fan_t; + +static fan_t s_fan[SIM_FAN_MAX]; + +static void fan_sync(void); + void sim_pwm_reset_internal(void); /* 只给 sim_hal_reset() 用 */ -void sim_pwm_reset_internal(void) { memset(s_ch, 0, sizeof(s_ch)); } +void sim_pwm_reset_internal(void) +{ + memset(s_ch, 0, sizeof(s_ch)); + memset(s_fan, 0, sizeof(s_fan)); /* 换了一块新板子 ⇒ 风扇也是新的 */ +} static bool valid(int ch) { return ch >= 0 && ch < HAL_PWM_CH_MAX; } +static bool fan_valid(int idx) { return idx >= 0 && idx < SIM_FAN_MAX && s_fan[idx].attached; } + +/* 这一刻这只风扇的转速。三个把它变成 0 的条件各对应一条真实故障。 */ +static uint32_t fan_rpm_now(const fan_t *f) +{ + if (f->stalled) { + return 0; /* 堵转 / 风扇线没插 */ + } + if (f->en_pin >= 0 && sim_gpio_level(f->en_pin) != 1) { + return 0; /* 12V 总开关没开 —— 复位态就是这个样子 */ + } + int g = sim_pwm_permille(f->pwm_ch); + if (g < 0) { + /* PWM 还没配:板上 R734 把 Q6 栅极拉低 ⇒ 风扇 PWM 线被风扇自己拉高 + ⇒ **满速**。这是安全侧,也是固件还没接管时的真实行为。 */ + g = 0; + } + const uint32_t fan_duty = 1000u - (uint32_t)g; /* Q6 反相 */ + return f->rpm_min + (f->rpm_full - f->rpm_min) * fan_duty / 1000u; +} + +/* 把「上次结算到现在」这段时间的边沿积出来。 */ +static void fan_sync(void) +{ + const uint64_t now = hal_time_now_us(); + for (int i = 0; i < SIM_FAN_MAX; i++) { + fan_t *f = &s_fan[i]; + if (!f->attached) { + continue; + } + if (now <= f->last_us) { + f->last_us = now; + continue; + } + uint64_t dt = now - f->last_us; + f->last_us = now; + + const uint32_t rpm = fan_rpm_now(f); + if (rpm == 0) { + continue; + } + if (f->gated) { + /* 关断期没有回流 ⇒ 脉冲只出现在导通的那一部分时间里。 + ⚠️ 这一行就是 TR-6 那条误报的来源:占空比越低,同样一秒里 + 吐出来的脉冲越少,而风扇转得好好的。 */ + int g = sim_pwm_permille(f->pwm_ch); + const uint32_t fan_duty = (g < 0) ? 1000u : (1000u - (uint32_t)g); + dt = dt * fan_duty / 1000u; + } + /* edges = rpm/60 × ppr × dt[s],按 1e6 缩放累加。 */ + f->frac += (uint64_t)rpm * f->ppr * dt / 60u; + f->edges += (uint32_t)(f->frac / 1000000u); + f->frac %= 1000000u; + } +} + +void sim_fan_attach(int idx, int pwm_ch, int en_pin, + uint32_t rpm_full, uint32_t rpm_min, uint8_t ppr) +{ + if (idx < 0 || idx >= SIM_FAN_MAX || ppr == 0) { + return; + } + s_fan[idx] = (fan_t){ + .attached = true, .pwm_ch = pwm_ch, .en_pin = en_pin, + .rpm_full = rpm_full, .rpm_min = rpm_min, .ppr = ppr, + .gated = true, .last_us = hal_time_now_us(), + }; +} + +void sim_fan_set_gated(int idx, bool on) +{ + fan_sync(); + if (fan_valid(idx)) { + s_fan[idx].gated = on; + } +} + +void sim_fan_set_stalled(int idx, bool on) +{ + fan_sync(); + if (fan_valid(idx)) { + s_fan[idx].stalled = on; + } +} + +uint32_t sim_fan_edges(int idx) +{ + fan_sync(); + return fan_valid(idx) ? s_fan[idx].edges : 0; +} + +uint32_t sim_fan_rpm(int idx) +{ + fan_sync(); + return fan_valid(idx) ? fan_rpm_now(&s_fan[idx]) : 0; +} + hal_err_t hal_pwm_config(int ch, int pin, uint32_t freq_hz) { if (!valid(ch) || pin < 0 || pin >= SIM_GPIO_PINS || freq_hz == 0) { return HAL_ERR_ARG; } + fan_sync(); /* 配置之前那段时间按「还没配 ⇒ 满速」结算(见 fan_rpm_now) */ s_ch[ch] = (ch_t){ .configured = true, .pin = pin, .freq_hz = freq_hz, .permille = 0 }; return HAL_OK; } @@ -32,6 +154,9 @@ hal_err_t hal_pwm_set_permille(int ch, uint16_t permille) if (!valid(ch) || !s_ch[ch].configured || permille > 1000) { return HAL_ERR_ARG; } + /* 先按**旧**占空比把已经过去的那段时间结算掉,再换新值 —— + 不然改占空比的那一刻会把过去一整秒的脉冲按新占空比重算一遍。 */ + fan_sync(); s_ch[ch].permille = permille; return HAL_OK; } diff --git a/firmware/components/phonerail_hal/sim/sim_hal.h b/firmware/components/phonerail_hal/sim/sim_hal.h index e4656b9..1af8948 100644 --- a/firmware/components/phonerail_hal/sim/sim_hal.h +++ b/firmware/components/phonerail_hal/sim/sim_hal.h @@ -33,9 +33,10 @@ void sim_hal_reboot(void); /* ---------------- I²C ---------------- */ -#define SIM_I2C_MAX_DEV 16 +#define SIM_I2C_MAX_DEV 24 /* R2:主干 4 + 五片刀片各 2 = 14,留些余量 */ #define SIM_I2C_REGS 256 #define SIM_I2C_LOG_BYTES 8 +#define SIM_I2C_MUX_CH 8 /* TCA9548A 的通道数 */ /* 假器件的窃听事件。通用寄存器表模拟不了「配成输出就对地短路」这种事, 所以按 datasheet 建模的假器件(sim_pca9555)挂一个钩子进来。 */ @@ -49,7 +50,11 @@ typedef void (*sim_i2c_hook_t)(struct sim_i2c_dev *d, sim_i2c_ev_t ev); typedef struct sim_i2c_dev { uint8_t addr; - uint8_t reg_width; /* 1 或 2 字节。2 字节按**大端**——INA226 / DAC63204 / LM75 都是 */ + /* **R2**:-1 = 挂在主干上;0~7 = 挂在 mux 的那个通道后面。 + 通道关着的时候它看不见(事务一律 NAK),这正是「选错通道」能被测出来的原因。 */ + int bus; + bool is_mux; /* 这颗就是 TCA9548A 自己:写它是**一个裸字节**,不带寄存器号 */ + uint8_t reg_width; /* 1 或 2 字节。2 字节按**大端**——INA226 / DAC63204 都是 */ bool present; /* false = 器件失联(A15 降级矩阵用),事务一律 NAK */ int nak_left; /* >0:接下来 n 次事务 NAK 后自动恢复;<0:一直 NAK;0:正常 */ uint16_t regs[SIM_I2C_REGS]; @@ -61,17 +66,38 @@ typedef struct sim_i2c_dev { sim_i2c_hook_t hook; /* NULL = 就是一张寄存器表 */ } sim_i2c_dev_t; -/* 往假总线上挂一个器件。地址重复或超出 16 个会 abort(测试写错了,不该悄悄过)。 */ +/* 往假总线的**主干**上挂一个器件。同一条支路上地址重复会 abort(测试写错了)。 */ sim_i2c_dev_t *sim_i2c_add(uint8_t addr, uint8_t reg_width); -sim_i2c_dev_t *sim_i2c_dev(uint8_t addr); /* 没挂就返回 NULL */ +sim_i2c_dev_t *sim_i2c_dev(uint8_t addr); /* 主干上的那一个,没挂返回 NULL */ + +/* 往 mux 的某个通道后面挂一个器件(**R2**:五片刀片可以用同一个地址)。 */ +sim_i2c_dev_t *sim_i2c_add_ch(int ch, uint8_t addr, uint8_t reg_width); +sim_i2c_dev_t *sim_i2c_dev_ch(int ch, uint8_t addr); + +/* 挂那颗假 TCA9548A。`rst_pin` 是 `MUX_RST#` 接的 GPIO(低有效)—— + 固件拉低它的那一刻,假器件的通道**全部关掉**,和真器件一样。 */ +void sim_i2c_add_mux(uint8_t addr, int rst_pin); + +int sim_i2c_mux_ch(void); /* 当前通道:-1 全关 / 0~7 / **-2 同时开了多路** */ +uint8_t sim_i2c_mux_mask(void); /* 控制字原样 */ +uint32_t sim_i2c_mux_resets(void); + +/* 同一个地址在**当前可见范围**里出现了不止一次的次数。 + 真板上那是两片一起应答、读回来是线与(数值正常、还很稳), + 所以这个计数必须恒为 0 —— 非 0 就是通道开重了。 */ +uint32_t sim_i2c_ambiguous(void); /* 预置 / 回读寄存器。地址没挂过会 abort。 */ void sim_i2c_set_reg(uint8_t addr, uint8_t reg, uint16_t val); uint16_t sim_i2c_get_reg(uint8_t addr, uint8_t reg); +void sim_i2c_set_reg_ch(int ch, uint8_t addr, uint8_t reg, uint16_t val); +uint16_t sim_i2c_get_reg_ch(int ch, uint8_t addr, uint8_t reg); /* 故障注入。count>0:接下来 count 次事务 NAK;count<0:一直 NAK;0:清除。 */ void sim_i2c_inject_nak(uint8_t addr, int count); void sim_i2c_set_present(uint8_t addr, bool present); +void sim_i2c_inject_nak_ch(int ch, uint8_t addr, int count); +void sim_i2c_set_present_ch(int ch, uint8_t addr, bool present); /* **假总线上的事务是要花时间的**(默认开):每次事务把虚拟时钟往前拨 `hal_i2c_txn_bits() / 频率` 那么多微秒。没有这一条,t_safety 的一个周期在 sim 里 @@ -126,31 +152,47 @@ uint32_t sim_sr595_enable_outputs(void); /* 某一位在栅极上的实际电平;OE# 为高时返回 -1(高阻,不是 0)。 */ int sim_sr595_pin(int bit); -/* ---------------- PCA9555(按 datasheet 建模的假器件) ---------------- */ +/* ---------------- PCA9555 ×5(按 datasheet 建模的假器件) ---------------- */ -/* 通用寄存器表证明不了 A6 的核心判据。这个模型只多做三件真器件会做的事: +/* 通用寄存器表证明不了这一层的核心判据。这个模型只多做三件真器件会做的事: ① **Output Port 复位值 0xFF** —— 所以把一个脚配成输出,它下一刻就输出高; ② 输入寄存器按位合成:配成输入的位读**外部电平**,配成输出的位读**自己的输出**; - ③ 四个备用脚 P0_7 / P1_5~7 **焊死在地上** —— 读永远是 0,被驱动成高就是短路。 - ③ 是这块板子上「配错方向」唯一会留下痕迹的地方:真板子上它既不报错也不 NAK。 */ -void sim_pca9555_attach(uint8_t addr); + ③ 备用脚 P0.5~7 / P1.4~7 **焊死在地上** —— 读永远是 0,被驱动成高就是短路。 + ③ 是这块板子上「配错方向」唯一会留下痕迹的地方:真板子上它既不报错也不 NAK。 + + **R2**:五片刀片各一颗,同地址、不同 mux 通道。`idx` 是 0~4, + 与 `rail - 1` 对应 —— 但那是测试自己的约定,假器件只认 attach 的顺序。 */ +#define SIM_PCA9555_MAX 5 + +void sim_pca9555_attach_ch(int idx, int ch, uint8_t addr); /* 外部世界把某个端口的 8 根线驱动成 level(接地脚不受影响,恒 0)。 FLT#/PGOOD 都是开漏上拉,所以「什么都没发生」的静息值是 0xFF。 */ -void sim_pca9555_drive(int port, uint8_t level); +void sim_pca9555_drive(int idx, int port, uint8_t level); -uint8_t sim_pca9555_config(int port); /* 方向寄存器现值,1 = 输入 */ -uint8_t sim_pca9555_output(int port); /* 输出锁存器现值(复位 0xFF) */ -uint8_t sim_pca9555_polinv(int port); +uint8_t sim_pca9555_config(int idx, int port); /* 方向寄存器现值,1 = 输入 */ +uint8_t sim_pca9555_output(int idx, int port); /* 输出锁存器现值(复位 0xFF) */ +uint8_t sim_pca9555_polinv(int idx, int port); -/* 接地脚被配成输出过几次(不管输出高低,配了就算 —— 那已经是在对地灌电流)。 */ +/* 某个输出脚**在栅极上的实际电平**:配成输入时返回 -1(高阻), + 配成输出时返回锁存器里那一位。断言「键真的被按下了」用这个, + 不要去读驱动自己的影子(那是自己证明自己)。 */ +int sim_pca9555_pin(int idx, int port, int bit); + +/* 接地脚被配成输出过几次(不管输出高低,配了就算 —— 那已经是在对地灌电流)。 + **五片合计**:这个数必须恒为 0,是哪一片犯的都一样严重。 */ uint32_t sim_pca9555_gnd_driven(void); /* 其中真的输出了高电平、也就是**直接对地短路**过几次。 */ uint32_t sim_pca9555_shorted(void); +/* ⭐ 「方向先于输出」那条规矩的拦网:某一片在**输出锁存器还是 0xFF 的时候** + 就把 Port0 配成了输出的次数。真板上那一刻五路的键全被按下 + VBUS 全部上电。 + 必须恒为 0。 */ +uint32_t sim_pca9555_unsafe_enable(void); + /* INT# 现在是不是被拉低(低有效)。输入变化拉低,**读走输入端口才释放**。 */ -bool sim_pca9555_int_asserted(void); -uint32_t sim_pca9555_int_asserts(void); /* 累计拉低次数 */ +bool sim_pca9555_int_asserted(int idx); +uint32_t sim_pca9555_int_asserts(int idx); /* 累计拉低次数 */ /* ---------------- PWM ---------------- */ @@ -158,6 +200,32 @@ int sim_pwm_pin(int ch); /* 未配置返回 -1 */ uint32_t sim_pwm_freq(int ch); /* 未配置返回 0 */ int sim_pwm_permille(int ch); /* 未配置返回 -1 */ +/* ---------------- 假风扇 ×2(A16 / TR-6 的 TACH 采样窗口) ---------------- */ + +/* 按板子的接法建模的假风扇:**一路 PWM 喂两只**、一个 12V 总开关、两路独立测速。 + 它比「一个转速变量」多做四件真板子会做的事,每一件都是一条判据的靠山: + ① `FAN_EN` 是低电平就**一圈都不转**(复位态就是这样,固件不主动开就没风); + ② PWM 反相(板上 Q6 低边管):GPIO 占空比 d ⇒ 风扇侧 `1000 − d` + —— 固件把反相写反时,这里的转速会随温度**下降**; + ③ **可选的「关断期无脉冲」**(`sim_fan_set_gated`,三线低边调速的行为): + 占空比 40 % 就只有 40 % 的时间在吐脉冲。TR-6 那条误报全靠它才摆得出来; + ④ 0 % PWM 仍转 `rpm_min`(设计文档 §22.6:四线风扇 0 % 下仍 3300 RPM)。 + + 边沿数是按**虚拟时钟**积分出来的,积分在读 `sim_fan_edges()/sim_fan_rpm()`、 + 写 `hal_pwm_set_permille()`、以及 `sim_fan_set_*()` 时结算。 + ⚠️ 改 `FAN_EN` 引脚之前请先读一次 `sim_fan_edges()`:模型不监视 GPIO 的写, + 那一段时间会整段按新的 EN 状态算。 */ +#define SIM_FAN_MAX 2 + +void sim_fan_attach(int idx, int pwm_ch, int en_pin, + uint32_t rpm_full, uint32_t rpm_min, uint8_t ppr); + +void sim_fan_set_gated(int idx, bool on); /* 默认 true = 关断期不吐脉冲 */ +void sim_fan_set_stalled(int idx, bool on); /* 堵转 / 拔线:一个脉冲也没有 */ + +uint32_t sim_fan_edges(int idx); /* 累计 TACH 边沿数(没 attach 返回 0) */ +uint32_t sim_fan_rpm(int idx); /* 此刻的真实转速,给测试自查用 */ + /* ---------------- 两把互斥锁 ---------------- */ /* 扮演第几号任务(默认 0)。单线程 sim 里「另一个任务持着锁」就靠它模拟。 */ diff --git a/firmware/components/phonerail_hal/sim/sim_pca9555.c b/firmware/components/phonerail_hal/sim/sim_pca9555.c index 38f0f56..2133b1d 100644 --- a/firmware/components/phonerail_hal/sim/sim_pca9555.c +++ b/firmware/components/phonerail_hal/sim/sim_pca9555.c @@ -1,77 +1,114 @@ -/* 按 datasheet 建模的假 PCA9555。宿主机专用。 +/* 按 datasheet 建模的假 PCA9555 ×5(R2:每片刀片一颗)。宿主机专用。 - 通用寄存器表(sim_i2c)能测「写下去的字节对不对」,但测不出 A6 真正要拦的那件事: - **把一个焊死在地上的脚配成输出**。真板子上这既不报错也不 NAK,只是发烫。 - 所以这里把三件真器件会做的事补上(见 sim_hal.h 那一节)。 + 通用寄存器表(sim_i2c)能测「写下去的字节对不对」,但测不出这一层真正要拦的 + 两件事: + ① **把一个焊死在地上的脚配成输出**(P0.5~7 / P1.4~7)—— + 真板子上这既不报错也不 NAK,只是发烫; + ② **在输出锁存器还是 0xFF 的时候就把 Port0 配成输出** —— + 那一刻三个键全被按下 + EDL 短接 + VBUS 上电(pca9555.h 那个框)。 + ② 在真板上的现象是「五台手机一起进了 Recovery」,而 I²C 从头到尾一切正常。 - ⚠️ 这个模型是用来证明**代码逻辑**对的。别拿它推翻设计文档 §17.2 的任何一条。 */ + ⚠️ 这个模型是用来证明**代码逻辑**对的。别拿它推翻硬件设计的任何一条。 */ #include #include #include "pca9555.h" #include "sim_hal.h" -/* 焊死在地上的备用脚(设计文档 §17.2 的注)。 */ +/* 焊死在地上的备用脚(`_blade_pcb_nets.json` 的 `U4` pin9~11 / pin17~20)。 */ static const uint8_t s_gnd_mask[2] = { PCA9555_P0_GND_MASK, PCA9555_P1_GND_MASK }; typedef struct { bool attached; + int ch; /* 挂在 mux 的哪个通道后面 */ uint8_t addr; uint8_t ext[2]; /* 外部世界驱动的电平;开漏上拉,静息 0xFF */ uint8_t int_latch[2];/* 上一次「被读走」时的输入值,用来判 INT# */ bool int_low; uint32_t int_asserts; - uint32_t gnd_driven; - uint32_t shorted; -} sim_pca9555_t; +} sim_pca_t; -static sim_pca9555_t s; +static sim_pca_t s[SIM_PCA9555_MAX]; +static uint32_t s_gnd_driven; /* 五片合计 */ +static uint32_t s_shorted; +static uint32_t s_unsafe_enable; void sim_pca9555_reset_internal(void); /* 只给 sim_hal_reset() 用 */ void sim_pca9555_reset_internal(void) { - memset(&s, 0, sizeof(s)); + memset(s, 0, sizeof(s)); + s_gnd_driven = 0; + s_shorted = 0; + s_unsafe_enable = 0; +} + +static sim_pca_t *inst(int idx) +{ + assert(idx >= 0 && idx < SIM_PCA9555_MAX); + assert(s[idx].attached); /* 先 sim_pca9555_attach_ch() */ + return &s[idx]; +} + +/* 从 dev 反查是哪一片:地址相同,靠通道区分(真板上也是这么区分的)。 */ +static sim_pca_t *from_dev(const sim_i2c_dev_t *d) +{ + for (int i = 0; i < SIM_PCA9555_MAX; i++) { + if (s[i].attached && s[i].addr == d->addr && s[i].ch == d->bus) { + return &s[i]; + } + } + assert(0 && "hook 挂在了一个没登记的假 PCA9555 上"); + return &s[0]; } /* 输入寄存器 = 逐位选择:配成输入的位取外部电平,配成输出的位取自己的输出锁存器。 接地脚无论配成什么,线上都是 0(它焊在地上,赢的永远是铜)。 */ -static uint8_t input_value(const sim_i2c_dev_t *d, int port) +static uint8_t input_value(const sim_i2c_dev_t *d, const sim_pca_t *p, int port) { const uint8_t cfg = (uint8_t)d->regs[PCA9555_REG_CONFIG0 + port]; const uint8_t out = (uint8_t)d->regs[PCA9555_REG_OUTPUT0 + port]; const uint8_t pol = (uint8_t)d->regs[PCA9555_REG_POLINV0 + port]; - uint8_t v = (uint8_t)((s.ext[port] & cfg) | (out & (uint8_t)~cfg)); + uint8_t v = (uint8_t)((p->ext[port] & cfg) | (out & (uint8_t)~cfg)); v &= (uint8_t)~s_gnd_mask[port]; /* 接地脚读回来永远是 0 */ return (uint8_t)(v ^ pol); /* 极性反转寄存器作用在**输入**上 */ } -/* 方向寄存器一变就查一次:有没有把接地脚配成输出,输出的又是不是高电平。 */ -static void check_grounded_pins(const sim_i2c_dev_t *d) +/* 方向寄存器一变就查两件事(见文件头 ① ②)。 */ +static void check_dangerous(const sim_i2c_dev_t *d) { for (int port = 0; port < 2; port++) { const uint8_t cfg = (uint8_t)d->regs[PCA9555_REG_CONFIG0 + port]; const uint8_t out = (uint8_t)d->regs[PCA9555_REG_OUTPUT0 + port]; const uint8_t driven = (uint8_t)(s_gnd_mask[port] & (uint8_t)~cfg); /* cfg 0 = 输出 */ - if (driven == 0) { - continue; - } - s.gnd_driven++; - if ((driven & out) != 0) { - s.shorted++; /* 真板子上这一刻开始冒烟 */ + if (driven != 0) { + s_gnd_driven++; + if ((driven & out) != 0) { + s_shorted++; /* 真板子上这一刻开始冒烟 */ + } } } + + /* ② Port0 上被配成输出、而锁存器里是 1 的位:那一刻它就开始输出高电平。 + 本板 Port0 的五个输出(三个键 / EDL / VBUS)全都是**高有效**, + 所以这等于「键被按下 + VBUS 上电」。 */ + const uint8_t cfg0 = (uint8_t)d->regs[PCA9555_REG_CONFIG0]; + const uint8_t out0 = (uint8_t)d->regs[PCA9555_REG_OUTPUT0]; + const uint8_t as_output = (uint8_t)(PCA9555_P0_OUT_MASK & (uint8_t)~cfg0); + if ((as_output & out0) != 0) { + s_unsafe_enable++; + } } /* INT#:任何输入位相对「上次被读走的那一帧」发生变化就拉低,读走输入端口才释放。 */ -static void update_int(const sim_i2c_dev_t *d) +static void update_int(const sim_i2c_dev_t *d, sim_pca_t *p) { for (int port = 0; port < 2; port++) { - if (input_value(d, port) != s.int_latch[port]) { - if (!s.int_low) { - s.int_low = true; - s.int_asserts++; + if (input_value(d, p, port) != p->int_latch[port]) { + if (!p->int_low) { + p->int_low = true; + p->int_asserts++; } return; } @@ -80,34 +117,39 @@ static void update_int(const sim_i2c_dev_t *d) static void hook(sim_i2c_dev_t *d, sim_i2c_ev_t ev) { + sim_pca_t *p = from_dev(d); + if (ev == SIM_I2C_EV_POST_WRITE) { - check_grounded_pins(d); - update_int(d); + check_dangerous(d); + update_int(d, p); return; } /* PRE_READ:把两个输入寄存器合成好再让总线取走。 */ for (int port = 0; port < 2; port++) { - d->regs[PCA9555_REG_INPUT0 + port] = input_value(d, port); + d->regs[PCA9555_REG_INPUT0 + port] = input_value(d, p, port); } /* 读输入端口 = 确认中断。读别的寄存器不清 INT#(真器件就是这个行为, 也是「只靠中断会永远收不到下一个沿」的根源)。 */ if (d->ptr == PCA9555_REG_INPUT0 || d->ptr == PCA9555_REG_INPUT1) { - s.int_latch[0] = (uint8_t)d->regs[PCA9555_REG_INPUT0]; - s.int_latch[1] = (uint8_t)d->regs[PCA9555_REG_INPUT1]; - s.int_low = false; + p->int_latch[0] = (uint8_t)d->regs[PCA9555_REG_INPUT0]; + p->int_latch[1] = (uint8_t)d->regs[PCA9555_REG_INPUT1]; + p->int_low = false; } } -void sim_pca9555_attach(uint8_t addr) +void sim_pca9555_attach_ch(int idx, int ch, uint8_t addr) { - assert(!s.attached); /* 板上只有一片 */ - sim_i2c_dev_t *d = sim_i2c_add(addr, 1); + assert(idx >= 0 && idx < SIM_PCA9555_MAX); + assert(!s[idx].attached); + sim_i2c_dev_t *d = sim_i2c_add_ch(ch, addr, 1); - s.attached = true; - s.addr = addr; - s.ext[0] = 0xFF; /* FLT# / PGOOD 都是开漏上拉,静息是高 */ - s.ext[1] = 0xFF; + sim_pca_t *p = &s[idx]; + p->attached = true; + p->ch = ch; + p->addr = addr; + p->ext[0] = 0xFF; /* 开漏上拉,静息是高 */ + p->ext[1] = 0xFF; /* 上电复位值(datasheet 表 6):方向全输入 0xFF,**输出锁存器 0xFF**,极性 0。 输出的 0xFF 是本器件最阴的一条:配成输出的那一刻就输出高电平。 */ @@ -118,45 +160,61 @@ void sim_pca9555_attach(uint8_t addr) d->regs[PCA9555_REG_POLINV0] = 0x00; d->regs[PCA9555_REG_POLINV1] = 0x00; - s.int_latch[0] = input_value(d, 0); - s.int_latch[1] = input_value(d, 1); + p->int_latch[0] = input_value(d, p, 0); + p->int_latch[1] = input_value(d, p, 1); d->hook = hook; } -static sim_i2c_dev_t *must_dev(void) +static sim_i2c_dev_t *dev_of(int idx) { - assert(s.attached); /* 先 sim_pca9555_attach() */ - sim_i2c_dev_t *d = sim_i2c_dev(s.addr); + sim_pca_t *p = inst(idx); + sim_i2c_dev_t *d = sim_i2c_dev_ch(p->ch, p->addr); assert(d != NULL); return d; } -void sim_pca9555_drive(int port, uint8_t level) +void sim_pca9555_drive(int idx, int port, uint8_t level) { assert(port == 0 || port == 1); - s.ext[port] = level; - update_int(must_dev()); + sim_pca_t *p = inst(idx); + p->ext[port] = level; + update_int(dev_of(idx), p); } -uint8_t sim_pca9555_config(int port) +uint8_t sim_pca9555_config(int idx, int port) { assert(port == 0 || port == 1); - return (uint8_t)must_dev()->regs[PCA9555_REG_CONFIG0 + port]; + return (uint8_t)dev_of(idx)->regs[PCA9555_REG_CONFIG0 + port]; } -uint8_t sim_pca9555_output(int port) +uint8_t sim_pca9555_output(int idx, int port) { assert(port == 0 || port == 1); - return (uint8_t)must_dev()->regs[PCA9555_REG_OUTPUT0 + port]; + return (uint8_t)dev_of(idx)->regs[PCA9555_REG_OUTPUT0 + port]; } -uint8_t sim_pca9555_polinv(int port) +uint8_t sim_pca9555_polinv(int idx, int port) { assert(port == 0 || port == 1); - return (uint8_t)must_dev()->regs[PCA9555_REG_POLINV0 + port]; + return (uint8_t)dev_of(idx)->regs[PCA9555_REG_POLINV0 + port]; } -uint32_t sim_pca9555_gnd_driven(void) { return s.gnd_driven; } -uint32_t sim_pca9555_shorted(void) { return s.shorted; } -bool sim_pca9555_int_asserted(void) { return s.int_low; } -uint32_t sim_pca9555_int_asserts(void) { return s.int_asserts; } +int sim_pca9555_pin(int idx, int port, int bit) +{ + assert(port == 0 || port == 1); + assert(bit >= 0 && bit < 8); + const sim_i2c_dev_t *d = dev_of(idx); + const uint8_t cfg = (uint8_t)d->regs[PCA9555_REG_CONFIG0 + port]; + const uint8_t out = (uint8_t)d->regs[PCA9555_REG_OUTPUT0 + port]; + + if ((cfg & (uint8_t)(1u << bit)) != 0) { + return -1; /* 配成输入 = 高阻,不是 0 */ + } + return (out & (uint8_t)(1u << bit)) != 0 ? 1 : 0; +} + +uint32_t sim_pca9555_gnd_driven(void) { return s_gnd_driven; } +uint32_t sim_pca9555_shorted(void) { return s_shorted; } +uint32_t sim_pca9555_unsafe_enable(void) { return s_unsafe_enable; } +bool sim_pca9555_int_asserted(int idx) { return inst(idx)->int_low; } +uint32_t sim_pca9555_int_asserts(int idx) { return inst(idx)->int_asserts; } diff --git a/firmware/host/CMakeLists.txt b/firmware/host/CMakeLists.txt index e319f2f..5b22cac 100644 --- a/firmware/host/CMakeLists.txt +++ b/firmware/host/CMakeLists.txt @@ -24,10 +24,13 @@ set(PHONERAIL_CORE_SRCS ${FW_ROOT}/main/version.c ${FW_ROOT}/main/rail.c ${FW_ROOT}/main/rail_cfg.c + ${FW_ROOT}/main/i2cbus.c + ${FW_ROOT}/main/slot.c ${FW_ROOT}/main/sched.c ${FW_ROOT}/main/tasks.c ${FW_ROOT}/main/t_safety.c ${FW_ROOT}/main/t_actuator.c + ${FW_ROOT}/main/t_thermal.c ${FW_ROOT}/main/evlog.c ) @@ -54,6 +57,8 @@ set(PHONERAIL_DRV_SRCS ${PHONERAIL_DRV_DIR}/dac63204.c ${PHONERAIL_DRV_DIR}/sr595.c ${PHONERAIL_DRV_DIR}/pca9555.c + ${PHONERAIL_DRV_DIR}/tca9548a.c + ${PHONERAIL_DRV_DIR}/ch224a.c ) add_library(phonerail_core STATIC diff --git a/firmware/host/qtest.sh b/firmware/host/qtest.sh new file mode 100644 index 0000000..dc74b19 --- /dev/null +++ b/firmware/host/qtest.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# 单个测试的「快编快跑」——给并行开工用(R2 迁移期间,别的测试可能还编不过, +# host/test.sh 的 cmake GLOB 会被它们卡住)。 +# +# bash host/qtest.sh test_rail_cfg [test_slot ...] +# +# 它只编「core 源码 + unity + 你指定的那一个测试」,产物扔进 host/.q/, +# 不碰 host/build,所以多个会话同时跑互不干扰。 +# +# ⚠️ 全绿之后仍然要跑一次完整的 `bash host/test.sh` —— 那一份才是 DoD 认的。 +set -u +cd "$(dirname "$0")/.." || exit 1 + +OUT=host/.q +mkdir -p "$OUT" + +INC="-Imain -Icomponents/phonerail_hal/include -Icomponents/phonerail_hal/sim + -Icomponents/drv/include -Ithird_party/unity" + +# app_main.c 是 ESP 专用入口,宿主机不编。 +CORE="main/version.c main/rail.c main/rail_cfg.c main/i2cbus.c main/slot.c + main/sched.c main/tasks.c main/t_safety.c main/t_actuator.c main/evlog.c" +[ -f main/t_thermal.c ] && CORE="$CORE main/t_thermal.c" + +DRV="components/drv/ina226.c components/drv/dac63204.c components/drv/sr595.c + components/drv/pca9555.c components/drv/tca9548a.c components/drv/ch224a.c" + +HALDIR=components/phonerail_hal +SIM="$HALDIR/hal_err.c $HALDIR/hal_i2c_acct.c + $HALDIR/sim/hal_lock_sim.c $HALDIR/sim/sim_hal.c $HALDIR/sim/hal_i2c_sim.c + $HALDIR/sim/hal_gpio_sim.c $HALDIR/sim/sim_sr595.c $HALDIR/sim/sim_pca9555.c + $HALDIR/sim/hal_pwm_sim.c $HALDIR/sim/hal_time_sim.c $HALDIR/sim/hal_store_sim.c" + +rc=0 +for t in "$@"; do + src="test/$t.c" + if [ ! -f "$src" ]; then + echo "!! 没有 $src"; rc=1; continue + fi + echo "=== $t ===" + # shellcheck disable=SC2086 + if gcc -std=c11 -Wall -Wextra -Werror -g $INC \ + "$src" third_party/unity/unity.c $CORE $DRV $SIM \ + -o "$OUT/$t.exe"; then + "$OUT/$t.exe" || rc=1 + else + rc=1 + fi +done +exit $rc diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index e6123b1..e57cbb1 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -6,10 +6,13 @@ idf_component_register( "version.c" "rail.c" "rail_cfg.c" + "i2cbus.c" + "slot.c" "sched.c" "tasks.c" "t_safety.c" "t_actuator.c" + "t_thermal.c" "evlog.c" INCLUDE_DIRS "." REQUIRES phonerail_hal drv) diff --git a/firmware/main/app_main.c b/firmware/main/app_main.c index 8d5cace..3227120 100644 --- a/firmware/main/app_main.c +++ b/firmware/main/app_main.c @@ -6,12 +6,17 @@ #include "evlog.h" #include "freertos/FreeRTOS.h" #include "freertos/task.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 "phonerail.h" #include "rail_cfg.h" #include "sched.h" +#include "slot.h" #include "t_actuator.h" #include "t_safety.h" #include "tasks.h" @@ -64,10 +69,19 @@ static safety_snap_t s_snap; static bool act_pg_from_safety(void *ctx, int rail, bool *pg) { (void)ctx; - if (!s_snap.io_ok) { + /* ⭐ R2:PCA9555 变成**每路一片**(五片同构、同地址 0x20,靠 mux 分开), + 所以快照里的输入也是每路一份 —— R1 那时全板只有一片,只有一份。 + ⚠️ 这里的 `rail - 1` 是**数组下标**,不是由路号算出来的器件编号 + (rail_cfg.h 禁的是后者)。器件是哪一片由 `slot_*` 那一层查表决定。 */ + if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) { return false; } - *pg = pca9555_rail_pg(&s_snap.io, rail); /* 极性只在驱动里出现一次 */ + if (!s_snap.io_ok[rail - 1]) { + return false; + } + /* 极性只在驱动里出现一次。⚠️ R2 的判读函数**不带 rail** —— 五片逐格相同, + 位号是板级常量(pca9555.h),传路号进去反而会让人以为五片位表不一样。 */ + *pg = pca9555_efuse_pg(&s_snap.io[rail - 1]); return true; } @@ -118,7 +132,10 @@ static void task_thermal(void *arg) sched_periodic_t tick; sched_periodic_init(&tick, task_desc(TASK_THERMAL)->period_ms); for (;;) { - /* A16:两路 NTC + 载板 LM75 → 风扇 PWM 闭环(TR-6 的 TACH 采样窗口)。 */ + /* A16:两路 NTC(IO4/IO5)→ 风扇 PWM 闭环(TR-6 的 TACH 采样窗口)。 + ⚠️ R2 没有 LM75 了(载板整个不存在,计划 §5.10)。 + 真正的任务体在 `main/t_thermal.c` 里,由 A16 那一包填; + **这里不 include 它**,免得两个人同时改到同一处。 */ sched_periodic_wait(&tick); } } @@ -174,6 +191,33 @@ static void task_log(void *arg) } } +/* USB2517I 的复位脉冲 —— **整个固件里唯一碰 `HUB_RST#` 的地方,而且只在启动调一次**。 + + 低有效(板上 R716 上拉到 +3V3_HUB),给 1 ms 足够:手册要的是几十微秒量级。 + + ⛔⛔ **运行期一个字都不许再写它**(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:失联 ≠ 断电)。 */ +static void hub_reset_pulse_once(void) +{ + 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; + } + (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) { const task_desc_t *d = task_desc(id); @@ -191,21 +235,32 @@ void app_main(void) selftest_or_die(); - /* 十根 EN 线配成输出并全部拉低。**先于一切外设**:DAC / eFuse 一上电就有 - 输出,这两根线这时必须已经确定是低的。 */ + /* ⭐ R2:**五根** `EFUSE_EN` 配成输出并全部拉低(R1 是十根:另外五根 `VBUS_EN` + 已经挪到刀片的 PCA9555 上了)。**先于一切外设**:DAC / eFuse 一上电就有 + 输出,这五根线这时必须已经确定是低的。 */ hal_err_t err = rail_en_init(); if (err != HAL_OK) { ESP_LOGE(TAG, "EN 引脚初始化失败:%s", hal_err_str(err)); abort(); } - ESP_LOGI(TAG, "五路输出与 VBUS 全部关闭"); + /* ⚠️ 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, - .scl_gpio = BOARD_PIN_I2C_SCL, + .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); @@ -214,9 +269,62 @@ void app_main(void) abort(); } - /* ⚠️ 器件初始化(INA226 的 Config/Calibration、DAC、PCA9555 方向、595 安全值) - 与**降级矩阵**是 A15 的启动顺序那一包。在那之前 t_safety 读回来的电流恒为 0 - (Calibration 还没写),这是预期的,不是故障。 */ + /* ⭐ 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 还没写), + 这是预期的,不是故障。 */ /* 执行器:PG 从巡检的快照里取,本任务自己不碰 I²C(t_actuator.h 开头那一段)。 ⚠️ 必须在 `spawn(TASK_SAFETY, ...)` 之前:判据要关路时会先 `t_actuator_abort()`, diff --git a/firmware/main/board_pins.h b/firmware/main/board_pins.h index 0166476..7618127 100644 --- a/firmware/main/board_pins.h +++ b/firmware/main/board_pins.h @@ -1,16 +1,25 @@ -/* ESP32-S3 引脚分配 —— 全固件唯一一份(设计文档 §8.1,2026-09-06 按 PCB 几何 - 重排的那一版,§26.18)。 +/* 底板 ESP32-S3 引脚分配 —— 全固件唯一一份(**R2 底板**,计划 §5.1)。 ⚠️ **引脚号是从「板上要去哪儿」倒推的,不是逻辑顺序。** 和 INA226 地址、 - 595 位号一样:查表,不要算。哪怕看上去像顺排(IO9~IO14 那一段), - 也是巧合 —— `EFUSE_EN` 那五根就一个规律都没有(39 / 48 / 6 / 41 / 8)。 + PCA9555 位号一样:查表,不要算。 - 五路各自的 `EFUSE_ENn` / `VBUS_ENn` **不在这里**,在 `rail_cfg.h` 的那张五路表里 - (它们是「每路一个」的东西,和 INA 地址、DAC 通道、按键位号是同一类)。 - 本文件只放**全板独一份**的那些脚。`rail_selftest()` 会把两边合起来查重。 + ⚠️ **模组脚号 ≠ GPIO 号。** 下面每一行的注释里都写着模组脚号,那是给对着 + 原理图 / PCB 核对用的;`#define` 的值是 **GPIO 号**,那是给 `hal_gpio_*` 用的。 + 两者在 WROOM-1 上没有任何规律(脚 17 = IO9,脚 34 = IO41)。 - ⚠️ 这里的值与 `.claude/pcb1-parts.json` 里 U20(WROOM-1 模组)的真实焊盘网络 - 逐脚核对过(2026-09-07)。改动之前先回去核一遍,别照着记忆改。 */ + 五路各自的 `EFUSE_EN` / `PRSNT#` **不在这里**,在 `rail_cfg.h` 的那张五路表里 + (它们是「每路一个」的东西)。本文件只放**全板独一份**的那些脚。 + `rail_selftest()` 会把两边合起来查重。 + + ⚠️ 这里的值与 `.claude/r2/_base_pcb_nets.json` 里 `U20`(WROOM-1 模组)的真实 + 焊盘网络逐脚核对过(2026-09-16),并与 `.claude/_esp32_pinmap.json` 的 + `r2_base` 对上。改动之前先回去核一遍,别照着记忆改。 + + ---- R1 → R2 变了什么(别拿老记忆改这个文件)---------------------------- + · `VBUS_EN1~5` 五根 GPIO **没有了** —— 挪到刀片的 PCA9555 P0.4(计划 §5.4); + · 74HC595 的四根线**没有了** —— 整条链退役(R2 D9); + · 新增 `PRSNT#×5` / `MUX_RST#` / `INA_ALERT` / `HUB_RST` / `FAN_EN` / 第二路 TACH; + · I²C 从 IO14/15 挪到 IO41/42,NTC 从 IO1/2 挪到 IO4/5。 */ #ifndef BOARD_PINS_H #define BOARD_PINS_H @@ -20,28 +29,62 @@ extern "C" { /* ---- 全板独一份的信号(模组脚号 → GPIO) -------------------------------- */ -#define BOARD_PIN_I2C_SDA 14 /* 模组脚 22 —— 6×INA226 + 2×DAC + PCA9555(+ 载板 LM75) */ -#define BOARD_PIN_I2C_SCL 15 /* 模组脚 8 */ -#define BOARD_PIN_FAN_TACH 16 /* 模组脚 9 → R731/R732 分压 */ -#define BOARD_PIN_FAN_PWM 18 /* 模组脚 11 → R733 */ -#define BOARD_PIN_UP_VBUS_DET 47 /* 模组脚 24 → J24.25,上游 VBUS 有没有插 */ -#define BOARD_PIN_NTC1 1 /* 模组脚 39,ADC1_CH0 —— 必须是 IO1~IO10 */ -#define BOARD_PIN_NTC2 2 /* 模组脚 38,ADC1_CH1 */ +/* I²C 主干:TCA9548A(0x70) + 输入侧 INA226(0x43) + 两片 DAC63204(0x4A/0x4B)。 + 五片刀片的 INA226/PCA9555 在 mux 的分支上,不直接挂在这条线上。 */ +#define BOARD_PIN_I2C_SDA 41 /* 模组脚 34 */ +#define BOARD_PIN_I2C_SCL 42 /* 模组脚 35 */ -/* USB-Serial-JTAG 的 D±。**S3 上是硬连线,动不了**(模组脚 13/14 → J22)。 - 下载 / 调试口就是它,板上没有 JTAG 排针、也没有 USB 转串口芯片。 +/* TCA9548A 的 `RESET#`:板上 R611 上拉到 +3V3 ⇒ **低有效**。 + ESP32 复位期间高阻 = mux 正常工作。 */ +#define BOARD_PIN_MUX_RST_N 40 /* 模组脚 33 → U35.3 */ + +/* 五片刀片 PCA9555 的 `INT#` **线或**(R604 上拉)⇒ 低 = 有某一片报中断, + **看不出是哪一片**。中断只是加速路径:`t_safety` 每 20 ms 无条件读一圈, + 因为 PCA9555 的 INT# 要读走输入端口才释放(漏掉一次就再也等不到下一个沿)。 */ +#define BOARD_PIN_IOEXP_INT_N 39 /* 模组脚 32 → 五个插槽的 B11 */ + +/* 底板输入侧 INA226(U13, 0x43)的 ALERT,R605 上拉 ⇒ 低有效。 + ⚠️ R1 里这根是「六片线或进 PCA9555」,R2 只有这一片,而且直连 GPIO。 */ +#define BOARD_PIN_INA_ALERT_N 38 /* 模组脚 31 → U13.3 */ + +/* USB2517I 的复位,上拉到 +3V3_HUB ⇒ 低有效。 + ⛔⛔ **运行期一个字都不许写它**(R2 D13 代价 2):ESP32 自己的控制台与烧录口 + 就挂在这个 Hub 的第 6 个下行口上,误复位 = 自断控制台,而且下一次烧录也进不来。 + 只在启动自检时脉冲一次;真出事的退路是 J33 那个 5P 应急串口。 */ +#define BOARD_PIN_HUB_RST_N 8 /* 模组脚 12 → U32.43 */ + +#define BOARD_PIN_UP_VBUS_DET 6 /* 模组脚 6 → R721/R722 分压,上游 USB-C 插没插 */ + +/* 两路 NTC。**必须落在 ADC1(IO1~IO10)**:ADC2 在 WiFi 开着时读不了, + 而本板 WiFi 常开。挪到 ADC2 不会报错,只会在连上 WiFi 之后开始读失败。 */ +#define BOARD_PIN_NTC1 4 /* 模组脚 4,ADC1_CH3 → RT601 */ +#define BOARD_PIN_NTC2 5 /* 模组脚 5,ADC1_CH4 → RT602 */ + +/* 风扇:一路 PWM 喂两个座(J26/J27),一个 12V 总开关,两路独立测速。 + ⚠️ `FAN_EN` 复位时高阻 + 下拉 = **风扇不转**,固件必须主动开(Q8 → Q7 两级)。 + ⚠️ 两路 TACH 各经一级开集三极管(Q9/Q10)**反相**后上拉 ⇒ 数边沿没问题, + 但任何「按电平判它转没转」的写法都是反的。 */ +#define BOARD_PIN_FAN_PWM 47 /* 模组脚 24 → R733 */ +#define BOARD_PIN_FAN_EN 48 /* 模组脚 25 → R748 → Q8 */ +#define BOARD_PIN_FAN_TACH_IN 21 /* 模组脚 23 → Q9 集电极(进风扇) */ +#define BOARD_PIN_FAN_TACH_OUT 14 /* 模组脚 22 → Q10 集电极(出风扇) */ + +/* USB-Serial-JTAG 的 D±。**S3 上是硬连线,动不了**(模组脚 13/14)。 + R2 里它接的是 **Hub 的第 6 个下行口**(D13:全机只剩一根线到电脑), + 不再是一个独立的 USB-C 座。下载 / 调试 / 上位机 CDC 都走它。 ⚠️ 永远别烧 `DIS_USB_JTAG` eFuse —— 烧了引脚 JTAG 和 USB 两边都没有。 */ #define BOARD_PIN_USB_DM 19 #define BOARD_PIN_USB_DP 20 /* ---- 不许占用的脚 -------------------------------------------------------- */ -/* strapping:挂负载会影响上电模式(S3 只有这四个是)。 */ +/* strapping:挂负载会影响上电模式(S3 只有这四个是)。 + ⚠️ IO0 在 R2 上接着 J33 应急口的下载脚,**更加不能当普通 GPIO 用**。 */ #define BOARD_PINS_STRAPPING \ { \ 0, 3, 45, 46 \ } -/* WROOM-1 的 **R8 版(八线 PSRAM)占用**,留空以便换料。 */ +/* WROOM-1-**N16R8** 的八线 PSRAM 占用(2026-09-13 定的料号)。 */ #define BOARD_PINS_RESERVED \ { \ 35, 36, 37 \ @@ -55,9 +98,15 @@ extern "C" { 26, 27, 28, 29, 30, 31, 32 \ } -/* ESP32-S3 的 GPIO 编号上限(IO48 **存在**,`EFUSE_EN2` 就在上面)。 +/* UART0(J33 应急口)。**不许当 GPIO 用**:它是 Hub 挂掉之后唯一进得来的路。 */ +#define BOARD_PINS_UART0 \ + { \ + 43, 44 \ + } + +/* ESP32-S3 的 GPIO 编号上限(IO48 **存在**,`FAN_EN` 就在上面)。 ⚠️ HAL 两侧的边界都写成 `pin <= BOARD_PIN_NUM_MAX`;A7 之前那里写的是 - `pin < 48`,正好把 IO48 关在门外 —— 现象是 CH2 的 eFuse 既开不了也**关不掉**。 */ + `pin < 48`,正好把 IO48 关在门外。 */ #define BOARD_PIN_NUM_MAX 48 #ifdef __cplusplus diff --git a/firmware/main/evlog.c b/firmware/main/evlog.c index 84f09fc..62c14e2 100644 --- a/firmware/main/evlog.c +++ b/firmware/main/evlog.c @@ -1,570 +1,575 @@ -/* 事件日志的实现。接口与「为什么这么分层」在 evlog.h,这里只写 flash 上的样子 - 与几处不写下来就一定会被改坏的地方。 - - ---- flash 上的样子(17 个扇区 × 4 KB) ---------------------------------- - - 扇区 n: [ 16 B 扇区头 ][ 记录 0 ][ 记录 1 ] … [ 记录 254 ] - magic/ver/ 每条 16 B,255 条 - recs/epoch/chk - - · **扇区头里的 `epoch` 是全局递增的**(第几个被写的扇区), - 所以 `扇区号 = epoch % 17`,重启时不用记住任何东西:读 17 个头, - epoch 最大的那个就是正在写的扇区。 - · **记录的序号 = `epoch × 255 + 槽号`**。序号因此就是「flash 里第几条」, - 不需要额外存一个计数器,也不会在掉电后倒退。 - · 环形:写满一个扇区就**擦掉下一个**(也就是最老的那个)再写它的头。 - 擦除是 4 KB 粒度的,所以「盖掉最旧的」一次盖 255 条 —— 这正是 §10.7 - 的容量算法里那 4096 条的来源。 - - ---- 为什么每条记录要带一个校验字节 -------------------------------------- - NOR flash 的写可以被掉电打断在任意一个字节上,落进去的是**半条记录**, - 而半条记录在字节层面看跟一条真记录长得一模一样(时间戳合法、路号合法、 - 类型合法)。没有校验的话,重启后的扫描会把它当成有效记录继续往后写, - 而上位机拉到的是一条时间戳来自过去、电压是 0xFFFF 的「事件」。 - 校验字节让扫描停在它前面 —— `test_a_torn_write_does_not_become_a_record()`。 - - ⚠️ 校验的是**完整性**,不是正确性:它认得出「写了一半」,认不出「写错了地方」。 - 后者由扇区头里的 epoch 与 recs 兜着。 - - ---- 字节序 -------------------------------------------------------------- - 记录是把结构体原样写下去的,所以 flash 里是**本机小端**。这不成问题: - 读它的只有同一颗 MCU 上的同一份固件。上位机拿到的记录是经链路层 - (A13/A14)序列化过去的,不是直接读 flash 的镜像。 */ -#include -#include - -#include "evlog.h" -#include "hal_lock.h" -#include "hal_store.h" -#include "hal_time.h" - -#define EVLOG_MAGIC 0x474F4C45u /* "ELOG" */ -#define EVLOG_VER 1 - -/* 一次 `hal_store_write()` 最多带几条。256 B 的栈上缓冲,够摊薄写事务的开销了; - 再大也只是让 `evlog_flush()` 的最坏耗时变长。 */ -#define EVLOG_CHUNK 16 - -typedef struct { - uint32_t magic; - uint16_t ver; - uint16_t recs; /* 本扇区能装几条。几何变了(换了 flash / 换了分区大小)时, - 旧扇区认不出来 —— 这比按新几何去解释旧内容好得多 */ - uint32_t epoch; /* 全局递增:第几个被写的扇区 */ - uint32_t chk; -} evlog_shdr_t; - -/* 两个「结构体就是 flash 布局」的地方,编译期钉死。 */ -typedef char evlog_rec_is_16b[(sizeof(evlog_rec_t) == 16) ? 1 : -1]; -typedef char evlog_hdr_is_16b[(sizeof(evlog_shdr_t) == 16) ? 1 : -1]; - -/* ---- RAM 环形缓冲 -------------------------------------------------------- - 入队方可能是任何一个任务(t_safety 优先级最高),出队方只有 t_log。 - 两个下标的改动在临界区里做完(hal_lock.h:那不是第三把锁)。 */ -static evlog_rec_t s_ring[EVLOG_RAM_CAP]; -static uint32_t s_head; /* 下一条写进去的位置 */ -static uint32_t s_tail; /* 下一条要落盘的位置 */ -static uint32_t s_count; /* 环里现有几条 */ -static uint32_t s_oldest_ms; /* 环里最老那条的入队时刻(攒批的计时起点) */ -static uint32_t s_lost; /* 欠着一条 LOST 记录,值是丢了多少条 */ - -/* ---- flash 侧 ------------------------------------------------------------ */ -static bool s_have_store; -static uint32_t s_sector_bytes; -static uint32_t s_sectors; -static uint32_t s_recs; /* 每扇区能装几条记录(= sector_bytes/16 − 1) */ -static uint32_t s_epoch; /* 正在写的扇区的 epoch */ -static uint32_t s_slot; /* 该扇区里下一个空槽;== s_recs 表示这个扇区写满了 */ -static uint32_t s_next_seq; -static uint32_t s_oldest_seq; - -/* ---- 计数器 -------------------------------------------------------------- */ -static uint32_t s_queued; -static uint32_t s_stored; -static uint32_t s_dropped; -static uint32_t s_io_errors; - -/* ---- 校验 ---------------------------------------------------------------- */ - -/* CRC-8(多项式 0x07,初值 0xA5)。 - - ⚠️ **不要「优化」成异或和。** 第一版就是异或和,它认不出下面这条: - 一条 `bus_mv` 与 `current_ua` 都是 0 的记录(BOOT 就长这样),被掉电切在 - 第 8 个字节上,后 8 个字节留在 0xFF —— 而 8 个 0xFF 的异或恰好是 0, - 于是半条记录的异或和与整条**一模一样**,校验照过。 - `test_a_torn_write_with_zero_payload_is_still_rejected()` 就是这条。 - CRC 对「一段字节整体变成 0xFF」敏感,初值也不是 0(认得出全 0 的一片)。 */ -static uint8_t rec_chk(const evlog_rec_t *r) -{ - const uint8_t *b = (const uint8_t *)r; - uint8_t c = 0xA5u; - for (size_t i = 0; i < sizeof(*r); i++) { - if (i == offsetof(evlog_rec_t, chk)) { - continue; - } - c ^= b[i]; - for (int k = 0; k < 8; k++) { - c = (uint8_t)((c & 0x80u) ? ((uint8_t)(c << 1) ^ 0x07u) : (uint8_t)(c << 1)); - } - } - return c; -} - -/* 擦干净的槽(16 个 0xFF)在这里必须是 false —— 扫描就靠它找「上次写到哪了」, - 而这是整份日志唯一的位置信息。所以不靠 CRC 去撞那 1/256,直接认死: - **全 0xFF = 没写过**。真记录里 `t_ms` 是 0xFFFFFFFF 且五个字段全 0xFF 的 - 那一条不存在(那是 49.7 天整、−1 mV、−1 µA、路号 255 同时成立)。 */ -static bool rec_all_ff(const evlog_rec_t *r) -{ - const uint8_t *b = (const uint8_t *)r; - for (size_t i = 0; i < sizeof(*r); i++) { - if (b[i] != 0xFFu) { - return false; - } - } - return true; -} - -static bool rec_valid(const evlog_rec_t *r) -{ - return !rec_all_ff(r) && r->chk == rec_chk(r); -} - -static uint32_t hdr_chk(const evlog_shdr_t *h) -{ - return h->magic ^ ((uint32_t)h->ver | ((uint32_t)h->recs << 16)) ^ h->epoch ^ 0x5A5A5A5Au; -} - -static bool hdr_valid(const evlog_shdr_t *h, uint32_t sector) -{ - return h->magic == EVLOG_MAGIC && h->ver == EVLOG_VER && h->recs == (uint16_t)s_recs && - h->chk == hdr_chk(h) && (h->epoch % s_sectors) == sector; -} - -static uint32_t rec_off(uint32_t sector, uint32_t slot) -{ - return sector * s_sector_bytes + (slot + 1u) * (uint32_t)sizeof(evlog_rec_t); -} - -/* ---- 入队(谁产生事件谁调;不阻塞、不碰 flash) -------------------------- */ - -static void push(const evlog_rec_t *r) -{ - /* ⚠️ 临界区里**只改内存**:时间戳、校验都在外面算完了。 - 往里面加一行「顺手」的活,抖的是 t_safety 那 20 ms 的节拍。 */ - hal_crit_enter(); - if (s_count == EVLOG_RAM_CAP) { - /* 满了盖最旧的一条 —— §10.7 的原则是「最新的一条永远进得来」。 - 丢掉的这条会在下一次落盘时以一条 LOST 记录留下痕迹。 */ - s_tail = (s_tail + 1u) % EVLOG_RAM_CAP; - s_count--; - s_dropped++; - s_lost++; - } - if (s_count == 0u) { - s_oldest_ms = r->t_ms; /* 攒批的计时从环里第一条算起 */ - } - s_ring[s_head] = *r; - s_head = (s_head + 1u) % EVLOG_RAM_CAP; - s_count++; - s_queued++; - hal_crit_exit(); -} - -void evlog_put(uint8_t rail, log_ev_t type, uint8_t arg, int32_t bus_mv, int32_t ua) -{ - evlog_rec_t r; - memset(&r, 0, sizeof(r)); /* 记录会被原样写进 flash:不留没初始化的字节 */ - r.t_ms = hal_time_now_ms(); - r.rail = rail; - r.type = (uint8_t)type; - r.arg = arg; - r.bus_mv = bus_mv; - r.current_ua = ua; - r.chk = rec_chk(&r); - push(&r); -} - -void evlog_put_safety(void *ctx, const safety_event_t *ev) -{ - (void)ctx; - if (ev == NULL) { - return; - } - evlog_rec_t r; - memset(&r, 0, sizeof(r)); - /* ⚠️ 时间戳用事件里带的那个:判据记的是**断电那一刻**, - 而这个回调是断完电才被调的。差的那几十微秒不重要,重要的是别把 - 「事件发生的时刻」悄悄换成「记账的时刻」—— 排查时序问题时会差出一整圈。 */ - r.t_ms = ev->t_ms; - r.rail = ev->rail; - r.type = (uint8_t)evlog_from_safety(ev->type); - r.bus_mv = ev->bus_mv; - r.current_ua = ev->current_ua; - r.chk = rec_chk(&r); - push(&r); -} - -/* 显式的一一对应。**不许写成 `LOG_EV_OVER_VOLT + (ev − 1)`** —— - 那样 t_safety.h 里插进一个新事件类型,flash 里所有旧记录的含义就整体挪位了, - 而且不会有任何一处报错。`test_the_safety_mapping_is_explicit()` 拦这件事。 */ -log_ev_t evlog_from_safety(uint8_t safety_ev) -{ - switch ((safety_ev_t)safety_ev) { - case SAFETY_EV_OVER_VOLT: return LOG_EV_OVER_VOLT; - case SAFETY_EV_UNDER_VOLT: return LOG_EV_UNDER_VOLT; - case SAFETY_EV_REVERSE: return LOG_EV_REVERSE; - case SAFETY_EV_PUSHED_UP: return LOG_EV_PUSHED_UP; - case SAFETY_EV_NONE: break; - } - return LOG_EV_NONE; -} - -/* ---- 落盘(只有 t_log 与 A12 的拉日志命令调) ---------------------------- */ - -static hal_err_t write_header(uint32_t sector, uint32_t epoch) -{ - evlog_shdr_t h; - memset(&h, 0, sizeof(h)); - h.magic = EVLOG_MAGIC; - h.ver = EVLOG_VER; - h.recs = (uint16_t)s_recs; - h.epoch = epoch; - h.chk = hdr_chk(&h); - return hal_store_write(sector * s_sector_bytes, &h, sizeof(h)); -} - -/* 换到下一个扇区:先擦(这一下盖掉的就是最老的 255 条),再写头。 */ -static hal_err_t advance_sector(void) -{ - const uint32_t epoch = s_epoch + 1u; - const uint32_t sector = epoch % s_sectors; - hal_err_t err = hal_store_erase(sector); - if (err != HAL_OK) { - return err; - } - err = write_header(sector, epoch); - if (err != HAL_OK) { - return err; - } - s_epoch = epoch; - s_slot = 0; - s_next_seq = epoch * s_recs; - if (epoch >= s_sectors) { - s_oldest_seq = (epoch - s_sectors + 1u) * s_recs; - } - return HAL_OK; -} - -/* 把 n 条连续记录写进当前扇区(跨扇区就自己换)。返回真的写进去几条。 */ -static uint32_t store_run(const evlog_rec_t *r, uint32_t n) -{ - uint32_t done = 0; - while (done < n) { - if (s_slot >= s_recs) { - if (advance_sector() != HAL_OK) { - s_io_errors++; - break; - } - } - uint32_t k = n - done; - if (k > s_recs - s_slot) { - k = s_recs - s_slot; - } - const uint32_t off = rec_off(s_epoch % s_sectors, s_slot); - if (hal_store_write(off, &r[done], k * (uint32_t)sizeof(evlog_rec_t)) != HAL_OK) { - s_io_errors++; - /* 写失败时**这一槽可能只落进去了半条**(掉电、或者 flash 真坏了)。 - 半条记录后面再接着写的话,重启扫描会停在半条那里,把后面写的 - 全部当成不存在。所以整个扇区的剩余部分就此放弃,下一条换扇区去。 - 代价是最多浪费 254 个槽,换来的是「扫描停在哪 = 日志到哪为止」 - 这条不变量永远成立。 */ - s_slot = s_recs; - break; - } - s_slot += k; - s_next_seq += k; - s_stored += k; - done += k; - } - return done; -} - -/* 补一条「这里丢了 n 条」。它自己也占一个序号 —— 日志里看得见的那种丢失。 - 返回真的写进去几条(0 或 1):它和普通记录一样算进 `evlog_flush()` 的返回值, - 否则会出现「flush 说写了 0 条,序号却往前走了一格」这种对不上的账。 */ -static uint32_t store_lost_marker(void) -{ - uint32_t n; - hal_crit_enter(); - n = s_lost; - s_lost = 0; - hal_crit_exit(); - if (n == 0u) { - return 0u; - } - evlog_rec_t r; - memset(&r, 0, sizeof(r)); - r.t_ms = hal_time_now_ms(); - r.type = (uint8_t)LOG_EV_LOST; - r.bus_mv = (int32_t)n; /* 丢了几条 */ - r.chk = rec_chk(&r); - if (store_run(&r, 1u) != 1u) { - hal_crit_enter(); - s_lost += n; /* 连这条都没写进去,欠着,下次再补 */ - hal_crit_exit(); - return 0u; - } - return 1u; -} - -static uint32_t pop(evlog_rec_t *dst, uint32_t want) -{ - hal_crit_enter(); - uint32_t n = (want < s_count) ? want : s_count; - for (uint32_t i = 0; i < n; i++) { - dst[i] = s_ring[(s_tail + i) % EVLOG_RAM_CAP]; - } - s_tail = (s_tail + n) % EVLOG_RAM_CAP; - s_count -= n; - if (s_count > 0u) { - s_oldest_ms = s_ring[s_tail].t_ms; - } - hal_crit_exit(); - return n; -} - -bool evlog_due(void) -{ - bool due; - hal_crit_enter(); - due = (s_lost > 0u) || (s_count >= EVLOG_BATCH) || - (s_count > 0u && hal_time_elapsed_ms(s_oldest_ms) >= EVLOG_MAX_AGE_MS); - hal_crit_exit(); - return due; -} - -uint32_t evlog_flush(uint32_t max) -{ - if (!s_have_store) { - return 0u; /* 没有那块分区:事件留在 RAM 里,别把它们弄丢 */ - } - const uint32_t budget = (max == 0u) ? EVLOG_RAM_CAP : max; - /* ⚠️ 补记的 LOST 不占预算:它是上一次落盘欠下的账,拖到下一次只会让 - 「日志里丢了一段」这件事更晚才被看见。最多超出预算 1 条。 */ - uint32_t total = store_lost_marker(); - - uint32_t from_ring = 0; - evlog_rec_t buf[EVLOG_CHUNK]; - while (from_ring < budget) { - uint32_t want = budget - from_ring; - if (want > EVLOG_CHUNK) { - want = EVLOG_CHUNK; - } - const uint32_t n = pop(buf, want); - if (n == 0u) { - break; - } - const uint32_t w = store_run(buf, n); - total += w; - from_ring += w; - if (w < n) { - /* 已经从环里取出来了却没写进去 —— 这些是真丢了, - 记进 LOST,下一次落盘时它会以一条记录的形式出现在日志里。 */ - hal_crit_enter(); - s_dropped += (n - w); - s_lost += (n - w); - hal_crit_exit(); - break; - } - } - return total; -} - -/* ---- 读回 ---------------------------------------------------------------- */ - -uint32_t evlog_read(uint32_t from_seq, evlog_rec_t *out, uint32_t max) -{ - if (!s_have_store || out == NULL || max == 0u) { - return 0u; - } - uint32_t seq = (from_seq < s_oldest_seq) ? s_oldest_seq : from_seq; - if (seq >= s_next_seq) { - return 0u; - } - uint32_t n = s_next_seq - seq; - if (n > max) { - n = max; - } - for (uint32_t i = 0; i < n; i++) { - const uint32_t s = seq + i; - const uint32_t epoch = s / s_recs; - const uint32_t slot = s % s_recs; - evlog_rec_t r; - memset(&r, 0, sizeof(r)); - if (hal_store_read(rec_off(epoch % s_sectors, slot), &r, sizeof(r)) != HAL_OK || - !rec_valid(&r)) { - /* 序号空间里的一个洞(写失败之后被放弃的槽)。**照样占一个位置**, - 否则调用方的游标 `from_seq + n` 会对不上,拉日志会卡在洞上。 */ - memset(&r, 0, sizeof(r)); - r.type = (uint8_t)LOG_EV_NONE; - } - out[i] = r; - } - return n; -} - -/* ---- 启动:扫一遍 flash 找到接着写的位置 --------------------------------- */ - -static hal_err_t format_all(void) -{ - for (uint32_t i = 0; i < s_sectors; i++) { - const hal_err_t err = hal_store_erase(i); - if (err != HAL_OK) { - return err; - } - } - const hal_err_t err = write_header(0u, 0u); - if (err != HAL_OK) { - return err; - } - s_epoch = 0; - s_slot = 0; - s_next_seq = 0; - s_oldest_seq = 0; - return HAL_OK; -} - -hal_err_t evlog_init(void) -{ - /* RAM 侧无条件清干净:真板上这里刚上电,宿主机上这条让 `sim_hal_reboot()` - 之后的行为和真重启一致(statics 在进程里是活着的)。 */ - hal_crit_enter(); - s_head = 0; - s_tail = 0; - s_count = 0; - s_oldest_ms = 0; - s_lost = 0; - hal_crit_exit(); - s_queued = 0; - s_stored = 0; - s_dropped = 0; - s_io_errors = 0; - s_have_store = false; - s_sector_bytes = 0; - s_sectors = 0; - s_recs = 0; - s_epoch = 0; - s_slot = 0; - s_next_seq = 0; - s_oldest_seq = 0; - - hal_err_t err = hal_store_init(); - if (err != HAL_OK) { - /* 没有分区。**不 abort、不影响入队**:日志是排查手段,不是安全链 - (evlog.h 上面那段)。事件照进 RAM,只是没人能把它们写下去。 */ - evlog_put(0, LOG_EV_BOOT, 0, 0, 0); - return err; - } - - hal_store_geom_t g; - hal_store_geometry(&g); - 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); - return HAL_ERR_STATE; - } - s_sector_bytes = g.sector_bytes; - s_sectors = g.sectors; - s_recs = g.sector_bytes / (uint32_t)sizeof(evlog_rec_t) - 1u; /* 头占一个槽 */ - s_have_store = true; - - /* 17 个扇区头里 epoch 最大的那个 = 上次正在写的扇区。 - `hdr_valid()` 顺带查了 `epoch % 扇区数 == 扇区号` —— 几何变了、 - 或者这块区被别人写过,都会在这里落空,然后整块重来。 */ - bool found = false; - uint32_t best = 0; - for (uint32_t i = 0; i < s_sectors; i++) { - evlog_shdr_t h; - memset(&h, 0, sizeof(h)); - if (hal_store_read(i * s_sector_bytes, &h, sizeof(h)) != HAL_OK) { - continue; - } - if (hdr_valid(&h, i) && (!found || h.epoch > best)) { - found = true; - best = h.epoch; - } - } - - if (!found) { - err = format_all(); - if (err != HAL_OK) { - s_io_errors++; - s_have_store = false; - } - } else { - s_epoch = best; - /* 扫到第一个校验不过的槽为止 —— 那就是上次写到的地方。 - 「校验不过」包含两种:擦干净的(0xFF)和被掉电切断的半条。 */ - s_slot = 0; - for (uint32_t r = 0; r < s_recs; r++) { - evlog_rec_t rec; - memset(&rec, 0, sizeof(rec)); - if (hal_store_read(rec_off(best % s_sectors, r), &rec, sizeof(rec)) != HAL_OK || - !rec_valid(&rec)) { - break; - } - s_slot = r + 1u; - } - s_next_seq = best * s_recs + s_slot; - s_oldest_seq = (best >= s_sectors) ? (best - s_sectors + 1u) * s_recs : 0u; - } - - /* §10.7「上电从 0 计」⇒ 时间戳会跳回去。没有这一条的话,重启前后的记录 - 在时间轴上互相穿插,而日志里看不出重启发生过。 */ - evlog_put(0, LOG_EV_BOOT, 0, 0, 0); - return s_have_store ? HAL_OK : HAL_ERR_STATE; -} - -/* ---- 杂项 ---------------------------------------------------------------- */ - -void evlog_stats(evlog_stats_t *st) -{ - if (st == NULL) { - return; - } - hal_crit_enter(); - st->queued = s_queued; - st->stored = s_stored; - st->dropped = s_dropped; - st->pending = s_count; - hal_crit_exit(); - st->io_errors = s_io_errors; - st->oldest_seq = s_oldest_seq; - st->next_seq = s_next_seq; -} - -uint32_t evlog_capacity(void) { return s_have_store ? s_sectors * s_recs : 0u; } - -const char *evlog_ev_str(log_ev_t ev) -{ - switch (ev) { - case LOG_EV_NONE: return "(空)"; - case LOG_EV_BOOT: return "上电"; - case LOG_EV_LOST: return "事件丢失"; - case LOG_EV_OVER_VOLT: return "过压关路"; - case LOG_EV_UNDER_VOLT: return "欠压关路"; - case LOG_EV_REVERSE: return "反灌断VBUS"; - case LOG_EV_PUSHED_UP: return "顶高断VBUS"; - case LOG_EV_EFUSE_FAULT: return "eFuse故障"; - case LOG_EV_PG_CHANGE: return "PG变化"; - case LOG_EV_I2C_LOST: return "I2C失联"; - case LOG_EV_THERMAL_DERATE: return "过温降载"; - case LOG_EV_FAN_STALL: return "风扇停转"; - case LOG_EV_REBOOT: return "重启序列"; - case LOG_EV_CMD_REJECT: return "命令被拒"; - case LOG_EV_COUNT: break; - } - return "?"; -} +/* 事件日志的实现。接口与「为什么这么分层」在 evlog.h,这里只写 flash 上的样子 + 与几处不写下来就一定会被改坏的地方。 + + ---- flash 上的样子(17 个扇区 × 4 KB) ---------------------------------- + + 扇区 n: [ 16 B 扇区头 ][ 记录 0 ][ 记录 1 ] … [ 记录 254 ] + magic/ver/ 每条 16 B,255 条 + recs/epoch/chk + + · **扇区头里的 `epoch` 是全局递增的**(第几个被写的扇区), + 所以 `扇区号 = epoch % 17`,重启时不用记住任何东西:读 17 个头, + epoch 最大的那个就是正在写的扇区。 + · **记录的序号 = `epoch × 255 + 槽号`**。序号因此就是「flash 里第几条」, + 不需要额外存一个计数器,也不会在掉电后倒退。 + · 环形:写满一个扇区就**擦掉下一个**(也就是最老的那个)再写它的头。 + 擦除是 4 KB 粒度的,所以「盖掉最旧的」一次盖 255 条 —— 这正是 §10.7 + 的容量算法里那 4096 条的来源。 + + ---- 为什么每条记录要带一个校验字节 -------------------------------------- + NOR flash 的写可以被掉电打断在任意一个字节上,落进去的是**半条记录**, + 而半条记录在字节层面看跟一条真记录长得一模一样(时间戳合法、路号合法、 + 类型合法)。没有校验的话,重启后的扫描会把它当成有效记录继续往后写, + 而上位机拉到的是一条时间戳来自过去、电压是 0xFFFF 的「事件」。 + 校验字节让扫描停在它前面 —— `test_a_torn_write_does_not_become_a_record()`。 + + ⚠️ 校验的是**完整性**,不是正确性:它认得出「写了一半」,认不出「写错了地方」。 + 后者由扇区头里的 epoch 与 recs 兜着。 + + ---- 字节序 -------------------------------------------------------------- + 记录是把结构体原样写下去的,所以 flash 里是**本机小端**。这不成问题: + 读它的只有同一颗 MCU 上的同一份固件。上位机拿到的记录是经链路层 + (A13/A14)序列化过去的,不是直接读 flash 的镜像。 */ +#include +#include + +#include "evlog.h" +#include "hal_lock.h" +#include "hal_store.h" +#include "hal_time.h" + +#define EVLOG_MAGIC 0x474F4C45u /* "ELOG" */ +#define EVLOG_VER 1 + +/* 一次 `hal_store_write()` 最多带几条。256 B 的栈上缓冲,够摊薄写事务的开销了; + 再大也只是让 `evlog_flush()` 的最坏耗时变长。 */ +#define EVLOG_CHUNK 16 + +typedef struct { + uint32_t magic; + uint16_t ver; + uint16_t recs; /* 本扇区能装几条。几何变了(换了 flash / 换了分区大小)时, + 旧扇区认不出来 —— 这比按新几何去解释旧内容好得多 */ + uint32_t epoch; /* 全局递增:第几个被写的扇区 */ + uint32_t chk; +} evlog_shdr_t; + +/* 两个「结构体就是 flash 布局」的地方,编译期钉死。 */ +typedef char evlog_rec_is_16b[(sizeof(evlog_rec_t) == 16) ? 1 : -1]; +typedef char evlog_hdr_is_16b[(sizeof(evlog_shdr_t) == 16) ? 1 : -1]; + +/* ---- RAM 环形缓冲 -------------------------------------------------------- + 入队方可能是任何一个任务(t_safety 优先级最高),出队方只有 t_log。 + 两个下标的改动在临界区里做完(hal_lock.h:那不是第三把锁)。 */ +static evlog_rec_t s_ring[EVLOG_RAM_CAP]; +static uint32_t s_head; /* 下一条写进去的位置 */ +static uint32_t s_tail; /* 下一条要落盘的位置 */ +static uint32_t s_count; /* 环里现有几条 */ +static uint32_t s_oldest_ms; /* 环里最老那条的入队时刻(攒批的计时起点) */ +static uint32_t s_lost; /* 欠着一条 LOST 记录,值是丢了多少条 */ + +/* ---- flash 侧 ------------------------------------------------------------ */ +static bool s_have_store; +static uint32_t s_sector_bytes; +static uint32_t s_sectors; +static uint32_t s_recs; /* 每扇区能装几条记录(= sector_bytes/16 − 1) */ +static uint32_t s_epoch; /* 正在写的扇区的 epoch */ +static uint32_t s_slot; /* 该扇区里下一个空槽;== s_recs 表示这个扇区写满了 */ +static uint32_t s_next_seq; +static uint32_t s_oldest_seq; + +/* ---- 计数器 -------------------------------------------------------------- */ +static uint32_t s_queued; +static uint32_t s_stored; +static uint32_t s_dropped; +static uint32_t s_io_errors; + +/* ---- 校验 ---------------------------------------------------------------- */ + +/* CRC-8(多项式 0x07,初值 0xA5)。 + + ⚠️ **不要「优化」成异或和。** 第一版就是异或和,它认不出下面这条: + 一条 `bus_mv` 与 `current_ua` 都是 0 的记录(BOOT 就长这样),被掉电切在 + 第 8 个字节上,后 8 个字节留在 0xFF —— 而 8 个 0xFF 的异或恰好是 0, + 于是半条记录的异或和与整条**一模一样**,校验照过。 + `test_a_torn_write_with_zero_payload_is_still_rejected()` 就是这条。 + CRC 对「一段字节整体变成 0xFF」敏感,初值也不是 0(认得出全 0 的一片)。 */ +static uint8_t rec_chk(const evlog_rec_t *r) +{ + const uint8_t *b = (const uint8_t *)r; + uint8_t c = 0xA5u; + for (size_t i = 0; i < sizeof(*r); i++) { + if (i == offsetof(evlog_rec_t, chk)) { + continue; + } + c ^= b[i]; + for (int k = 0; k < 8; k++) { + c = (uint8_t)((c & 0x80u) ? ((uint8_t)(c << 1) ^ 0x07u) : (uint8_t)(c << 1)); + } + } + return c; +} + +/* 擦干净的槽(16 个 0xFF)在这里必须是 false —— 扫描就靠它找「上次写到哪了」, + 而这是整份日志唯一的位置信息。所以不靠 CRC 去撞那 1/256,直接认死: + **全 0xFF = 没写过**。真记录里 `t_ms` 是 0xFFFFFFFF 且五个字段全 0xFF 的 + 那一条不存在(那是 49.7 天整、−1 mV、−1 µA、路号 255 同时成立)。 */ +static bool rec_all_ff(const evlog_rec_t *r) +{ + const uint8_t *b = (const uint8_t *)r; + for (size_t i = 0; i < sizeof(*r); i++) { + if (b[i] != 0xFFu) { + return false; + } + } + return true; +} + +static bool rec_valid(const evlog_rec_t *r) +{ + return !rec_all_ff(r) && r->chk == rec_chk(r); +} + +static uint32_t hdr_chk(const evlog_shdr_t *h) +{ + return h->magic ^ ((uint32_t)h->ver | ((uint32_t)h->recs << 16)) ^ h->epoch ^ 0x5A5A5A5Au; +} + +static bool hdr_valid(const evlog_shdr_t *h, uint32_t sector) +{ + return h->magic == EVLOG_MAGIC && h->ver == EVLOG_VER && h->recs == (uint16_t)s_recs && + h->chk == hdr_chk(h) && (h->epoch % s_sectors) == sector; +} + +static uint32_t rec_off(uint32_t sector, uint32_t slot) +{ + return sector * s_sector_bytes + (slot + 1u) * (uint32_t)sizeof(evlog_rec_t); +} + +/* ---- 入队(谁产生事件谁调;不阻塞、不碰 flash) -------------------------- */ + +static void push(const evlog_rec_t *r) +{ + /* ⚠️ 临界区里**只改内存**:时间戳、校验都在外面算完了。 + 往里面加一行「顺手」的活,抖的是 t_safety 那 20 ms 的节拍。 */ + hal_crit_enter(); + if (s_count == EVLOG_RAM_CAP) { + /* 满了盖最旧的一条 —— §10.7 的原则是「最新的一条永远进得来」。 + 丢掉的这条会在下一次落盘时以一条 LOST 记录留下痕迹。 */ + s_tail = (s_tail + 1u) % EVLOG_RAM_CAP; + s_count--; + s_dropped++; + s_lost++; + } + if (s_count == 0u) { + s_oldest_ms = r->t_ms; /* 攒批的计时从环里第一条算起 */ + } + s_ring[s_head] = *r; + s_head = (s_head + 1u) % EVLOG_RAM_CAP; + s_count++; + s_queued++; + hal_crit_exit(); +} + +void evlog_put(uint8_t rail, log_ev_t type, uint8_t arg, int32_t bus_mv, int32_t ua) +{ + evlog_rec_t r; + memset(&r, 0, sizeof(r)); /* 记录会被原样写进 flash:不留没初始化的字节 */ + r.t_ms = hal_time_now_ms(); + r.rail = rail; + r.type = (uint8_t)type; + r.arg = arg; + r.bus_mv = bus_mv; + r.current_ua = ua; + r.chk = rec_chk(&r); + push(&r); +} + +void evlog_put_safety(void *ctx, const safety_event_t *ev) +{ + (void)ctx; + if (ev == NULL) { + return; + } + evlog_rec_t r; + memset(&r, 0, sizeof(r)); + /* ⚠️ 时间戳用事件里带的那个:判据记的是**断电那一刻**, + 而这个回调是断完电才被调的。差的那几十微秒不重要,重要的是别把 + 「事件发生的时刻」悄悄换成「记账的时刻」—— 排查时序问题时会差出一整圈。 */ + r.t_ms = ev->t_ms; + r.rail = ev->rail; + r.type = (uint8_t)evlog_from_safety(ev->type); + r.bus_mv = ev->bus_mv; + r.current_ua = ev->current_ua; + r.chk = rec_chk(&r); + push(&r); +} + +/* 显式的一一对应。**不许写成 `LOG_EV_OVER_VOLT + (ev − 1)`** —— + 那样 t_safety.h 里插进一个新事件类型,flash 里所有旧记录的含义就整体挪位了, + 而且不会有任何一处报错。`test_the_safety_mapping_is_explicit()` 拦这件事。 */ +log_ev_t evlog_from_safety(uint8_t safety_ev) +{ + switch ((safety_ev_t)safety_ev) { + case SAFETY_EV_OVER_VOLT: return LOG_EV_OVER_VOLT; + case SAFETY_EV_UNDER_VOLT: return LOG_EV_UNDER_VOLT; + case SAFETY_EV_REVERSE: return LOG_EV_REVERSE; + case SAFETY_EV_PUSHED_UP: return LOG_EV_PUSHED_UP; + case SAFETY_EV_MUX_LOST: return LOG_EV_MUX_LOST; + case SAFETY_EV_SLOT_EMPTY: return LOG_EV_SLOT_EMPTY; + case SAFETY_EV_NONE: break; + } + return LOG_EV_NONE; +} + +/* ---- 落盘(只有 t_log 与 A12 的拉日志命令调) ---------------------------- */ + +static hal_err_t write_header(uint32_t sector, uint32_t epoch) +{ + evlog_shdr_t h; + memset(&h, 0, sizeof(h)); + h.magic = EVLOG_MAGIC; + h.ver = EVLOG_VER; + h.recs = (uint16_t)s_recs; + h.epoch = epoch; + h.chk = hdr_chk(&h); + return hal_store_write(sector * s_sector_bytes, &h, sizeof(h)); +} + +/* 换到下一个扇区:先擦(这一下盖掉的就是最老的 255 条),再写头。 */ +static hal_err_t advance_sector(void) +{ + const uint32_t epoch = s_epoch + 1u; + const uint32_t sector = epoch % s_sectors; + hal_err_t err = hal_store_erase(sector); + if (err != HAL_OK) { + return err; + } + err = write_header(sector, epoch); + if (err != HAL_OK) { + return err; + } + s_epoch = epoch; + s_slot = 0; + s_next_seq = epoch * s_recs; + if (epoch >= s_sectors) { + s_oldest_seq = (epoch - s_sectors + 1u) * s_recs; + } + return HAL_OK; +} + +/* 把 n 条连续记录写进当前扇区(跨扇区就自己换)。返回真的写进去几条。 */ +static uint32_t store_run(const evlog_rec_t *r, uint32_t n) +{ + uint32_t done = 0; + while (done < n) { + if (s_slot >= s_recs) { + if (advance_sector() != HAL_OK) { + s_io_errors++; + break; + } + } + uint32_t k = n - done; + if (k > s_recs - s_slot) { + k = s_recs - s_slot; + } + const uint32_t off = rec_off(s_epoch % s_sectors, s_slot); + if (hal_store_write(off, &r[done], k * (uint32_t)sizeof(evlog_rec_t)) != HAL_OK) { + s_io_errors++; + /* 写失败时**这一槽可能只落进去了半条**(掉电、或者 flash 真坏了)。 + 半条记录后面再接着写的话,重启扫描会停在半条那里,把后面写的 + 全部当成不存在。所以整个扇区的剩余部分就此放弃,下一条换扇区去。 + 代价是最多浪费 254 个槽,换来的是「扫描停在哪 = 日志到哪为止」 + 这条不变量永远成立。 */ + s_slot = s_recs; + break; + } + s_slot += k; + s_next_seq += k; + s_stored += k; + done += k; + } + return done; +} + +/* 补一条「这里丢了 n 条」。它自己也占一个序号 —— 日志里看得见的那种丢失。 + 返回真的写进去几条(0 或 1):它和普通记录一样算进 `evlog_flush()` 的返回值, + 否则会出现「flush 说写了 0 条,序号却往前走了一格」这种对不上的账。 */ +static uint32_t store_lost_marker(void) +{ + uint32_t n; + hal_crit_enter(); + n = s_lost; + s_lost = 0; + hal_crit_exit(); + if (n == 0u) { + return 0u; + } + evlog_rec_t r; + memset(&r, 0, sizeof(r)); + r.t_ms = hal_time_now_ms(); + r.type = (uint8_t)LOG_EV_LOST; + r.bus_mv = (int32_t)n; /* 丢了几条 */ + r.chk = rec_chk(&r); + if (store_run(&r, 1u) != 1u) { + hal_crit_enter(); + s_lost += n; /* 连这条都没写进去,欠着,下次再补 */ + hal_crit_exit(); + return 0u; + } + return 1u; +} + +static uint32_t pop(evlog_rec_t *dst, uint32_t want) +{ + hal_crit_enter(); + uint32_t n = (want < s_count) ? want : s_count; + for (uint32_t i = 0; i < n; i++) { + dst[i] = s_ring[(s_tail + i) % EVLOG_RAM_CAP]; + } + s_tail = (s_tail + n) % EVLOG_RAM_CAP; + s_count -= n; + if (s_count > 0u) { + s_oldest_ms = s_ring[s_tail].t_ms; + } + hal_crit_exit(); + return n; +} + +bool evlog_due(void) +{ + bool due; + hal_crit_enter(); + due = (s_lost > 0u) || (s_count >= EVLOG_BATCH) || + (s_count > 0u && hal_time_elapsed_ms(s_oldest_ms) >= EVLOG_MAX_AGE_MS); + hal_crit_exit(); + return due; +} + +uint32_t evlog_flush(uint32_t max) +{ + if (!s_have_store) { + return 0u; /* 没有那块分区:事件留在 RAM 里,别把它们弄丢 */ + } + const uint32_t budget = (max == 0u) ? EVLOG_RAM_CAP : max; + /* ⚠️ 补记的 LOST 不占预算:它是上一次落盘欠下的账,拖到下一次只会让 + 「日志里丢了一段」这件事更晚才被看见。最多超出预算 1 条。 */ + uint32_t total = store_lost_marker(); + + uint32_t from_ring = 0; + evlog_rec_t buf[EVLOG_CHUNK]; + while (from_ring < budget) { + uint32_t want = budget - from_ring; + if (want > EVLOG_CHUNK) { + want = EVLOG_CHUNK; + } + const uint32_t n = pop(buf, want); + if (n == 0u) { + break; + } + const uint32_t w = store_run(buf, n); + total += w; + from_ring += w; + if (w < n) { + /* 已经从环里取出来了却没写进去 —— 这些是真丢了, + 记进 LOST,下一次落盘时它会以一条记录的形式出现在日志里。 */ + hal_crit_enter(); + s_dropped += (n - w); + s_lost += (n - w); + hal_crit_exit(); + break; + } + } + return total; +} + +/* ---- 读回 ---------------------------------------------------------------- */ + +uint32_t evlog_read(uint32_t from_seq, evlog_rec_t *out, uint32_t max) +{ + if (!s_have_store || out == NULL || max == 0u) { + return 0u; + } + uint32_t seq = (from_seq < s_oldest_seq) ? s_oldest_seq : from_seq; + if (seq >= s_next_seq) { + return 0u; + } + uint32_t n = s_next_seq - seq; + if (n > max) { + n = max; + } + for (uint32_t i = 0; i < n; i++) { + const uint32_t s = seq + i; + const uint32_t epoch = s / s_recs; + const uint32_t slot = s % s_recs; + evlog_rec_t r; + memset(&r, 0, sizeof(r)); + if (hal_store_read(rec_off(epoch % s_sectors, slot), &r, sizeof(r)) != HAL_OK || + !rec_valid(&r)) { + /* 序号空间里的一个洞(写失败之后被放弃的槽)。**照样占一个位置**, + 否则调用方的游标 `from_seq + n` 会对不上,拉日志会卡在洞上。 */ + memset(&r, 0, sizeof(r)); + r.type = (uint8_t)LOG_EV_NONE; + } + out[i] = r; + } + return n; +} + +/* ---- 启动:扫一遍 flash 找到接着写的位置 --------------------------------- */ + +static hal_err_t format_all(void) +{ + for (uint32_t i = 0; i < s_sectors; i++) { + const hal_err_t err = hal_store_erase(i); + if (err != HAL_OK) { + return err; + } + } + const hal_err_t err = write_header(0u, 0u); + if (err != HAL_OK) { + return err; + } + s_epoch = 0; + s_slot = 0; + s_next_seq = 0; + s_oldest_seq = 0; + return HAL_OK; +} + +hal_err_t evlog_init(void) +{ + /* RAM 侧无条件清干净:真板上这里刚上电,宿主机上这条让 `sim_hal_reboot()` + 之后的行为和真重启一致(statics 在进程里是活着的)。 */ + hal_crit_enter(); + s_head = 0; + s_tail = 0; + s_count = 0; + s_oldest_ms = 0; + s_lost = 0; + hal_crit_exit(); + s_queued = 0; + s_stored = 0; + s_dropped = 0; + s_io_errors = 0; + s_have_store = false; + s_sector_bytes = 0; + s_sectors = 0; + s_recs = 0; + s_epoch = 0; + s_slot = 0; + s_next_seq = 0; + s_oldest_seq = 0; + + hal_err_t err = hal_store_init(); + if (err != HAL_OK) { + /* 没有分区。**不 abort、不影响入队**:日志是排查手段,不是安全链 + (evlog.h 上面那段)。事件照进 RAM,只是没人能把它们写下去。 */ + evlog_put(0, LOG_EV_BOOT, 0, 0, 0); + return err; + } + + hal_store_geom_t g; + hal_store_geometry(&g); + 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); + return HAL_ERR_STATE; + } + s_sector_bytes = g.sector_bytes; + s_sectors = g.sectors; + s_recs = g.sector_bytes / (uint32_t)sizeof(evlog_rec_t) - 1u; /* 头占一个槽 */ + s_have_store = true; + + /* 17 个扇区头里 epoch 最大的那个 = 上次正在写的扇区。 + `hdr_valid()` 顺带查了 `epoch % 扇区数 == 扇区号` —— 几何变了、 + 或者这块区被别人写过,都会在这里落空,然后整块重来。 */ + bool found = false; + uint32_t best = 0; + for (uint32_t i = 0; i < s_sectors; i++) { + evlog_shdr_t h; + memset(&h, 0, sizeof(h)); + if (hal_store_read(i * s_sector_bytes, &h, sizeof(h)) != HAL_OK) { + continue; + } + if (hdr_valid(&h, i) && (!found || h.epoch > best)) { + found = true; + best = h.epoch; + } + } + + if (!found) { + err = format_all(); + if (err != HAL_OK) { + s_io_errors++; + s_have_store = false; + } + } else { + s_epoch = best; + /* 扫到第一个校验不过的槽为止 —— 那就是上次写到的地方。 + 「校验不过」包含两种:擦干净的(0xFF)和被掉电切断的半条。 */ + s_slot = 0; + for (uint32_t r = 0; r < s_recs; r++) { + evlog_rec_t rec; + memset(&rec, 0, sizeof(rec)); + if (hal_store_read(rec_off(best % s_sectors, r), &rec, sizeof(rec)) != HAL_OK || + !rec_valid(&rec)) { + break; + } + s_slot = r + 1u; + } + s_next_seq = best * s_recs + s_slot; + s_oldest_seq = (best >= s_sectors) ? (best - s_sectors + 1u) * s_recs : 0u; + } + + /* §10.7「上电从 0 计」⇒ 时间戳会跳回去。没有这一条的话,重启前后的记录 + 在时间轴上互相穿插,而日志里看不出重启发生过。 */ + evlog_put(0, LOG_EV_BOOT, 0, 0, 0); + return s_have_store ? HAL_OK : HAL_ERR_STATE; +} + +/* ---- 杂项 ---------------------------------------------------------------- */ + +void evlog_stats(evlog_stats_t *st) +{ + if (st == NULL) { + return; + } + hal_crit_enter(); + st->queued = s_queued; + st->stored = s_stored; + st->dropped = s_dropped; + st->pending = s_count; + hal_crit_exit(); + st->io_errors = s_io_errors; + st->oldest_seq = s_oldest_seq; + st->next_seq = s_next_seq; +} + +uint32_t evlog_capacity(void) { return s_have_store ? s_sectors * s_recs : 0u; } + +const char *evlog_ev_str(log_ev_t ev) +{ + switch (ev) { + case LOG_EV_NONE: return "(空)"; + case LOG_EV_BOOT: return "上电"; + case LOG_EV_LOST: return "事件丢失"; + case LOG_EV_OVER_VOLT: return "过压关路"; + case LOG_EV_UNDER_VOLT: return "欠压关路"; + case LOG_EV_REVERSE: return "反灌断VBUS"; + case LOG_EV_PUSHED_UP: return "顶高断VBUS"; + case LOG_EV_EFUSE_FAULT: return "eFuse故障"; + case LOG_EV_PG_CHANGE: return "PG变化"; + case LOG_EV_I2C_LOST: return "I2C失联"; + case LOG_EV_THERMAL_DERATE: return "过温降载"; + case LOG_EV_FAN_STALL: return "风扇停转"; + case LOG_EV_REBOOT: return "重启序列"; + case LOG_EV_CMD_REJECT: return "命令被拒"; + case LOG_EV_MUX_LOST: return "mux失联全断"; + case LOG_EV_SLOT_EMPTY: return "刀片不在位"; + case LOG_EV_PD_LIMIT: return "PD限功率"; + case LOG_EV_COUNT: break; + } + return "?"; +} diff --git a/firmware/main/evlog.h b/firmware/main/evlog.h index fea1fee..0f014d8 100644 --- a/firmware/main/evlog.h +++ b/firmware/main/evlog.h @@ -1,160 +1,166 @@ -/* 事件日志(需求 §10.7)——「RAM 环形缓冲 + 批量落盘 + 按序号增量拉取」。 - - §10.7 的第一句话是这一节全部设计的来源:**记录路径不能依赖上位机在线**。 - 排线接触不良、手机把电压顶回来、eFuse 断了 —— 这些事发生时, - 最可能同时发生的就是「WiFi 也断了」。所以事件先进 RAM,再由最低优先级的 - `t_log` 攒够一批写进 flash;上位机什么时候连上来,什么时候按序号拉走。 - - ---- 三层,各自只干一件事 ------------------------------------------------ - 1. **入队**(`evlog_put*`):谁产生事件谁调。几条指令,临界区里做完, - **绝不阻塞、绝不碰 flash**。t_safety 是在断电之后调它的(t_safety.h)。 - 2. **落盘**(`evlog_flush`):只有 `t_log` 调。它是唯一碰 flash 的人。 - 3. **读回**(`evlog_read`):按全局序号取,给 A12 的 `events 起始序号` 用。 - - ---- 为什么序号不在入队时分配 -------------------------------------------- - 序号是**记录在 flash 里的位置**算出来的(`epoch × 每扇区条数 + 槽号`), - 不是一个自增计数器。这样一来「序号」和「flash 里第几条」永远是同一件事, - 重启之后接着数也不用把计数器存起来。代价是**没落盘的事件还没有序号**: - `evlog_read()` 只服务已经落盘的部分,要看最新的就先 `evlog_flush(0)`。 - 反过来做(入队时发号)会在 RAM 缓冲溢出丢事件时留下一段永远补不上的空号, - 而上位机那边的现象是「拉日志卡在某个序号上不动」。 - - ---- 丢事件是可以的,**悄悄丢**不行 -------------------------------------- - RAM 缓冲满了就覆盖最旧的(§10.7 的 4096 条同理:满了盖最旧,最新的一条 - 永远进得去)。被盖掉的那些会在下一次落盘时留下一条 `LOG_EV_LOST`, - `bus_mv` 字段记着丢了多少条。一份会**安静地**少掉几行的日志, - 比没有日志更糟 —— 排查的人会拿它当完整的时间线看。 */ -#ifndef EVLOG_H -#define EVLOG_H - -#include -#include - -#include "hal_err.h" -#include "t_safety.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* ---- 事件类型 ------------------------------------------------------------ - ⚠️⚠️ **这些数字是 flash 里的格式,不是一个内部枚举。** 板子上跑了半年的 - 日志是按数字存的,改一个值就等于把历史记录全部重新解释了一遍, - 而且**读出来仍然是合法的记录**,没有任何一处会报错。 - 只许在末尾追加,不许插入、不许重排、不许删除。 - `test_flash_format_is_frozen()` 把每一个数字都钉死了。 */ -typedef enum { - LOG_EV_NONE = 0, - LOG_EV_BOOT = 1, /* 每次上电的第一条。§10.7「上电从 0 计」⇒ 时间戳会跳回去, - 没有这一条的话,重启前后的记录混在一起没法读 */ - LOG_EV_LOST = 2, /* RAM 缓冲溢出,丢了 bus_mv 条(见文件头) */ - - /* A10 的四条判据事件。⚠️ 这里的数字与 `safety_ev_t` 的**不一样**, - 两边靠 `evlog_from_safety()` 里那个显式 switch 对应 —— 不许写成加个偏移。 */ - LOG_EV_OVER_VOLT = 3, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */ - LOG_EV_UNDER_VOLT = 4, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */ - LOG_EV_REVERSE = 5, /* SW-8:电流转负 → 断这一路 VBUS */ - LOG_EV_PUSHED_UP = 6, /* SW-8:VBUS 期间电压被顶高 → 断这一路 VBUS */ - - /* §10.7 必记事件里**还没人产生**的那几类。号先占住(见上面那条警告), - 产生它们的代码各归各的包:括号里是包号。 */ - LOG_EV_EFUSE_FAULT = 7, /* eFuse FLT# 拉低(A15) */ - LOG_EV_PG_CHANGE = 8, /* PGOOD 变化,arg = 新电平(A15) */ - LOG_EV_I2C_LOST = 9, /* I²C 器件失联,arg = 地址(A15) */ - LOG_EV_THERMAL_DERATE = 10,/* 过温降载,bus_mv = 温度 m°C(A16) */ - LOG_EV_FAN_STALL = 11, /* 风扇停转(A16) */ - LOG_EV_REBOOT = 12, /* 每次重启序列,arg = 软/硬 + 谁下的命令(A9/A12) */ - LOG_EV_CMD_REJECT = 13, /* 越界命令被拒,arg = 命令号(A12) */ - - LOG_EV_COUNT, -} log_ev_t; - -/* §10.7 的「单条」:时间戳 + 路号 + 事件类型 + 当时的 V/I,正好 16 B。 - ⚠️ 这个结构体的**内存布局就是 flash 布局**,直接按字节写下去的。 - 加字段 = 换格式(见上),改字段顺序同理。 */ -typedef struct { - uint32_t t_ms; /* 本次上电起算的毫秒(§10.7:上位机连上后下发墙钟做偏移) */ - uint8_t rail; /* 1~5;0 = 整板级事件(BOOT / LOST / 风扇 …) */ - uint8_t type; /* log_ev_t */ - uint8_t arg; /* 事件相关的小参数,没有就 0 */ - uint8_t chk; /* 另外 15 个字节的 CRC-8。见 evlog.c「为什么要校验」 */ - int32_t bus_mv; /* 出事那一刻的实测值,不是设定值 */ - int32_t current_ua; -} evlog_rec_t; - -/* RAM 环形缓冲的条数。§10.7 写的是「≥256 条」。 - 256 × 16 B = 4 KB —— 在 512 KB 的内部 RAM 里可以忽略,而它换来的是 - 「t_log 被饿住 4 秒(按 §10.7 的量级远不止)也不丢事件」。 */ -#define EVLOG_RAM_CAP 256 - -/* 攒够这么多条就落盘一次。一次 flash 写事务的开销与条数几乎无关, - 16 条一批 ⇒ 每条事件的擦写摊薄到 1/16。 */ -#define EVLOG_BATCH 16 - -/* 但最老的那一条最多等这么久 —— 攒批不能把「掉电前记下来」这件事攒没了。 - 1 s 是 §10.3 里 t_log「空闲时」的量级,也是掉电丢失窗口的上界。 */ -#define EVLOG_MAX_AGE_MS 1000 - -/* 一次 `evlog_flush()` 最多写这么多条。t_log 是最低优先级,长时间占着 flash - 本身没有危害,但一次写 4000 条会让**这个函数**变成一个几百毫秒的黑洞, - 而它是要被 A12 的命令处理器同步调用的(拉日志之前先 flush)。 */ -#define EVLOG_FLUSH_MAX 64 - -typedef struct { - uint32_t queued; /* 累计入队几条 */ - uint32_t stored; /* 累计落盘几条(含补记的 LOST) */ - uint32_t dropped; /* 累计被覆盖掉几条(RAM 缓冲溢出) */ - uint32_t pending; /* 此刻还在 RAM 里没落盘的条数 */ - uint32_t io_errors;/* flash 写/擦失败次数 */ - uint32_t oldest_seq; /* flash 里还留着的最老一条的序号 */ - uint32_t next_seq; /* 下一条落盘的记录会拿到的序号 */ -} evlog_stats_t; - -/* 扫一遍 flash 找到接着写的位置,并**入队一条 `LOG_EV_BOOT`**。 - 幂等:重复调等于「又重启了一次」,会再记一条 BOOT。 - 返回 HAL_ERR_STATE = 没有那块分区(ESP 上是分区表漏了 `evlog`)—— - ⚠️ 调用方**不要因此 abort**:日志是排查手段,不是安全链的一环(§10.2)。 - 这种情况下入队照常工作(事件留在 RAM 里),只是落不了盘。 */ -hal_err_t evlog_init(void); - -/* 入队一条。**不阻塞、不碰 flash、不会失败** —— 缓冲满了就盖掉最旧的一条 - 并记一次 `dropped`(下一次落盘时会补一条 LOST)。 - 任何任务、任何优先级都可以调,包括 t_safety 断电之后的那一下。 */ -void evlog_put(uint8_t rail, log_ev_t type, uint8_t arg, int32_t bus_mv, int32_t ua); - -/* A10 的事件出口。直接 `t_safety_set_event_sink(evlog_put_safety, NULL)`。 - ⚠️ 时间戳用的是**事件里带的那个**(判据记的是断电那一刻),不是现在。 */ -void evlog_put_safety(void *ctx, const safety_event_t *ev); - -/* `safety_ev_t` → `log_ev_t`。落盘格式与判据的枚举是两件事,见 log_ev_t 上的警告。 - 认不出来的返回 LOG_EV_NONE(不会发生;发生了也不该把这条记录扔掉)。 */ -log_ev_t evlog_from_safety(uint8_t safety_ev); - -/* 该落盘了吗:攒够 `EVLOG_BATCH` 条,或者最老的一条等了 `EVLOG_MAX_AGE_MS`。 - t_log 每一圈问一次,false 就接着睡。 */ -bool evlog_due(void); - -/* 把 RAM 里的写进 flash。max = 0 表示「现在有多少写多少」(上界就是缓冲的 - 256 条);t_log 每圈传的是 `EVLOG_FLUSH_MAX`。返回**真的写进 flash 几条** - —— 含一条补记的 LOST,所以它可能比预算多 1(那条是上次欠下的账)。 - 只有 t_log 与 A12 的拉日志命令调它 —— 它是**唯一**碰 flash 的地方。 */ -uint32_t evlog_flush(uint32_t max); - -/* 从 from_seq 起,按序号读回**已落盘**的记录(没落盘的先 flush,见文件头)。 - 返回真的读到几条。from_seq 比 `oldest_seq` 小时从 `oldest_seq` 开始 - —— 上位机据此就能发现「中间那一段已经被环形缓冲盖掉了」。 */ -uint32_t evlog_read(uint32_t from_seq, evlog_rec_t *out, uint32_t max); - -void evlog_stats(evlog_stats_t *st); - -/* flash 里最多能留几条(§10.7 要求 ≥4096)。没有分区时返回 0。 */ -uint32_t evlog_capacity(void); - -/* 只用于日志与测试失败信息,返回静态字符串,不会是 NULL。 */ -const char *evlog_ev_str(log_ev_t ev); - -#ifdef __cplusplus -} -#endif - -#endif /* EVLOG_H */ +/* 事件日志(需求 §10.7)——「RAM 环形缓冲 + 批量落盘 + 按序号增量拉取」。 + + §10.7 的第一句话是这一节全部设计的来源:**记录路径不能依赖上位机在线**。 + 排线接触不良、手机把电压顶回来、eFuse 断了 —— 这些事发生时, + 最可能同时发生的就是「WiFi 也断了」。所以事件先进 RAM,再由最低优先级的 + `t_log` 攒够一批写进 flash;上位机什么时候连上来,什么时候按序号拉走。 + + ---- 三层,各自只干一件事 ------------------------------------------------ + 1. **入队**(`evlog_put*`):谁产生事件谁调。几条指令,临界区里做完, + **绝不阻塞、绝不碰 flash**。t_safety 是在断电之后调它的(t_safety.h)。 + 2. **落盘**(`evlog_flush`):只有 `t_log` 调。它是唯一碰 flash 的人。 + 3. **读回**(`evlog_read`):按全局序号取,给 A12 的 `events 起始序号` 用。 + + ---- 为什么序号不在入队时分配 -------------------------------------------- + 序号是**记录在 flash 里的位置**算出来的(`epoch × 每扇区条数 + 槽号`), + 不是一个自增计数器。这样一来「序号」和「flash 里第几条」永远是同一件事, + 重启之后接着数也不用把计数器存起来。代价是**没落盘的事件还没有序号**: + `evlog_read()` 只服务已经落盘的部分,要看最新的就先 `evlog_flush(0)`。 + 反过来做(入队时发号)会在 RAM 缓冲溢出丢事件时留下一段永远补不上的空号, + 而上位机那边的现象是「拉日志卡在某个序号上不动」。 + + ---- 丢事件是可以的,**悄悄丢**不行 -------------------------------------- + RAM 缓冲满了就覆盖最旧的(§10.7 的 4096 条同理:满了盖最旧,最新的一条 + 永远进得去)。被盖掉的那些会在下一次落盘时留下一条 `LOG_EV_LOST`, + `bus_mv` 字段记着丢了多少条。一份会**安静地**少掉几行的日志, + 比没有日志更糟 —— 排查的人会拿它当完整的时间线看。 */ +#ifndef EVLOG_H +#define EVLOG_H + +#include +#include + +#include "hal_err.h" +#include "t_safety.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- 事件类型 ------------------------------------------------------------ + ⚠️⚠️ **这些数字是 flash 里的格式,不是一个内部枚举。** 板子上跑了半年的 + 日志是按数字存的,改一个值就等于把历史记录全部重新解释了一遍, + 而且**读出来仍然是合法的记录**,没有任何一处会报错。 + 只许在末尾追加,不许插入、不许重排、不许删除。 + `test_flash_format_is_frozen()` 把每一个数字都钉死了。 */ +typedef enum { + LOG_EV_NONE = 0, + LOG_EV_BOOT = 1, /* 每次上电的第一条。§10.7「上电从 0 计」⇒ 时间戳会跳回去, + 没有这一条的话,重启前后的记录混在一起没法读 */ + LOG_EV_LOST = 2, /* RAM 缓冲溢出,丢了 bus_mv 条(见文件头) */ + + /* A10 的四条判据事件。⚠️ 这里的数字与 `safety_ev_t` 的**不一样**, + 两边靠 `evlog_from_safety()` 里那个显式 switch 对应 —— 不许写成加个偏移。 */ + LOG_EV_OVER_VOLT = 3, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */ + LOG_EV_UNDER_VOLT = 4, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */ + LOG_EV_REVERSE = 5, /* SW-8:电流转负 → 断这一路 VBUS */ + LOG_EV_PUSHED_UP = 6, /* SW-8:VBUS 期间电压被顶高 → 断这一路 VBUS */ + + /* §10.7 必记事件里**还没人产生**的那几类。号先占住(见上面那条警告), + 产生它们的代码各归各的包:括号里是包号。 */ + LOG_EV_EFUSE_FAULT = 7, /* eFuse FLT# 拉低(A15) */ + LOG_EV_PG_CHANGE = 8, /* PGOOD 变化,arg = 新电平(A15) */ + LOG_EV_I2C_LOST = 9, /* I²C 器件失联,arg = 地址(A15) */ + LOG_EV_THERMAL_DERATE = 10,/* 过温降载,bus_mv = 温度 m°C(A16) */ + LOG_EV_FAN_STALL = 11, /* 风扇停转(A16) */ + LOG_EV_REBOOT = 12, /* 每次重启序列,arg = 软/硬 + 谁下的命令(A9/A12) */ + LOG_EV_CMD_REJECT = 13, /* 越界命令被拒,arg = 命令号(A12) */ + + /* ⭐ R2 新增(计划 §5.8 / §5.9)。**加在末尾**,前面 13 个的数字一个都没动 —— + flash 里跑过的历史记录因此仍然读得对。 */ + LOG_EV_MUX_LOST = 14, /* TCA9548A 连续选不中 ⇒ 五路全断(rail = 0,整板级) */ + LOG_EV_SLOT_EMPTY = 15,/* `PRSNT#` 为高:刀片不在位,这一路不许带电 */ + LOG_EV_PD_LIMIT = 16, /* PD 档位换了 / 触发限功率降载,bus_mv = 上限 mW/100(R-6) */ + + LOG_EV_COUNT, +} log_ev_t; + +/* §10.7 的「单条」:时间戳 + 路号 + 事件类型 + 当时的 V/I,正好 16 B。 + ⚠️ 这个结构体的**内存布局就是 flash 布局**,直接按字节写下去的。 + 加字段 = 换格式(见上),改字段顺序同理。 */ +typedef struct { + uint32_t t_ms; /* 本次上电起算的毫秒(§10.7:上位机连上后下发墙钟做偏移) */ + uint8_t rail; /* 1~5;0 = 整板级事件(BOOT / LOST / 风扇 …) */ + uint8_t type; /* log_ev_t */ + uint8_t arg; /* 事件相关的小参数,没有就 0 */ + uint8_t chk; /* 另外 15 个字节的 CRC-8。见 evlog.c「为什么要校验」 */ + int32_t bus_mv; /* 出事那一刻的实测值,不是设定值 */ + int32_t current_ua; +} evlog_rec_t; + +/* RAM 环形缓冲的条数。§10.7 写的是「≥256 条」。 + 256 × 16 B = 4 KB —— 在 512 KB 的内部 RAM 里可以忽略,而它换来的是 + 「t_log 被饿住 4 秒(按 §10.7 的量级远不止)也不丢事件」。 */ +#define EVLOG_RAM_CAP 256 + +/* 攒够这么多条就落盘一次。一次 flash 写事务的开销与条数几乎无关, + 16 条一批 ⇒ 每条事件的擦写摊薄到 1/16。 */ +#define EVLOG_BATCH 16 + +/* 但最老的那一条最多等这么久 —— 攒批不能把「掉电前记下来」这件事攒没了。 + 1 s 是 §10.3 里 t_log「空闲时」的量级,也是掉电丢失窗口的上界。 */ +#define EVLOG_MAX_AGE_MS 1000 + +/* 一次 `evlog_flush()` 最多写这么多条。t_log 是最低优先级,长时间占着 flash + 本身没有危害,但一次写 4000 条会让**这个函数**变成一个几百毫秒的黑洞, + 而它是要被 A12 的命令处理器同步调用的(拉日志之前先 flush)。 */ +#define EVLOG_FLUSH_MAX 64 + +typedef struct { + uint32_t queued; /* 累计入队几条 */ + uint32_t stored; /* 累计落盘几条(含补记的 LOST) */ + uint32_t dropped; /* 累计被覆盖掉几条(RAM 缓冲溢出) */ + uint32_t pending; /* 此刻还在 RAM 里没落盘的条数 */ + uint32_t io_errors;/* flash 写/擦失败次数 */ + uint32_t oldest_seq; /* flash 里还留着的最老一条的序号 */ + uint32_t next_seq; /* 下一条落盘的记录会拿到的序号 */ +} evlog_stats_t; + +/* 扫一遍 flash 找到接着写的位置,并**入队一条 `LOG_EV_BOOT`**。 + 幂等:重复调等于「又重启了一次」,会再记一条 BOOT。 + 返回 HAL_ERR_STATE = 没有那块分区(ESP 上是分区表漏了 `evlog`)—— + ⚠️ 调用方**不要因此 abort**:日志是排查手段,不是安全链的一环(§10.2)。 + 这种情况下入队照常工作(事件留在 RAM 里),只是落不了盘。 */ +hal_err_t evlog_init(void); + +/* 入队一条。**不阻塞、不碰 flash、不会失败** —— 缓冲满了就盖掉最旧的一条 + 并记一次 `dropped`(下一次落盘时会补一条 LOST)。 + 任何任务、任何优先级都可以调,包括 t_safety 断电之后的那一下。 */ +void evlog_put(uint8_t rail, log_ev_t type, uint8_t arg, int32_t bus_mv, int32_t ua); + +/* A10 的事件出口。直接 `t_safety_set_event_sink(evlog_put_safety, NULL)`。 + ⚠️ 时间戳用的是**事件里带的那个**(判据记的是断电那一刻),不是现在。 */ +void evlog_put_safety(void *ctx, const safety_event_t *ev); + +/* `safety_ev_t` → `log_ev_t`。落盘格式与判据的枚举是两件事,见 log_ev_t 上的警告。 + 认不出来的返回 LOG_EV_NONE(不会发生;发生了也不该把这条记录扔掉)。 */ +log_ev_t evlog_from_safety(uint8_t safety_ev); + +/* 该落盘了吗:攒够 `EVLOG_BATCH` 条,或者最老的一条等了 `EVLOG_MAX_AGE_MS`。 + t_log 每一圈问一次,false 就接着睡。 */ +bool evlog_due(void); + +/* 把 RAM 里的写进 flash。max = 0 表示「现在有多少写多少」(上界就是缓冲的 + 256 条);t_log 每圈传的是 `EVLOG_FLUSH_MAX`。返回**真的写进 flash 几条** + —— 含一条补记的 LOST,所以它可能比预算多 1(那条是上次欠下的账)。 + 只有 t_log 与 A12 的拉日志命令调它 —— 它是**唯一**碰 flash 的地方。 */ +uint32_t evlog_flush(uint32_t max); + +/* 从 from_seq 起,按序号读回**已落盘**的记录(没落盘的先 flush,见文件头)。 + 返回真的读到几条。from_seq 比 `oldest_seq` 小时从 `oldest_seq` 开始 + —— 上位机据此就能发现「中间那一段已经被环形缓冲盖掉了」。 */ +uint32_t evlog_read(uint32_t from_seq, evlog_rec_t *out, uint32_t max); + +void evlog_stats(evlog_stats_t *st); + +/* flash 里最多能留几条(§10.7 要求 ≥4096)。没有分区时返回 0。 */ +uint32_t evlog_capacity(void); + +/* 只用于日志与测试失败信息,返回静态字符串,不会是 NULL。 */ +const char *evlog_ev_str(log_ev_t ev); + +#ifdef __cplusplus +} +#endif + +#endif /* EVLOG_H */ diff --git a/firmware/main/i2cbus.c b/firmware/main/i2cbus.c new file mode 100644 index 0000000..85dd841 --- /dev/null +++ b/firmware/main/i2cbus.c @@ -0,0 +1,152 @@ +/* 带 mux 的 I²C 门面。契约与三条规矩见 i2cbus.h。 */ +#include "i2cbus.h" + +#include + +#include "board_pins.h" +#include "hal_gpio.h" +#include "hal_lock.h" +#include "hal_time.h" +#include "tca9548a.h" + +/* `MUX_RST#` 的低电平宽度。datasheet 只要求 6 ns,这里给的是「至少 1 ms」。 + ⚠️ **实际会比 1 ms 长**:ESP 侧 `hal_time_delay_ms()` 是 `vTaskDelay()`, + 在 `CONFIG_FREERTOS_HZ=100` 下一个 tick 就是 10 ms(而且它按「至少睡一个 + tick」写死)。对这颗器件绰绰有余,但要知道**调用者会被挡住这么久** —— + `i2cbus_recover()` 是 `t_safety` 在放锁之后调的,所以触发恢复的那一圈 + 巡检会晚一拍。这是可以接受的:走到这条路径时 mux 已经连续失联了, + 比起「五路电压电流全看不见还在继续跑」,晚一圈不算什么。 */ +#define I2CBUS_RST_MS 1 + +static bool s_inited; +static bool s_in_xact; /* 嵌套检测。单核上够用,双核上外面还有锁挡着 */ +static int s_ch = I2CBUS_CH_UNKNOWN; +static uint32_t s_selects; +static uint32_t s_select_fails; +static uint32_t s_fail_streak; +static uint32_t s_lock_timeouts; +static uint32_t s_recoveries; + +int i2cbus_channel(void) { return s_ch; } +uint32_t i2cbus_selects(void) { return s_selects; } +uint32_t i2cbus_select_fails(void) { return s_select_fails; } +uint32_t i2cbus_select_fail_streak(void) { return s_fail_streak; } +uint32_t i2cbus_lock_timeouts(void) { return s_lock_timeouts; } +uint32_t i2cbus_recoveries(void) { return s_recoveries; } + +void i2cbus_forget_channel(void) { s_ch = I2CBUS_CH_UNKNOWN; } + +/* 选通道。**成功才更新影子**;失败一律把影子作废(规矩 3)。 */ +static hal_err_t select_ch(int ch) +{ + if (ch == s_ch) { + return HAL_OK; /* 影子命中:一个字节都不发(计划 §5.5 的账就靠这个) */ + } + s_selects++; + const hal_err_t err = tca9548a_select(TCA9548A_ADDR, ch); + if (err != HAL_OK) { + s_ch = I2CBUS_CH_UNKNOWN; + s_select_fails++; + if (s_fail_streak < 0xFFFFFFFFu) { + s_fail_streak++; + } + return err; + } + s_ch = ch; + s_fail_streak = 0; + return HAL_OK; +} + +hal_err_t i2cbus_init(void) +{ + s_inited = false; + s_in_xact = false; + s_ch = I2CBUS_CH_UNKNOWN; + s_selects = 0; + s_select_fails = 0; + s_fail_streak = 0; + s_lock_timeouts = 0; + s_recoveries = 0; + + /* `MUX_RST#` 低有效、板上 R611 上拉。先写 1 再配输出会有一个「配成输出时 + 默认输出 0」的窗口,那就是一次意外复位 —— 所以顺序是:配输出(HAL 两侧 + 都保证配完是 0)→ 立刻写 1。窗口仍在,但只有几个指令,且此刻还没人用总线。 */ + hal_err_t err = hal_gpio_config(BOARD_PIN_MUX_RST_N, HAL_GPIO_OUTPUT); + if (err != HAL_OK) { + return err; + } + err = hal_gpio_write(BOARD_PIN_MUX_RST_N, 1); + if (err != HAL_OK) { + return err; + } + + s_inited = true; + + /* 认件顺便把通道全关。失败不 abort —— 见头文件。 */ + err = hal_lock_take(HAL_LOCK_I2C, HAL_LOCK_FOREVER); + if (err != HAL_OK) { + return err; + } + const hal_err_t probe = tca9548a_probe(TCA9548A_ADDR); + if (probe == HAL_OK) { + s_ch = I2CBUS_CH_TRUNK; /* probe 末尾就是「全关」 */ + } else { + s_ch = I2CBUS_CH_UNKNOWN; + } + hal_lock_give(HAL_LOCK_I2C); + return probe; +} + +hal_err_t i2cbus_xact(int ch, uint32_t timeout_ms, i2cbus_fn_t fn, void *arg) +{ + if (!s_inited || fn == NULL) { + return HAL_ERR_STATE; + } + if (s_in_xact) { + return HAL_ERR_STATE; /* 嵌套:回调里又调了一次(头文件末尾那条警告) */ + } + if (ch < I2CBUS_CH_TRUNK || ch >= TCA9548A_CH_COUNT) { + return HAL_ERR_ARG; + } + + hal_err_t err = hal_lock_take(HAL_LOCK_I2C, timeout_ms); + if (err != HAL_OK) { + s_lock_timeouts++; + return HAL_ERR_TIMEOUT; /* 这一次什么也没做 —— 调用方决定重试还是跳过 */ + } + + s_in_xact = true; + err = select_ch(ch); + if (err == HAL_OK) { + err = fn(arg); + } + s_in_xact = false; + + hal_lock_give(HAL_LOCK_I2C); + return err; +} + +hal_err_t i2cbus_recover(void) +{ + if (!s_inited) { + return HAL_ERR_STATE; + } + if (s_in_xact) { + return HAL_ERR_STATE; /* 持着锁去睡 1 ms,会把巡检钉住 */ + } + s_recoveries++; + + hal_err_t err = hal_gpio_write(BOARD_PIN_MUX_RST_N, 0); + if (err != HAL_OK) { + i2cbus_forget_channel(); + return err; + } + hal_time_delay_ms(I2CBUS_RST_MS); + err = hal_gpio_write(BOARD_PIN_MUX_RST_N, 1); + + /* ⚠️ **无论成功与否都作废影子**:复位之后器件里的通道是全关的, + 而影子还记着上一次选的那一路。不作废 = 下一次事务跳过选通、 + 发到一个断开的支路上,现象是「所有刀片一起失联」。 */ + i2cbus_forget_channel(); + return err; +} diff --git a/firmware/main/i2cbus.h b/firmware/main/i2cbus.h new file mode 100644 index 0000000..37db504 --- /dev/null +++ b/firmware/main/i2cbus.h @@ -0,0 +1,92 @@ +/* `i2cbus` —— 带 mux 的 I²C 总线门面(R2 新增,计划 §4.3)。 + + R2 之后,一次「读 CH3 的电压」在总线上其实是两件事: + **先写 TCA9548A 选通道 2,再读 0x40 的寄存器**。 + 这两件事之间如果被另一个任务插进来选了别的通道,读回来的是**另一台手机的 + 数据** —— 而且量纲、范围、变化趋势全部正常,没有任何一层会报错。 + 这是 R2 引入的、最难查的一类静默错误(C-d)。 + + 所以这一层只做一件事:**把「选通道 + 事务」变成一个不可分的动作。** + + ---- 三条规矩 ------------------------------------------------------------ + + 1. **应用层不许直接调 `hal_i2c_*`。** 驱动照旧只调 `hal_i2c_*`(它们要能单独 + 测),但驱动的**调用方**必须经 `i2cbus_xact()`。 + 2. **`i2cbus_select()` 不是公开接口。** 对外只有 `i2cbus_xact(ch, ...)`: + 拿锁 → 必要时选通道 → 跑回调 → 放锁。 + 3. **通道影子在任何一次选通失败之后作废**(置 `I2CBUS_CH_UNKNOWN`)。 + 不作废的话,下一次「通道相同、跳过选通」会把事务发到一个其实没切过去的 + 通道上 —— 这正好是第 1 条要防的那种错,只是由自己制造。 + + ---- 为什么要有通道影子 -------------------------------------------------- + `t_safety` 每 20 ms 跑一圈五路,如果每次 `xact` 都无条件发一遍选通字节, + 一圈就多 5 次事务。影子让「通道没变就不重发」,代价是上面第 3 条那个 + 必须小心处理的不变量。这笔账在计划 §5.5 里算过。 + + ⚠️ **回调里不许再调 `i2cbus_xact()`**(会嵌套拿同一把锁,直接 HAL_ERR_STATE), + 也不许在里面睡 —— 它是持着总线锁跑的。 */ +#ifndef I2CBUS_H +#define I2CBUS_H + +#include +#include + +#include "hal_err.h" +#include "tca9548a.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* 主干:所有支路断开。DAC63204 ×2 与输入侧 INA226 挂在这里。 */ +#define I2CBUS_CH_TRUNK TCA9548A_CH_NONE /* -1 */ + +/* 影子失效 —— 下一次 `xact` 无条件重发选通字节。 */ +#define I2CBUS_CH_UNKNOWN (-2) + +/* 一次事务的正文。**持着 I²C 锁、通道已经选好**的情况下被调一次。 + 返回值原样成为 `i2cbus_xact()` 的返回值(选通道失败时它根本不会被调)。 */ +typedef hal_err_t (*i2cbus_fn_t)(void *arg); + +/* 配 `MUX_RST#` 为输出并拉高(不复位)、认件、把通道全关。 + ⚠️ 必须在 `hal_i2c_init()` 与 `hal_lock_init()` 之后调。 + 认件失败照样返回错误码,**但不 abort**:mux 挂了是运行期的降级情形 + (计划 §5.8),不是固件写错了。 */ +hal_err_t i2cbus_init(void); + +/* 三件事一气呵成:拿锁(等 `timeout_ms`)→ 必要时选通道 → 跑 `fn` → 放锁。 + + 返回: + HAL_ERR_STATE —— 没 init,或者**嵌套调用**(回调里又调了一次); + HAL_ERR_TIMEOUT —— 没拿到锁(这一次什么也没做,调用方自己决定重试还是跳过); + 选通道的错误码 —— `fn` 一次都没被调; + 否则 = `fn` 的返回值。 */ +hal_err_t i2cbus_xact(int ch, uint32_t timeout_ms, i2cbus_fn_t fn, void *arg); + +/* ---- 观测与恢复 ---------------------------------------------------------- */ + +int i2cbus_channel(void); /* 当前影子:0~7 / `I2CBUS_CH_TRUNK` / `I2CBUS_CH_UNKNOWN` */ + +/* **连续**选通失败次数(成功一次就清零)。计划 §5.8 的「mux 失联」判据读它。 + ⚠️ 判据用「连续」而不是「累计」:一次总线毛刺不该停掉五台手机。 */ +uint32_t i2cbus_select_fail_streak(void); + +/* 累计量,给遥测与测试用。 */ +uint32_t i2cbus_selects(void); /* 真正发出去的选通字节数(影子命中不算) */ +uint32_t i2cbus_select_fails(void); /* 累计失败次数 */ +uint32_t i2cbus_lock_timeouts(void); +uint32_t i2cbus_recoveries(void); + +/* `MUX_RST#` 拉低一下再放开,然后把通道影子作废。 + 判据在「五路全断」之前必须先试这一次(计划 §5.8)。 + ⚠️ 自己拿锁,**不许在 `xact` 的回调里调**。 */ +hal_err_t i2cbus_recover(void); + +/* 影子作废。给 `i2cbus_recover()` 与测试用;正常路径不需要。 */ +void i2cbus_forget_channel(void); + +#ifdef __cplusplus +} +#endif + +#endif /* I2CBUS_H */ diff --git a/firmware/main/rail_cfg.c b/firmware/main/rail_cfg.c index 118dbc1..85e13da 100644 --- a/firmware/main/rail_cfg.c +++ b/firmware/main/rail_cfg.c @@ -1,4 +1,4 @@ -/* 五路配置表 + 启动自检 + 两根 EN 线。契约与来历见 rail_cfg.h。 */ +/* 五路配置表 + 启动自检 + `EFUSE_EN`。契约与来历见 rail_cfg.h。 */ #include "rail_cfg.h" #include @@ -9,27 +9,31 @@ #include "hal_gpio.h" #include "ina226.h" #include "pca9555.h" -#include "sr595.h" +#include "slot.h" +#include "tca9548a.h" /* ---- 那张表 -------------------------------------------------------------- - 六列的出处:INA 地址 §8.2;DAC 片/通道 §8.2 + §26.18;按键位与 EDL 位 - §17.2 + §26.18 C(从 `.claude/pcb1-parts.json` 的真实焊盘网络逐脚核过); - 两根 EN 的 GPIO §8.1(同样按 U20 的焊盘网络核过,2026-09-07)。 + 六列的出处(全部 2026-09-16 从板上缓存逐格核过): + · INA 地址 —— `.claude/r2/_blade_pcb_nets.json` 的 `U3` pin1/pin2 都接 GND ⇒ 0x40 + · mux 通道 —— `.claude/r2/_base_pcb_nets.json` 的 `U35`:SD0/SC0=SDA1/SCL1 → 槽 1, + 一路到 SD4/SC4=SDA5/SCL5 → 槽 5(通道 5 给 CH224A,通道 6/7 悬空) + · DAC 片/通道 —— R1 §8.2 + §26.18,**D11b 之后一个字没改** + · `EFUSE_EN` / `PRSNT#` —— `_base_pcb_nets.json` 的 `U20`(模组脚 → GPIO 见 board_pins.h) - ⚠️ **一列都不许改成算式。** 六列里没有一列是顺排的。 */ + ⚠️ 顺排的那几列也不许改成算式,`PRSNT#` 那一列就是现成的反例。 */ static const rail_cfg_t k_cfg[PHONERAIL_RAIL_COUNT] = { - /* INA DAC片 ch EFUSE VBUS PWR VUP VDN EDL FLT PG */ - /* CH1 */ { 0x40, 0x4A, 2, 39, 40, { 1, 2, 3 }, 16, 0, 0 }, - /* CH2 */ { 0x41, 0x4A, 0, 48, 10, { 19, 17, 18 }, 7, 1, 1 }, - /* CH3 */ { 0x44, 0x4B, 0, 6, 11, { 22, 21, 20 }, 6, 2, 2 }, - /* CH4 */ { 0x45, 0x4A, 1, 41, 42, { 9, 10, 11 }, 0, 3, 3 }, - /* CH5 */ { 0x42, 0x4B, 1, 8, 17, { 15, 14, 13 }, 5, 4, 4 }, + /* INA mux DAC片 ch EFUSE_EN PRSNT# */ + /* CH1 */ { INA226_ADDR_RAIL, 0, 0x4A, 2, 9, 15 }, + /* CH2 */ { INA226_ADDR_RAIL, 1, 0x4A, 0, 10, 16 }, + /* CH3 */ { INA226_ADDR_RAIL, 2, 0x4B, 0, 11, 17 }, + /* CH4 */ { INA226_ADDR_RAIL, 3, 0x4A, 1, 12, 18 }, + /* CH5 */ { INA226_ADDR_RAIL, 4, 0x4B, 1, 13, 7 }, /* ⚠️ PRSNT5# 不接着顺排 */ }; _Static_assert(PHONERAIL_RAIL_COUNT == INA226_RAIL_COUNT, "五路数对不上 INA226"); _Static_assert(PHONERAIL_RAIL_COUNT == DAC63204_RAIL_COUNT, "五路数对不上 DAC63204"); -_Static_assert(PHONERAIL_RAIL_COUNT == PCA9555_RAIL_COUNT, "五路数对不上 PCA9555"); +_Static_assert(PHONERAIL_RAIL_COUNT == PCA9555_SLOT_COUNT, "五路数对不上 PCA9555 实例数"); const rail_cfg_t *rail_cfg(int rail) { @@ -37,9 +41,8 @@ const rail_cfg_t *rail_cfg(int rail) return &k_cfg[rail - 1]; } -/* ---- 自检之一:本表与四个驱动的表逐格对账 -------------------------------- - 分家的现象是「命令发给了另一路」,功能测试测不出来,所以这里是**逐格**比, - 不是抽查。四个驱动的取值函数越界会 abort,但这里的 rail 恒在 1~5。 */ +/* ---- 自检之一:本表与驱动的表逐格对账 ------------------------------------ + 分家的现象是「命令发给了另一路」,功能测试测不出来,所以这里是**逐格**比。 */ static bool table_matches_drivers(const rail_cfg_t *t) { for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { @@ -51,27 +54,36 @@ static bool table_matches_drivers(const rail_cfg_t *t) if (c->dac_addr != dac63204_rail_addr(rail) || c->dac_ch != dac63204_rail_ch(rail)) { return false; } - for (int k = 0; k < SR595_KEY_COUNT; k++) { - if (c->key_bit[k] != (uint8_t)sr595_key_bit(rail, (sr595_key_t)k)) { - return false; + } + return true; +} + +/* ---- 自检之二:mux 通道(R2 新增) --------------------------------------- + 三条:都在 0~7 之内、五路互不重复、**都不等于 PD 那一路**。 + 第三条不是洁癖:通道 5 上是 CH224A,选到它再去读 0x40 会读不到任何东西, + 而现象是「这一路的电压电流突然全是 0」—— 看上去像刀片拔了。 */ +static bool mux_ok(const rail_cfg_t *t) +{ + for (int i = 0; i < PHONERAIL_RAIL_COUNT; i++) { + const uint8_t ch = t[i].mux_ch; + if (ch >= TCA9548A_CH_COUNT) { + return false; + } + if (ch == TCA9548A_CH_PD) { + return false; + } + for (int j = i + 1; j < PHONERAIL_RAIL_COUNT; j++) { + if (t[j].mux_ch == ch) { + return false; /* 两路指向同一片刀片 */ } } - if (c->edl_bit != (uint8_t)sr595_edl_bit(rail)) { - return false; - } - if (c->flt_bit != (uint8_t)pca9555_flt_bit(rail)) { - return false; - } - if (c->pg_bit != (uint8_t)pca9555_pg_bit(rail)) { - return false; - } } return true; } -/* ---- 自检之二:引脚无重复、无越界 ---------------------------------------- +/* ---- 自检之三:引脚无重复、无越界 ---------------------------------------- 查的是**全板**,不只是五路那十个脚:重复的两个脚一定分属两个模块, - 只查自己那十个的话,「`VBUS_EN2` 写成了 IO14」这种错(撞上 SDA)查不出来。 */ + 只查自己那十个的话,「`PRSNT3#` 写成了 IO41」这种错(撞上 SDA)查不出来。 */ static bool pin_in(const int *list, size_t n, int pin) { @@ -88,6 +100,7 @@ static bool pin_usable(int pin) static const int absent[] = BOARD_PINS_ABSENT; static const int strapping[] = BOARD_PINS_STRAPPING; static const int reserved[] = BOARD_PINS_RESERVED; + static const int uart0[] = BOARD_PINS_UART0; if (pin < 0 || pin > BOARD_PIN_NUM_MAX) { return false; @@ -99,7 +112,10 @@ static bool pin_usable(int pin) return false; } if (pin_in(reserved, sizeof(reserved) / sizeof(reserved[0]), pin)) { - return false; /* R8 版八线 PSRAM 占着,留空以便换料 */ + return false; /* N16R8 的八线 PSRAM 占着 */ + } + if (pin_in(uart0, sizeof(uart0) / sizeof(uart0[0]), pin)) { + return false; /* J33 应急口:Hub 挂掉之后唯一进得来的路(R2 D13) */ } return true; } @@ -107,28 +123,24 @@ static bool pin_usable(int pin) static bool pins_ok(const rail_cfg_t *t) { /* 全板用掉的每一个脚。顺序无所谓,这里只查「都能用」和「不重复」。 */ - int used[2 * PHONERAIL_RAIL_COUNT + 14]; + int used[2 * PHONERAIL_RAIL_COUNT + 11]; size_t n = 0; for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { used[n++] = t[rail - 1].efuse_en; - used[n++] = t[rail - 1].vbus_en; + used[n++] = t[rail - 1].prsnt_n; } used[n++] = BOARD_PIN_I2C_SDA; used[n++] = BOARD_PIN_I2C_SCL; - used[n++] = BOARD_PIN_FAN_TACH; - used[n++] = BOARD_PIN_FAN_PWM; + used[n++] = BOARD_PIN_MUX_RST_N; + used[n++] = BOARD_PIN_IOEXP_INT_N; + used[n++] = BOARD_PIN_INA_ALERT_N; + used[n++] = BOARD_PIN_HUB_RST_N; used[n++] = BOARD_PIN_UP_VBUS_DET; used[n++] = BOARD_PIN_NTC1; used[n++] = BOARD_PIN_NTC2; - used[n++] = BOARD_PIN_USB_DM; - used[n++] = BOARD_PIN_USB_DP; - /* 四根移位链的线与 PCA9555 的中断脚:**引用驱动里的宏,不重抄一遍数字**。 */ - used[n++] = SR595_PIN_SER; - used[n++] = SR595_PIN_SRCLK; - used[n++] = SR595_PIN_RCLK; - used[n++] = SR595_PIN_OE_N; - used[n++] = PCA9555_PIN_INT_N; + used[n++] = BOARD_PIN_FAN_PWM; + used[n++] = BOARD_PIN_FAN_EN; assert(n == sizeof(used) / sizeof(used[0])); for (size_t i = 0; i < n; i++) { @@ -142,6 +154,29 @@ static bool pins_ok(const rail_cfg_t *t) } } + /* 两路 TACH 与 USB 的 D± 单独查:TACH 会与上面那些撞,而 D± 是硬连线, + 它们只要不和五路那十个脚撞就行。 */ + const int more[] = { BOARD_PIN_FAN_TACH_IN, BOARD_PIN_FAN_TACH_OUT }; + for (size_t k = 0; k < sizeof(more) / sizeof(more[0]); k++) { + if (!pin_usable(more[k])) { + return false; + } + for (size_t i = 0; i < n; i++) { + if (used[i] == more[k]) { + return false; + } + } + } + if (BOARD_PIN_FAN_TACH_IN == BOARD_PIN_FAN_TACH_OUT) { + return false; + } + /* 原生 USB 的两个脚是硬连线(IO19/IO20),谁也不能占。 */ + for (size_t i = 0; i < n; i++) { + if (used[i] == BOARD_PIN_USB_DM || used[i] == BOARD_PIN_USB_DP) { + return false; + } + } + /* 两路 NTC 必须在 ADC1(IO1~IO10)上:ADC2 在 WiFi 开着的时候读不了, 而本板 WiFi 是常开的(§10.8 传输 A)。挪到 ADC2 上不会报错, 只会在连上 WiFi 之后开始读失败 —— 台架上很难复现。 */ @@ -158,7 +193,7 @@ uint32_t rail_selftest_table(const rail_cfg_t *t) uint32_t bad = 0; if (t == NULL) { - return RAIL_SELFTEST_TABLE | RAIL_SELFTEST_PINS; + return RAIL_SELFTEST_TABLE | RAIL_SELFTEST_PINS | RAIL_SELFTEST_MUX; } if (!table_matches_drivers(t)) { bad |= RAIL_SELFTEST_TABLE; @@ -166,6 +201,9 @@ uint32_t rail_selftest_table(const rail_cfg_t *t) if (!pins_ok(t)) { bad |= RAIL_SELFTEST_PINS; } + if (!mux_ok(t)) { + bad |= RAIL_SELFTEST_MUX; + } return bad; } @@ -173,17 +211,14 @@ uint32_t rail_selftest(void) { uint32_t bad = 0; - /* 四个驱动各自的表(A3~A6 都留了这个谓词,A7 是它们的归宿)。 */ + /* 三个驱动各自的表(A3/A4/R-2 都留了这个谓词)。 */ if (!ina226_addr_table_ok()) { bad |= RAIL_SELFTEST_INA; } if (!dac63204_map_ok()) { bad |= RAIL_SELFTEST_DAC; } - if (!sr595_map_ok()) { - bad |= RAIL_SELFTEST_SR595; - } - if (!pca9555_map_ok() || !pca9555_addr_ok()) { + if (!pca9555_map_ok() || !pca9555_addr_ok() || !tca9548a_addr_ok()) { bad |= RAIL_SELFTEST_PCA9555; } return bad | rail_selftest_table(k_cfg); @@ -196,10 +231,10 @@ const char *rail_selftest_str(uint32_t bit) return "INA226 地址表"; case RAIL_SELFTEST_DAC: return "DAC63204 映射表"; - case RAIL_SELFTEST_SR595: - return "74HC595 位表"; + case RAIL_SELFTEST_MUX: + return "mux 通道重复或越界"; case RAIL_SELFTEST_PCA9555: - return "PCA9555 位表/地址"; + return "PCA9555/TCA9548A 位表或地址"; case RAIL_SELFTEST_TABLE: return "五路表与驱动分家"; case RAIL_SELFTEST_PINS: @@ -209,50 +244,39 @@ const char *rail_selftest_str(uint32_t bit) } } -/* ---- 两根 EN 线 ---------------------------------------------------------- */ +/* ---- `EFUSE_EN` ---------------------------------------------------------- */ -/* 高有效。**全固件唯一一处极性**(和 `sr595_set_hub_reset()` 里那个反相、 - `rail_mv_to_code()` 里那个减法同一个道理:多一处就多一处写反的机会)。 */ +/* 高有效。**全固件唯一一处极性**(和 `rail_mv_to_code()` 里那个减法、 + `slot.c` 里 VBUS 那一处同一个道理:多一处就多一处写反的机会)。 */ static hal_err_t en_write(int pin, bool on) { return hal_gpio_write(pin, on ? 1 : 0); } -/* 两根线的影子(契约见 rail_cfg.h)。写失败时**不更新** —— 那一次的线上电平 - 是未知的,而「保留上一次成功的意图」在两个方向上都倒向安全的那一侧。 */ +/* 影子(契约见 rail_cfg.h)。写失败时**不更新**。 */ static bool s_efuse_on[PHONERAIL_RAIL_COUNT]; -static bool s_vbus_on[PHONERAIL_RAIL_COUNT]; bool rail_efuse_is_on(int rail) { return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT && s_efuse_on[rail - 1]; } -bool rail_vbus_is_on(int rail) -{ - return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT && s_vbus_on[rail - 1]; -} - hal_err_t rail_en_init(void) { for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { - const rail_cfg_t *c = &k_cfg[rail - 1]; - const int pins[2] = { c->efuse_en, c->vbus_en }; + const int pin = k_cfg[rail - 1].efuse_en; - for (int i = 0; i < 2; i++) { - hal_err_t err = hal_gpio_config(pins[i], HAL_GPIO_OUTPUT); - if (err != HAL_OK) { - return err; - } - /* 配成输出的那一刻两边的 HAL 都已经在输出 0 了,这一句是把「关」 - 写成明文 —— 别让「五路上电即全关」依赖某个默认值。 */ - err = en_write(pins[i], false); - if (err != HAL_OK) { - return err; - } + hal_err_t err = hal_gpio_config(pin, HAL_GPIO_OUTPUT); + if (err != HAL_OK) { + return err; + } + /* 配成输出的那一刻两边的 HAL 都已经在输出 0 了,这一句是把「关」 + 写成明文 —— 别让「五路上电即全关」依赖某个默认值。 */ + err = en_write(pin, false); + if (err != HAL_OK) { + return err; } s_efuse_on[rail - 1] = false; - s_vbus_on[rail - 1] = false; } return HAL_OK; } @@ -269,26 +293,15 @@ hal_err_t rail_efuse_enable(int rail, bool on) return err; } -hal_err_t rail_vbus_enable(int rail, bool on) -{ - if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) { - return HAL_ERR_ARG; - } - const hal_err_t err = en_write(k_cfg[rail - 1].vbus_en, on); - if (err == HAL_OK) { - s_vbus_on[rail - 1] = on; - } - return err; -} - hal_err_t rail_all_off(void) { hal_err_t first = HAL_OK; - /* 先断五路 VBUS,再关五路 eFuse:反过来的话中间有一小段「手机只靠 USB - 供着电」,那既不是开也不是关。十个都要试,不许中途 return。 */ + /* 先断五路 VBUS(走 I²C,可能失败),再关五路 eFuse(GPIO,永远成功)。 + 反过来的话中间有一小段「手机只靠 USB 供着电」,那既不是开也不是关。 + 十次都要试,不许中途 return —— 关电路径上「剩下的也别关了」是最坏的选择。 */ for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { - hal_err_t err = rail_vbus_enable(rail, false); + hal_err_t err = slot_vbus_enable(rail, false); if (err != HAL_OK && first == HAL_OK) { first = err; } diff --git a/firmware/main/rail_cfg.h b/firmware/main/rail_cfg.h index f5b2ff6..f5ad98c 100644 --- a/firmware/main/rail_cfg.h +++ b/firmware/main/rail_cfg.h @@ -1,35 +1,40 @@ -/* 五路一张表(需求 §10.4)—— **禁止用通道号算任何东西**。 +/* 五路一张表(需求 §10.4,**R2 版**)—— **禁止用通道号算任何东西**。 - 板上和「第几路」有关的编号有六组,**没有一组是顺排的,也没有两组是一致的**: + ---- R1 → R2:这条规矩换了对象,没有作废 -------------------------------- - CH INA226 DAC 按键位(PWR/VUP/VDN) EDL 位 EFUSE_EN VBUS_EN - 1 0x40 U6.OUT2 1 2 3 16 IO39 IO40 - 2 0x41 U6.OUT0 19 17 18 7 IO48 IO10 - 3 0x44 **U7**.OUT0 22 21 20 6 IO6 IO11 - 4 0x45 U6.OUT1 9 10 11 0 IO41 IO42 - 5 0x42 U7.OUT1 15 14 13 5 IO8 IO17 + R1 的板子上,和「第几路」有关的编号有六组,没有一组是顺排的。 + R2 把五路做成了**五片完全相同的刀片**,于是: - 六组编号各有各的来历(I²C 地址按布线难度、595 位号按 PCB 几何、GPIO 按出线 - 方向),三条都在设计文档里:§8.2 / §17.2 + §26.18 C / §8.1。 + · INA226 地址:五路**全是 0x40**(五片同构,靠 mux 区分) + · PCA9555 地址与位号:五路**逐格相同**(所以根本不进这张表,在 pca9555.h) + · DAC 片/通道:**一个字没改**,仍然是 R1 那张不顺排的表 + (⚠️ CH3 在 `U7` 而不是 `U6`,这个坑原样还在) + · mux 通道:0 1 2 3 4,**这次真的是顺排的** + · `EFUSE_EN`:IO9 IO10 IO11 IO12 IO13,也是顺排的(R2 §29.5 为布线重排过) + · `PRSNT#`:IO15 IO16 IO17 IO18 **IO7** —— **到第五路就断了**(方案 B 重排) + + 顺排的那三列仍然写进表里查,理由是:**顺排是当下的事实,不是不变量**。 + `PRSNT#` 那一列就是现成的反例 —— 四路顺排、第五路不是。 ⚠️ **这一片写错了,板子照样正常工作** —— 只是「重启 CH1」重启了 CH4、 - 「读 CH3 电流」读的是 CH5。功能测试测不出来,五台手机跑起来也看不出来, - 要等到某一路真出故障、而你关掉了另一路的时候才暴露。所以: + 「读 CH3 电流」读的是 CH5。功能测试测不出来,五台手机跑起来也看不出来。 - **除了本文件里这张表,任何地方都不许出现 `0x40 + rail`、`rail - 1` - 这类由通道号算出来的器件编号。** + **除了本文件里这张表,任何地方都不许出现 `rail - 1`、`0x40 + rail` + 这类由通道号算出来的器件编号 / 通道号。** - ---- 这张表和四个驱动里那四张表是什么关系 ---------------------------------- + ---- 不在这张表里的东西 -------------------------------------------------- - A3~A6 四个驱动各自带着自己那一张表(`ina226_rail_addr()` 等),因为驱动要能 - 单独测。本表是**把它们横着拼起来**的那一张,多出来的只有两列 GPIO - (`EFUSE_EN` / `VBUS_EN` 是 GPIO 直连,没有驱动可挂)。 + | 东西 | 在哪 | 为什么不在这里 | + |---|---|---| + | 三个按键位 / EDL / VBUS 位 | `pca9555.h` | 五路逐格相同,是「板级常量」不是「每路一个」 | + | `VBUS_EN` 的 GPIO | **没有了** | R2 里它在刀片的 PCA9555 P0.4 上(计划 §5.4) | + | 24 位移位链 | **没有了** | R2 D9:整条链退役 | - 两处写同一个数就有分家的风险,所以 `rail_selftest()` 会**逐格**拿本表和四个 - 驱动的取值函数对账,对不上直接判失败(`RAIL_SELFTEST_TABLE`)。 - 宿主机 `ctest` 每次都跑,上板 `app_main()` 开机也跑一次。 - —— 之所以不做成「开机时从驱动现算」,是因为那样**按定义就查不出分家**, - 而且表就不是 const 的了,用之前得先记着初始化。 */ + ---- 这张表和三个驱动里那三张表是什么关系 -------------------------------- + + 驱动各自带着自己那一张表(`ina226_rail_addr()` 等),因为驱动要能单独测。 + 本表是**把它们横着拼起来**的那一张,多出来的是 mux 通道与两列 GPIO。 + `rail_selftest()` 会**逐格**拿本表和驱动的取值函数对账,对不上直接判失败。 */ #ifndef RAIL_CFG_H #define RAIL_CFG_H @@ -38,28 +43,30 @@ #include "hal_err.h" #include "phonerail.h" -#include "sr595.h" /* sr595_key_t:按键位那三格的下标就是它 */ #ifdef __cplusplus extern "C" { #endif typedef struct { - uint8_t ina_addr; /* 0x40 0x41 0x44 0x45 0x42 —— 查表,不能算 */ - uint8_t dac_addr; /* 0x4A: CH1/2/4 0x4B: CH3/5 —— 也不是「前三路在 U6」 */ + /* 本路 INA226 的地址。**五路相同(0x40)** —— 区分是哪一路的是下面那个通道。 */ + uint8_t ina_addr; + + /* 本路在底板 TCA9548A 上的通道(0~7)。⚠️ **R2 里选错它 = 读到另一台手机**, + 而量纲、范围、变化趋势全都正常(计划 §5.1 C-d)。 */ + uint8_t mux_ch; + + uint8_t dac_addr; /* 0x4A: CH1/2/4 0x4B: CH3/5 —— 不是「前三路在 U6」 */ uint8_t dac_ch; /* 该片上的 OUT 号(0~3) */ - /* GPIO 直连,安全关键(§8.1「任何情况下都要能关电」)。 + /* `EFUSE_EN`:ESP32 GPIO **直连**,安全关键(R2 §7.3 的框:保护链第三层 + 唯一的执行手段,任何时候都不许挪到 I²C 后面)。 类型是 `int`,因为 `hal_gpio_*()` 收的就是 int;本表是唯一的出处。 */ int efuse_en; - int vbus_en; - /* 24 位移位链上的位号。下标用 `SR595_KEY_PWR/VUP/VDN`,别用 0/1/2。 */ - uint8_t key_bit[SR595_KEY_COUNT]; - uint8_t edl_bit; - - uint8_t flt_bit; /* PCA9555 **Port0** bit0~4 */ - uint8_t pg_bit; /* PCA9555 **Port1** bit0~4 —— 两个端口长得一模一样,别搞反 */ + /* `PRSNT#`:GPIO 输入,板上 3V3 上拉,**低 = 刀片在位**。 + 空槽上电 = 金手指裸露带 12V,所以这是一条安全判据(计划 §5.8)。 */ + int prsnt_n; } rail_cfg_t; /* rail 是 **1~5**(不是 0~4)。越界直接 abort:那是调用方算错了路号, @@ -73,56 +80,55 @@ const rail_cfg_t *rail_cfg(int rail); typedef enum { RAIL_SELFTEST_INA = 1u << 0, /* ina226_addr_table_ok() 不过 */ RAIL_SELFTEST_DAC = 1u << 1, /* dac63204_map_ok() 不过 */ - RAIL_SELFTEST_SR595 = 1u << 2, /* sr595_map_ok() 不过 */ + RAIL_SELFTEST_MUX = 1u << 2, /* mux 通道重复 / 越界 / 撞上 PD 那一路 */ RAIL_SELFTEST_PCA9555 = 1u << 3, /* pca9555_map_ok() / _addr_ok() 不过 */ - RAIL_SELFTEST_TABLE = 1u << 4, /* 本表和四个驱动的表分家了 */ - RAIL_SELFTEST_PINS = 1u << 5, /* EN 引脚重复 / 越界 / 撞上别的信号 */ + RAIL_SELFTEST_TABLE = 1u << 4, /* 本表和驱动的表分家了 */ + RAIL_SELFTEST_PINS = 1u << 5, /* GPIO 重复 / 越界 / 撞上别的信号 */ } rail_selftest_bit_t; uint32_t rail_selftest(void); -/* 只查**一张给定的**五路表(`RAIL_SELFTEST_TABLE` / `RAIL_SELFTEST_PINS` 两位; - 另外四位是四个驱动自己的表,和 t 无关)。`t` 要指向 PHONERAIL_RAIL_COUNT 行。 +/* 只查**一张给定的**五路表(`TABLE` / `PINS` / `MUX` 三位;另外三位是驱动 + 自己的表,和 t 无关)。`t` 要指向 PHONERAIL_RAIL_COUNT 行。 存在的理由:**自检逻辑本身也得被测。** 拿真表跑一遍只能证明「现在没事」, - 证明不了「真有事的时候查得出来」—— 而真表是 const 的,测试没法把它改坏。 - 所以判据单拎出来,测试喂几张故意写坏的表进去看它认不认。 */ + 证明不了「真有事的时候查得出来」—— 而真表是 const 的,测试没法把它改坏。 */ uint32_t rail_selftest_table(const rail_cfg_t *t); /* 单个失败位的人话名字,用来打日志。传 0 或者多于一位返回 "?"。 */ const char *rail_selftest_str(uint32_t bit); -/* ---- 两根 EN 线(表驱动遍历) -------------------------------------------- - `EFUSE_EN` / `VBUS_EN` 都是**高有效**,而且都在板上带 100 kΩ 下拉 - (R114 / R701~705)—— ESP32 复位期间 GPIO 高阻,五路输出全关、VBUS 全断。 - 极性只在 rail_cfg.c 的 `en_write()` 里出现这一次,上层一律传 bool。 */ +/* ---- `EFUSE_EN`(表驱动遍历) -------------------------------------------- + **高有效**,板上带 100 kΩ 下拉 ⇒ ESP32 复位期间 GPIO 高阻、五路输出全关。 + 极性只在 rail_cfg.c 的 `en_write()` 里出现这一次,上层一律传 bool。 -/* 十个脚配成输出并**全部拉低**(= 五路全关、VBUS 全断),然后才谈别的。 - ⚠️ 必须在 `rail_volt_init()` 之前调:DAC 上电那一刻输出就有电压了, - eFuse 的 EN 这时得已经确定是低的。任何一个脚配失败就返回 HAL_ERR_ARG。 */ + ⚠️ `VBUS_EN` **不在这里** —— 它在刀片的 PCA9555 上,走 `slot_vbus_enable()`。 + 这不只是换了个接口:I²C 失联时 VBUS **关不掉**,而 `EFUSE_EN` 永远关得掉 + (计划 §5.4)。两者的可靠性等级从此不一样,别再把它们当成一对。 */ + +/* 五个脚配成输出并**全部拉低**(= 五路输出全关),然后才谈别的。 + ⚠️ 必须在 `rail_volt_init()` 与 `slot_init()` 之前调:DAC 上电那一刻输出就 + 有电压了,eFuse 的 EN 这时得已经确定是低的。任何一个脚配失败就返回错误码。 */ hal_err_t rail_en_init(void); /* rail 越界 → HAL_ERR_ARG;没 `rail_en_init()` 过 → HAL_ERR_ARG(HAL 会拦)。 */ hal_err_t rail_efuse_enable(int rail, bool on); -hal_err_t rail_vbus_enable(int rail, bool on); -/* 这两根线**最后一次成功写进去**的值(上电与 `rail_en_init()` 之后都是 false)。 +/* 这根线**最后一次成功写进去**的值(上电与 `rail_en_init()` 之后都是 false)。 存在的理由是 A10 的判据要知道「这一路现在该不该有电」:关着的路电压本来就是 0, - 拿它去和设定值比,每一圈都会得出「偏低 3900 mV」,于是巡检会没完没了地 - 「再关一次已经关着的路」并刷事件。判据只在这两个影子为 true 时武装。 - - ⚠️ **只在写成功时更新**,而且方向是有讲究的:关的那一次写失败 ⇒ 影子留在 on - ⇒ 判据继续武装(还会再关一次);开的那一次写失败 ⇒ 影子留在 off - ⇒ 判据不武装(不会对着一路没开成的电报偏差)。两边都倒向安全的那一侧。 + 拿它去和设定值比,每一圈都会得出「偏低 3900 mV」。判据只在这个影子为 true 时武装。 —— 不去读回引脚,是因为 `hal_gpio_read()` 读输出脚在两个目标上的语义不一样, 而这里要的本来就是「固件的意图」,不是「线上的电平」。 */ bool rail_efuse_is_on(int rail); -bool rail_vbus_is_on(int rail); /* 五路全关:**先断 VBUS 再关 eFuse**,中间不留「只靠 USB 供着电」的窗口。 - 十个脚全部试一遍,不因为某一个失败就提前返回 —— 关电路径上「剩下九个也别关了」 - 是最坏的选择。返回第一个错误码(全成功就是 HAL_OK)。 + 十次操作全部试一遍,不因为某一次失败就提前返回。返回第一个错误码。 + + ⚠️ R2 里前半段(断 VBUS)要走 I²C,**可能失败**(mux / PCA9555 失联时必然 + 失败);后半段(关 eFuse)是 GPIO,永远成功。所以这个函数在最坏情况下的 + 语义是「五路直供确实断了,但 VBUS 可能还通着」—— 这是 R2 用一根跨板针换来的 + 代价(计划 §5.4),不是 bug。 ⚠️ 这是复位 / 收尾路径。A10 的故障快路径要直接调 `rail_efuse_enable(rail,false)`, 别绕这里:过压时该立刻断的是出事那一路,不是等着遍历五路。 */ hal_err_t rail_all_off(void); diff --git a/firmware/main/slot.c b/firmware/main/slot.c new file mode 100644 index 0000000..0701ac5 --- /dev/null +++ b/firmware/main/slot.c @@ -0,0 +1,143 @@ +/* 一片刀片的 I/O 门面。契约见 slot.h。 */ +#include "slot.h" + +#include +#include + +#include "hal_gpio.h" +#include "i2cbus.h" +#include "rail_cfg.h" + +/* `PRSNT#` 的缓存(见 slot.h:读失败保留上一次的值)。 */ +static bool s_present[PHONERAIL_RAIL_COUNT] = { true, true, true, true, true }; + +static bool rail_valid(int rail) { return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT; } + +int slot_mux_ch(int rail) +{ + assert(rail_valid(rail)); + return (int)rail_cfg(rail)->mux_ch; +} + +/* ---- PRSNT# -------------------------------------------------------------- */ + +hal_err_t slot_prsnt_init(void) +{ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + const hal_err_t err = + hal_gpio_config(rail_cfg(rail)->prsnt_n, HAL_GPIO_INPUT_PULLUP); + if (err != HAL_OK) { + return err; + } + } + slot_prsnt_poll(); + return HAL_OK; +} + +void slot_prsnt_poll(void) +{ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + int level = 0; + if (hal_gpio_read(rail_cfg(rail)->prsnt_n, &level) == HAL_OK) { + s_present[rail - 1] = (level == 0); /* 低 = 在位 */ + } + /* 读失败:保留上一次的值(slot.h 里的理由)。 */ + } +} + +bool slot_present(int rail) +{ + return rail_valid(rail) && s_present[rail - 1]; +} + +/* ---- 一次 xact 的正文们 -------------------------------------------------- + 每个都是「通道已选好、锁已持有」的状态下被调一次。 */ + +typedef struct { + int rail; + uint8_t mask; + uint8_t value; +} out0_arg_t; + +static hal_err_t do_out0(void *arg) +{ + const out0_arg_t *a = (const out0_arg_t *)arg; + return pca9555_write_out0(a->rail - 1, a->mask, a->value); +} + +static hal_err_t do_init(void *arg) +{ + const int rail = *(const int *)arg; + return pca9555_init(rail - 1); +} + +/* ---- 输出 ---------------------------------------------------------------- */ + +/* 已持锁版本:影子下标与通道由调用方保证一致 —— 调用方只可能是 + `t_safety`,而它是拿 `slot_mux_ch(rail)` 去开的 `xact`。 */ +static hal_err_t out0_locked(int rail, uint8_t mask, uint8_t value) +{ + if (!rail_valid(rail)) { + return HAL_ERR_ARG; + } + return pca9555_write_out0(rail - 1, mask, value); +} + +static hal_err_t out0_xact(int rail, uint8_t mask, uint8_t value) +{ + if (!rail_valid(rail)) { + return HAL_ERR_ARG; + } + out0_arg_t a = { rail, mask, value }; + return i2cbus_xact(slot_mux_ch(rail), SLOT_LOCK_TIMEOUT_MS, do_out0, &a); +} + +hal_err_t slot_init(int rail) +{ + if (!rail_valid(rail)) { + return HAL_ERR_ARG; + } + int r = rail; + return i2cbus_xact(slot_mux_ch(rail), SLOT_LOCK_TIMEOUT_MS, do_init, &r); +} + +hal_err_t slot_out0_write(int rail, uint8_t mask, uint8_t value) +{ + return out0_xact(rail, mask, value); +} + +uint8_t slot_shadow(int rail) +{ + assert(rail_valid(rail)); + return pca9555_shadow(rail - 1); +} + +/* VBUS 的极性:**高 = 通**(TPS22918 的 ON 脚,板上 R701 100 kΩ 下拉 ⇒ + PCA9555 复位为高阻时 VBUS 必断)。极性只在这两个函数里各出现一次。 */ +#define SLOT_VBUS_BIT ((uint8_t)(1u << PCA9555_BIT_VBUS_EN)) + +hal_err_t slot_vbus_enable(int rail, bool on) +{ + return out0_xact(rail, SLOT_VBUS_BIT, on ? SLOT_VBUS_BIT : 0u); +} + +hal_err_t slot_vbus_enable_locked(int rail, bool on) +{ + return out0_locked(rail, SLOT_VBUS_BIT, on ? SLOT_VBUS_BIT : 0u); +} + +bool slot_vbus_is_on(int rail) +{ + /* 影子就在驱动里(写成功才更新),这里不另存一份 —— 两处影子迟早会分家。 */ + return rail_valid(rail) && (pca9555_shadow(rail - 1) & SLOT_VBUS_BIT) != 0; +} + +/* ---- 输入 ---------------------------------------------------------------- */ + +hal_err_t slot_read_inputs_locked(int rail, pca9555_in_t *out) +{ + if (!rail_valid(rail) || out == NULL) { + return HAL_ERR_ARG; + } + return pca9555_read_inputs(out); +} diff --git a/firmware/main/slot.h b/firmware/main/slot.h new file mode 100644 index 0000000..f58acd6 --- /dev/null +++ b/firmware/main/slot.h @@ -0,0 +1,100 @@ +/* `slot` —— 一片刀片的 I/O 门面(R2 新增)。 + + R2 之后,「按一下 CH3 的电源键」在总线上是: + **选 mux 通道 2 → 改 0x20 那片 PCA9555 的 Port0 输出锁存器**。 + 路号 → 通道 → 影子下标这条链只在本文件里出现一次,别处一律调 `slot_*`。 + + ---- 为什么要有这一层(而不是让 t_actuator 直接调驱动)------------------ + + `pca9555_write_out0(slot, ...)` 的 `slot` 参数**只用来记影子**,驱动没法验证 + 它与当前 mux 通道是不是同一路。传错的后果是影子记在了另一片上, + 下一次写会把那一片的键"顺手"改掉 —— 而五路的电压电流全部正常。 + 本文件把「选通道」和「记影子」绑在同一个函数里,让这件事**不可能**传错。 + + ---- 两个版本的接口 ------------------------------------------------------ + + 带 `_locked` 后缀的**必须**在对应那一路的 `i2cbus_xact()` 回调里调 + (通道已选好、锁已持有);不带后缀的自己开一次 `xact`。 + `t_safety` 用前者(SW-8 要在同一圈、同一把锁里断 VBUS,计划 §5.4), + `t_actuator` 与命令处理器用后者。 + ⚠️ 在回调里调不带后缀的版本 = 嵌套 = `HAL_ERR_STATE`(`i2cbus.h` 的警告)。 */ +#ifndef SLOT_H +#define SLOT_H + +#include +#include + +#include "hal_err.h" +#include "pca9555.h" +#include "phonerail.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* 不带 `_locked` 的那些函数等 I²C 锁最多等这么久。 + 比 `t_safety` 的 5 ms 长:巡检等不到就跳过一圈(20 ms 后重来), + 而执行器等不到就得重试一整个状态(`ACT_ST_CLEANUP` 那条「不放弃」)。 */ +#define SLOT_LOCK_TIMEOUT_MS 20 + +/* ---- `PRSNT#`:纯 GPIO,不经总线(计划 §5.8) ---------------------------- */ + +/* 五根 `PRSNT#` 配成**上拉输入**。板上另有 R606~R610 上拉,这里再配一次内部上拉 + 是为了空槽时电平确定 —— 悬空的输入脚读回来是随机的,而「随机地认为刀片在位」 + 正是这条判据要防的事。 */ +hal_err_t slot_prsnt_init(void); + +/* 刀片在不在位。**低 = 在位**(刀片把金手指 B31 短到 A1 的 GND)。 + + ⚠️ 读失败时返回**上一次成功读到的值**(初值 true = 在位)。 + 理由:GPIO 读在两个目标上都只可能因为「没 config」失败,那时系统还没起来; + 而让一次读失败把五台正在跑的手机判成"拔掉了",代价比漏判大得多。 */ +bool slot_present(int rail); + +/* 把 `PRSNT#` 读一遍并更新缓存。`t_safety` 每圈调一次(不碰总线,很便宜)。 */ +void slot_prsnt_poll(void); + +/* ---- 初始化 -------------------------------------------------------------- */ + +/* 该路 PCA9555 的初始化(自带 `xact`)。顺序是驱动里那个框保证的: + **输出锁存器先清零,方向最后改**。 + ⚠️ 必须在 `rail_en_init()` 之后:这一步之后 VBUS 才真的受控。 */ +hal_err_t slot_init(int rail); + +/* ---- VBUS(PCA9555 P0.4 → TPS22918) ------------------------------------- */ + +hal_err_t slot_vbus_enable(int rail, bool on); +hal_err_t slot_vbus_enable_locked(int rail, bool on); + +/* 最后一次**成功**写进去的值(上电与 `slot_init()` 之后都是 false)。 + 不去读回器件,因为这里要的本来就是「固件的意图」(`rail_cfg.h` 里同一条理由)。 + ⚠️ 写失败时不更新:关的那次失败 ⇒ 影子留在 on ⇒ 判据继续武装(还会再关一次); + 开的那次失败 ⇒ 影子留在 off ⇒ 判据不武装。两边都倒向安全的那一侧。 */ +bool slot_vbus_is_on(int rail); + +/* ---- 三个侧键 + EDL(PCA9555 P0.0~P0.3) --------------------------------- */ + +/* 改 `mask` 指定的那几位。组合键的两位在**同一次写**里(§10.6)。 + `mask` 只许出现 `PCA9555_P0_OUT_MASK` 里的位。 */ +hal_err_t slot_out0_write(int rail, uint8_t mask, uint8_t value); + +/* 该路 Port0 输出锁存器的影子。 */ +uint8_t slot_shadow(int rail); + +/* ---- 输入(PCA9555 Port1) ----------------------------------------------- */ + +/* 一次事务读回两个输入端口。**只有 `_locked` 版本** —— 读输入的唯一调用者是 + `t_safety`,它本来就在 `xact` 里;别处要看状态请读巡检的快照 + (`t_actuator.h` 里「PG 为什么是别人喂进来的」那一段)。 */ +hal_err_t slot_read_inputs_locked(int rail, pca9555_in_t *out); + +/* ---- 路号 → mux 通道(唯一换算点) --------------------------------------- */ + +/* 这一路在 TCA9548A 上的通道号。查 `rail_cfg()`,**不算**。 */ +int slot_mux_ch(int rail); + +#ifdef __cplusplus +} +#endif + +#endif /* SLOT_H */ diff --git a/firmware/main/t_actuator.c b/firmware/main/t_actuator.c index 76fbb60..f6fd67b 100644 --- a/firmware/main/t_actuator.c +++ b/firmware/main/t_actuator.c @@ -6,10 +6,9 @@ #include #include -#include "hal_lock.h" #include "hal_time.h" #include "rail_cfg.h" -#include "sr595.h" +#include "slot.h" typedef struct { act_state_t state; @@ -17,7 +16,7 @@ typedef struct { uint32_t t0_ms; /* 进入当前状态的时刻(绝对,回绕安全) */ uint32_t keys; /* KEY 序列:逻辑键位(ACT_KEYS_*) */ uint32_t hold_ms; - uint32_t release_mask; /* 收尾时要在链上清掉的位。0 = 这个序列没按下任何东西 */ + uint8_t release_mask; /* 收尾时要在 Port0 上清掉的位。0 = 这个序列没按下任何东西 */ act_result_t result; uint32_t done; uint32_t cleanup_retries; @@ -38,30 +37,26 @@ static act_rail_t *at(int rail) return &s_rail[rail - 1]; } -/* 逻辑键位 → 链上位号。查 `sr595_key_bit()`,**不算**(sr595.h 的禁令)。 */ -static uint32_t key_chain_mask(int rail, uint32_t keys) +/* 逻辑键位 → PCA9555 Port0 位号。查 `pca9555_key_bit()`,**不算**。 + ⚠️ R2 里位号与路号无关(五片刀片同构),所以这个函数不收 rail。 */ +static uint8_t key_out_mask(uint32_t keys) { - uint32_t m = 0; - for (int k = 0; k < SR595_KEY_COUNT; k++) { + uint8_t m = 0; + for (int k = 0; k < PCA9555_KEY_COUNT; k++) { if ((keys & ((uint32_t)1u << k)) != 0) { - m |= (uint32_t)1u << sr595_key_bit(rail, (sr595_key_t)k); + m |= (uint8_t)(1u << pca9555_key_bit((pca9555_key_t)k)); } } return m; } -/* 动 24 位链的**唯一**入口:拿 595 那把锁 → 改影子 + 整帧发下去 → 放锁。 - 一帧原子(RCLK 之前输出不变)⇒ 组合键的两位天然落在同一帧里。 - ⚠️ 这里绝不会去拿 I²C 那把锁,两把锁因此永远不会嵌套(hal_lock.h)。 */ -static hal_err_t chain_write(uint32_t mask, uint32_t value) +/* 动这一路 Port0 的**唯一**入口:`slot_out0_write()` 自己开一次 `i2cbus_xact` + (拿锁 → 选通道 → 读改写一个字节 → 放锁)。 + 一次写就是一个字节 ⇒ 组合键的两位天然落在同一次写里(§10.6)。 + ⚠️ 这一步会拿 I²C 锁,所以**不许**在持锁的上下文里调它(R2 的新前提,见头文件)。 */ +static hal_err_t out_write(int rail, uint8_t mask, uint8_t value) { - hal_err_t err = hal_lock_take(HAL_LOCK_SR595, ACT_LOCK_TIMEOUT_MS); - if (err != HAL_OK) { - return err; /* 影子没动过,下一拍重来 */ - } - err = sr595_write(mask, value); - hal_lock_give(HAL_LOCK_SR595); - return err; + return slot_out0_write(rail, mask, value); } static bool pg_now(int rail, bool *pg) @@ -152,7 +147,7 @@ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now) if (el < ACT_EDL_SHORT_MS) { return ACT_EDL_SHORT_MS - el; } - if (rail_vbus_enable(rail, true) != HAL_OK) { + if (slot_vbus_enable(rail, true) != HAL_OK) { finish(r, ACT_RES_IO, now); /* CLEANUP 会把 EDL_EN 清掉 */ return ACT_CLEANUP_RETRY_MS; } @@ -168,7 +163,7 @@ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now) return ACT_CLEANUP_RETRY_MS; case ACT_ST_CLEANUP: - if (chain_write(r->release_mask, 0) == HAL_OK) { + if (out_write(rail, r->release_mask, 0) == HAL_OK) { r->release_mask = 0; r->seq = ACT_SEQ_NONE; enter(r, ACT_ST_IDLE, now); @@ -227,9 +222,9 @@ hal_err_t t_actuator_hard_reset(int rail) Fastboot 而不是正常起来。正常情况下这里一定已经是 0(有序列在跑的话 上面那一句就把请求挡了),所以影子不变 ⇒ 不脏 ⇒ 一个时钟都不会发。 只有上一次收尾没落地时才真的发一帧。 */ - const uint32_t km = sr595_key_mask(rail); - if ((sr595_shadow() & km) != 0) { - (void)chain_write(km, 0); /* 失败也照断电:断电永远比不断电安全 */ + const uint8_t km = pca9555_key_mask(); + if ((slot_shadow(rail) & km) != 0) { + (void)out_write(rail, km, 0); /* 失败也照断电:断电永远比不断电安全 */ } const hal_err_t err = rail_efuse_enable(rail, false); @@ -259,11 +254,11 @@ hal_err_t t_actuator_key(int rail, uint32_t keys, uint32_t hold_ms) return HAL_ERR_STATE; } - const uint32_t mask = key_chain_mask(rail, keys); + const uint8_t mask = key_out_mask(keys); - /* 组合键的两位在**同一次 `chain_write()`** 里 ⇒ 同一帧 ⇒ 同一个 RCLK 沿。 + /* 组合键的两位在**同一次写**里 ⇒ 输出锁存器一次更新。 分两次写会先单按一个键(§10.6)。 */ - const hal_err_t err = chain_write(mask, mask); + const hal_err_t err = out_write(rail, mask, mask); r->seq = ACT_SEQ_KEY; r->keys = keys; @@ -272,9 +267,9 @@ hal_err_t t_actuator_key(int rail, uint32_t keys, uint32_t hold_ms) r->release_mask = mask; if (err != HAL_OK) { - /* 影子可能已经改成「按下」了(拿到锁但整帧没发出去)。直接返回错误 - 就等于把一个「按下」的脏影子留在那儿,谁下一次 flush 谁就替我们把键 - 按下去 —— 而没有人会来松开它。所以照样进 CLEANUP 把它撤回来。 */ + /* 写失败时驱动**不更新影子**,但器件里那个字节的状态是未知的 + (事务可能发出去一半)。所以照样进 CLEANUP 把这几位显式清一遍 —— + 「按下去的东西一定要松开」这条规矩不看我们以为发生了什么。 */ finish(r, ACT_RES_IO, hal_time_now_ms()); return err; } @@ -296,8 +291,8 @@ hal_err_t t_actuator_edl(int rail) return HAL_ERR_STATE; } - const uint32_t mask = (uint32_t)1u << sr595_edl_bit(rail); /* 查表,不算 */ - const hal_err_t err = chain_write(mask, mask); + const uint8_t mask = (uint8_t)(1u << PCA9555_BIT_EDL_EN); + const hal_err_t err = out_write(rail, mask, mask); r->seq = ACT_SEQ_EDL; r->result = ACT_RES_NONE; diff --git a/firmware/main/t_actuator.h b/firmware/main/t_actuator.h index 660ddc2..4e402fb 100644 --- a/firmware/main/t_actuator.h +++ b/firmware/main/t_actuator.h @@ -1,203 +1,214 @@ -/* `t_actuator` —— 每路三个序列 + 互斥(需求 §10.6,§10.3 的第三行任务)。 - - 三个序列,**同一路上同时只能跑一个**(§10.6 原话「彼此互斥、不可重入」): - - | 序列 | 时序 | 出处 | - |---|---|---| - | 硬重启 | `EFUSE_EN↓` → 等 3~5 s → `EFUSE_EN↑` → 等 **17 ms** → 等 PG 置位 | 设计文档 §7.4 | - | 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | SW-5 / §13.3 | - | EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | SW-6 / §22.4 | - - ---- 三条不能改的规矩 ---------------------------------------------------- - - 1. **一个函数一步,不许写 `while(1)`。** 死循环在宿主机上一行也跑不到, - 而这一包的全部价值就是那几个时间常量与它们的先后 —— 那正是要被测的东西。 - `t_actuator_step()` 只看时钟、推状态、返回「下次最晚多久再来」, - 任务体在 app_main.c 里,还是三行。 - - 2. **放电由硬件联锁自动完成(SR-7),固件一个字都不要写。** 放电 MOS 的栅极 - 经一级反相器接在 `EFUSE_EN` 上(设计文档 §7.3),物理上不可能与 eFuse 同时导通。 - 固件里再补一根「放电控制」= 10 Ω 上持续 1.76 W,电阻烧掉。 - 所以本文件里**没有任何放电接口**,硬重启只有一根 `EFUSE_EN`。 - - 3. **按下去的东西一定要松开。** 这一层最坏的失败不是「序列没走完」, - 而是**键留在按下状态** —— 电源键被一直按着,手机再也起不来,而且遥测上 - 五路电压电流全都正常。所以每个序列都以 `ACT_ST_CLEANUP` 收尾(超时、 - 出错、正常结束走的是同一条路),而且 **CLEANUP 不放弃**:写不进去就一直重试, - 这一路一直算 busy。放弃比重试危险得多。 - - ---- PG 为什么是别人喂进来的 ---------------------------------------------- - `PGOOD` 挂在 PCA9555 的 Port1 上,而 PCA9555 在 I²C 上,`t_safety` 已经每 20 ms - 无条件读一次(§10.3)。执行器再去读一遍等于凭空多一份总线占用和一份两边不一致的 - 可能,所以 PG 从外面**喂**进来(`act_pg_fn`)。这条回调也是「不许在这里拿 I²C 锁」 - 的物理保证:本文件只碰 GPIO 与 595,与 I²C 无交集 ⇒ 两把锁永远不会嵌套。 */ -#ifndef T_ACTUATOR_H -#define T_ACTUATOR_H - -#include -#include -#include - -#include "hal_err.h" -#include "phonerail.h" -#include "sr595.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* ---- 时间常量:每一个都有出处,不许拍脑袋改 ----------------------------- */ - -/* 硬重启断电时长。§7.4:放到 <0.5 V 只要 ~110 ms(τ = 2200 µF × 10 Ω = 22 ms), - 其余是等手机彻底断电,窗口 3~5 s,取中值。 - ⚠️ 缩到 1 s 的现象**不是**「重启快了」,而是「有的机型压根没重启」 - (某些机型要 VBAT 掉到 2.5 V 以下才算真断电,见 §7.1)。 */ -#define ACT_HR_OFF_MS 4000 -#define ACT_HR_OFF_MIN_MS 3000 /* §7.4 的窗口,自检与测试拿它对账 */ -#define ACT_HR_OFF_MAX_MS 5000 - -/* `EFUSE_EN↑` 之后到输出稳定:tON 延迟 5.1 ms(C = 33 nF)+ 缓启动斜坡 11.5 ms - (0.36 V/ms)≈ **17 ms**(设计文档 §5「缓启动实际值」与 §7.4)。 - 这段时间之内 PG 本来就不该置位,去看它只会看到「还没好」。 */ -#define ACT_HR_RAMP_MS 17 - -/* 等 PG 的上限。TPS25940A 的 PGOOD 在输出过 2.97 V 时释放,也就是斜坡中段就该来了; - 500 ms 是 ~30 倍余量。等不到**不代表要再断一次电**,见 `ACT_RES_NO_PG`。 */ -#define ACT_HR_PG_TIMEOUT_MS 500 - -/* 等 PG 期间多久回来看一眼。PG 是 `t_safety` 每 20 ms 刷新的快照, - 比这个再密也没有新值。⚠️ 不能直接睡满 500 ms —— 那样「等到 PG 就结束」 - 会退化成「一律等满 500 ms」,而测试与遥测都看不出区别。 */ -#define ACT_HR_PG_POLL_MS 5 - -/* SW-5 的三档按键时长。 */ -#define ACT_KEY_MS_TAP 100 /* 短按:唤醒 / 息屏 */ -#define ACT_KEY_MS_ON 2000 /* 长按:开机 */ -#define ACT_KEY_MS_FORCE 10000 /* 长按:强制重启(两级重启的「一级(软)」) */ - -/* 按键时长上限。上层传进来一个 10 分钟,等于把电源键按死 —— - 这不是保护,是**拦调用方的手误**(SR-13 拦 4.2 V 是同一个道理)。 */ -#define ACT_KEY_MAX_MS 15000 - -/* EDL:先短接、再插 USB(SW-6 的顺序是硬要求)。 - 模拟开关 TS5A3157 的 Ron ~1 Ω、导通时间 µs 级,10 ms 是给它和 1.8 kΩ 上的建立留的。 */ -#define ACT_EDL_SHORT_MS 10 - -/* 「等枚举」。⚠️ **固件看不见枚举** —— 手机挂在板载 Hub 的下行口上,ESP32 这一侧 - 没有任何信号能告诉它 9008 有没有起来。所以这里只能定时等,3 s 是按 USB 枚举 - 典型 <1 s 给的余量。真要确认枚举成功,只能由上位机 `lsusb` 看 05c6:9008 - (那是 §10.8 上位机那一侧的事)。 */ -#define ACT_EDL_ENUM_MS 3000 - -/* 拿 595 那把锁最多等多久。这把锁的争用只可能来自 A15 的初始化与 A10 的收尾, - 都是短事务;等不到就下一拍再来(CLEANUP 会一直重试)。 */ -#define ACT_LOCK_TIMEOUT_MS 20 - -/* CLEANUP 重试间隔,以及空闲时建议任务睡多久(A12 会换成「带超时地等队列」)。 */ -#define ACT_CLEANUP_RETRY_MS 5 -#define ACT_IDLE_WAIT_MS 100 - -/* ---- 按键:逻辑键位,不是链上位号 --------------------------------------- - 上层一律用这三个位(和它们的组合)表达「按哪几个键」,链上的 24 位位号 - 由本文件经 `sr595_key_bit()` 查表换算。**任何地方都不许出现 `1 << (rail-1)` - 这类算出来的位号**(sr595.h 的那条禁令)。 */ -typedef enum { - ACT_KEYS_PWR = 1u << SR595_KEY_PWR, - ACT_KEYS_VUP = 1u << SR595_KEY_VUP, - ACT_KEYS_VDN = 1u << SR595_KEY_VDN, - - /* SW-5 的两个组合键。**必须同一帧写进去**(§10.6)—— - 分两次写会先单按一个键,而「先单按 VOL−」在有的机型上就是音量调节。 */ - ACT_KEYS_FASTBOOT = ACT_KEYS_VDN | ACT_KEYS_PWR, - ACT_KEYS_RECOVERY = ACT_KEYS_VUP | ACT_KEYS_PWR, - - ACT_KEYS_ALL = ACT_KEYS_PWR | ACT_KEYS_VUP | ACT_KEYS_VDN, -} act_keys_t; - -/* ---- 状态与结果 ---------------------------------------------------------- */ - -typedef enum { - ACT_SEQ_NONE = 0, - ACT_SEQ_HARD_RESET, - ACT_SEQ_KEY, - ACT_SEQ_EDL, -} act_seq_t; - -typedef enum { - ACT_ST_IDLE = 0, - ACT_ST_HR_OFF, /* `EFUSE_EN` 已拉低,等 3~5 s(放电由硬件自己做) */ - ACT_ST_HR_RAMP, /* `EFUSE_EN` 已拉高,等 17 ms 走完 tON + 斜坡 */ - ACT_ST_HR_PG, /* 等 PG 置位 */ - ACT_ST_KEY_HOLD, /* 键已按下,保持 T */ - ACT_ST_EDL_SHORT, /* `EDL_EN=1`,等短接建立 */ - ACT_ST_EDL_ENUM, /* `VBUS_EN=1`,等枚举 */ - ACT_ST_CLEANUP, /* 把本序列按下去的东西松开,落不下去就重试(不放弃) */ -} act_state_t; - -typedef enum { - ACT_RES_NONE = 0, - ACT_RES_OK, - /* 硬重启专用:电**已经开着**,只是 500 ms 内没等到 PG(也可能是 PCA9555 - 读不到)。⚠️ 这时**不许**再把这一路关掉:§10.10 里「PCA9555 不应答 = - 失去 FAULT/PG 可见性,**不关输出**」。把「看不见」当成「坏了」去断电, - 等于一片 I/O 扩展器失联就让五台手机一起停机。 */ - ACT_RES_NO_PG, - /* 写 595 或写 EN 引脚失败。序列中止,但收尾照做(键一定松开)。 */ - ACT_RES_IO, - /* 被 `t_actuator_abort()` 打断。 */ - ACT_RES_ABORTED, -} act_result_t; - -/* PG 取值回调。返回 false = **这一刻读不到**(PCA9555 失联 / 还没读过一圈), - 与「读到了,是低」是两回事 —— 后者是「电源还没好」,前者是「不知道」。 - 传 NULL 给 `t_actuator_init()` 就是「永远读不到」,硬重启会走完并记 ACT_RES_NO_PG。 */ -typedef bool (*act_pg_fn)(void *ctx, int rail, bool *pg); - -/* ---- 接口 ---------------------------------------------------------------- */ - -/* 幂等,五路全部回到 IDLE。**不碰硬件**:SW-11 的「上电全关」是 - `rail_en_init()` + `sr595_init()` 的安全影子干的(A15),不是这里。 */ -void t_actuator_init(act_pg_fn pg, void *ctx); - -/* 三个请求。rail 是 **1~5**。 - HAL_ERR_ARG —— 路号 / 键位 / 时长越界; - HAL_ERR_STATE —— 没 init,或者**这一路正在跑另一个序列**(§10.6 不可重入)。 - 要抢占的话调用方得先 `t_actuator_abort()`,写成显式的。 - ⚠️ 返回非 HAL_OK 时序列**没有开始**,只有一个例外:按下 / 置位那一步真的 - 发到链上但失败了 —— 那时影子可能已经改了,所以那一路会进 CLEANUP 把它撤回来, - 函数仍然返回错误码,但 `t_actuator_busy()` 会短暂为 true。 */ -hal_err_t t_actuator_hard_reset(int rail); -hal_err_t t_actuator_key(int rail, uint32_t keys, uint32_t hold_ms); -hal_err_t t_actuator_edl(int rail); - -/* 打断这一路正在跑的序列:立刻收尾(键松开 / `EDL_EN` 清零),结果记 ACT_RES_ABORTED。 - **不动 `EFUSE_EN` / `VBUS_EN`** —— 硬重启走到一半被打断时,把电再关一次 - 或者再开一次都是自作主张;断电是 A10 的判据和上位机命令的事。 */ -void t_actuator_abort(int rail); - -/* 推一步。返回**下次最晚多久再来**(ms):五路里最近的那个截止时刻。 - 全空闲返回 ACT_IDLE_WAIT_MS。醒早了没关系 —— 每个状态都是拿绝对时刻算的, - 早来就再返回一个剩余时间。(ESP-IDF 默认 `CONFIG_FREERTOS_HZ=100`, - `vTaskDelay(pdMS_TO_TICKS(17))` 只有 1 个 tick,本来就会早醒。) */ -uint32_t t_actuator_step(void); - -/* ---- 观测 ---------------------------------------------------------------- */ - -bool t_actuator_busy(int rail); -act_state_t t_actuator_state(int rail); -act_seq_t t_actuator_seq(int rail); -act_result_t t_actuator_last_result(int rail); -uint32_t t_actuator_done(int rail); /* 走完几个序列(含失败的) */ -uint32_t t_actuator_cleanup_retries(int rail); /* 收尾重试次数,遥测里报出去 */ - -const char *t_actuator_state_str(act_state_t st); -const char *t_actuator_result_str(act_result_t r); - -/* 一行人话,给启动日志和遥测用。返回写进去的字符数(不含结尾 0)。 */ -int t_actuator_format(char *buf, size_t len); - -#ifdef __cplusplus -} -#endif - -#endif /* T_ACTUATOR_H */ +/* `t_actuator` —— 每路三个序列 + 互斥(需求 §10.6,§10.3 的第三行任务)。 + + 三个序列,**同一路上同时只能跑一个**(§10.6 原话「彼此互斥、不可重入」): + + | 序列 | 时序 | 出处 | + |---|---|---| + | 硬重启 | `EFUSE_EN↓` → 等 3~5 s → `EFUSE_EN↑` → 等 **17 ms** → 等 PG 置位 | 设计文档 §7.4 | + | 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | SW-5 / §13.3 | + | EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | SW-6 / §22.4 | + + ---- 三条不能改的规矩 ---------------------------------------------------- + + 1. **一个函数一步,不许写 `while(1)`。** 死循环在宿主机上一行也跑不到, + 而这一包的全部价值就是那几个时间常量与它们的先后 —— 那正是要被测的东西。 + `t_actuator_step()` 只看时钟、推状态、返回「下次最晚多久再来」, + 任务体在 app_main.c 里,还是三行。 + + 2. **放电由硬件联锁自动完成(SR-7),固件一个字都不要写。** 放电 MOS 的栅极 + 经一级反相器接在 `EFUSE_EN` 上(设计文档 §7.3),物理上不可能与 eFuse 同时导通。 + 固件里再补一根「放电控制」= 10 Ω 上持续 1.76 W,电阻烧掉。 + 所以本文件里**没有任何放电接口**,硬重启只有一根 `EFUSE_EN`。 + + 3. **按下去的东西一定要松开。** 这一层最坏的失败不是「序列没走完」, + 而是**键留在按下状态** —— 电源键被一直按着,手机再也起不来,而且遥测上 + 五路电压电流全都正常。所以每个序列都以 `ACT_ST_CLEANUP` 收尾(超时、 + 出错、正常结束走的是同一条路),而且 **CLEANUP 不放弃**:写不进去就一直重试, + 这一路一直算 busy。放弃比重试危险得多。 + + ---- PG 为什么是别人喂进来的 ---------------------------------------------- + `PGOOD` 挂在刀片 PCA9555 的 P1.3 上,而 `t_safety` 已经每 20 ms 无条件读一圈 + (§10.3)。执行器再去读一遍等于凭空多一份总线占用和一份两边不一致的可能, + 所以 PG 从外面**喂**进来(`act_pg_fn`)。 + + ---- ⚠️ R2 改了这一层的锁前提 --------------------------------------------- + R1 里执行器只碰 GPIO 与 74HC595,与 I²C 无交集,所以两把锁永远不会嵌套。 + **R2 里按键 / EDL / VBUS 全在刀片的 PCA9555 上 ⇒ 本文件必须拿 I²C 锁** + (经 `slot_*`,它们自带 `i2cbus_xact`)。 + + 好消息是 595 那把锁没了,全系统**只剩一把锁**,嵌套在物理上不可能。 + 代价是与巡检争用同一把锁,于是: + · 每一次动作都是一次短事务,**不许在持锁期间等时间** + (按键保持 100 ms 是状态机的事,不是持锁的事); + · `ACT_ST_CLEANUP` 的「不放弃」比 R1 更要紧:总线忙时写不进去必须一直重试。 */ +#ifndef T_ACTUATOR_H +#define T_ACTUATOR_H + +#include +#include +#include + +#include "hal_err.h" +#include "phonerail.h" +#include "slot.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- 时间常量:每一个都有出处,不许拍脑袋改 ----------------------------- */ + +/* 硬重启断电时长。§7.4:放到 <0.5 V 只要 ~110 ms(τ = 2200 µF × 10 Ω = 22 ms), + 其余是等手机彻底断电,窗口 3~5 s,取中值。 + ⚠️ 缩到 1 s 的现象**不是**「重启快了」,而是「有的机型压根没重启」 + (某些机型要 VBAT 掉到 2.5 V 以下才算真断电,见 §7.1)。 */ +#define ACT_HR_OFF_MS 4000 +#define ACT_HR_OFF_MIN_MS 3000 /* §7.4 的窗口,自检与测试拿它对账 */ +#define ACT_HR_OFF_MAX_MS 5000 + +/* `EFUSE_EN↑` 之后到输出稳定:tON 延迟 5.1 ms(C = 33 nF)+ 缓启动斜坡 11.5 ms + (0.36 V/ms)≈ **17 ms**(设计文档 §5「缓启动实际值」与 §7.4)。 + 这段时间之内 PG 本来就不该置位,去看它只会看到「还没好」。 */ +#define ACT_HR_RAMP_MS 17 + +/* 等 PG 的上限。TPS25940A 的 PGOOD 在输出过 2.97 V 时释放,也就是斜坡中段就该来了; + 500 ms 是 ~30 倍余量。等不到**不代表要再断一次电**,见 `ACT_RES_NO_PG`。 */ +#define ACT_HR_PG_TIMEOUT_MS 500 + +/* 等 PG 期间多久回来看一眼。PG 是 `t_safety` 每 20 ms 刷新的快照, + 比这个再密也没有新值。⚠️ 不能直接睡满 500 ms —— 那样「等到 PG 就结束」 + 会退化成「一律等满 500 ms」,而测试与遥测都看不出区别。 */ +#define ACT_HR_PG_POLL_MS 5 + +/* SW-5 的三档按键时长。 */ +#define ACT_KEY_MS_TAP 100 /* 短按:唤醒 / 息屏 */ +#define ACT_KEY_MS_ON 2000 /* 长按:开机 */ +#define ACT_KEY_MS_FORCE 10000 /* 长按:强制重启(两级重启的「一级(软)」) */ + +/* 按键时长上限。上层传进来一个 10 分钟,等于把电源键按死 —— + 这不是保护,是**拦调用方的手误**(SR-13 拦 4.2 V 是同一个道理)。 */ +#define ACT_KEY_MAX_MS 15000 + +/* EDL:先短接、再插 USB(SW-6 的顺序是硬要求)。 + 模拟开关 TS5A3157 的 Ron ~1 Ω、导通时间 µs 级,10 ms 是给它和 1.8 kΩ 上的建立留的。 */ +#define ACT_EDL_SHORT_MS 10 + +/* 「等枚举」。⚠️ **固件看不见枚举** —— 手机挂在板载 Hub 的下行口上,ESP32 这一侧 + 没有任何信号能告诉它 9008 有没有起来。所以这里只能定时等,3 s 是按 USB 枚举 + 典型 <1 s 给的余量。真要确认枚举成功,只能由上位机 `lsusb` 看 05c6:9008 + (那是 §10.8 上位机那一侧的事)。 */ +#define ACT_EDL_ENUM_MS 3000 + +/* 每次动作等 I²C 锁最多等多久(`slot.h` 的 `SLOT_LOCK_TIMEOUT_MS` 就是它)。 + 争用来自每 20 ms 一圈的巡检;等不到就下一拍再来(CLEANUP 会一直重试)。 */ +#define ACT_LOCK_TIMEOUT_MS SLOT_LOCK_TIMEOUT_MS + +/* CLEANUP 重试间隔,以及空闲时建议任务睡多久(A12 会换成「带超时地等队列」)。 */ +#define ACT_CLEANUP_RETRY_MS 5 +#define ACT_IDLE_WAIT_MS 100 + +/* ---- 按键:逻辑键位,不是器件上的位号 ----------------------------------- + 上层一律用这三个位(和它们的组合)表达「按哪几个键」,PCA9555 Port0 上的 + 位号由本文件经 `pca9555_key_bit()` 查表换算。 + ⚠️ R2 里位号**不再随路号变**(五片刀片同构),但仍然查表不算。 */ +typedef enum { + ACT_KEYS_PWR = 1u << PCA9555_KEY_PWR, + ACT_KEYS_VUP = 1u << PCA9555_KEY_VUP, + ACT_KEYS_VDN = 1u << PCA9555_KEY_VDN, + + /* SW-5 的两个组合键。**必须同一帧写进去**(§10.6)—— + 分两次写会先单按一个键,而「先单按 VOL−」在有的机型上就是音量调节。 */ + ACT_KEYS_FASTBOOT = ACT_KEYS_VDN | ACT_KEYS_PWR, + ACT_KEYS_RECOVERY = ACT_KEYS_VUP | ACT_KEYS_PWR, + + ACT_KEYS_ALL = ACT_KEYS_PWR | ACT_KEYS_VUP | ACT_KEYS_VDN, +} act_keys_t; + +/* ---- 状态与结果 ---------------------------------------------------------- */ + +typedef enum { + ACT_SEQ_NONE = 0, + ACT_SEQ_HARD_RESET, + ACT_SEQ_KEY, + ACT_SEQ_EDL, +} act_seq_t; + +typedef enum { + ACT_ST_IDLE = 0, + ACT_ST_HR_OFF, /* `EFUSE_EN` 已拉低,等 3~5 s(放电由硬件自己做) */ + ACT_ST_HR_RAMP, /* `EFUSE_EN` 已拉高,等 17 ms 走完 tON + 斜坡 */ + ACT_ST_HR_PG, /* 等 PG 置位 */ + ACT_ST_KEY_HOLD, /* 键已按下,保持 T */ + ACT_ST_EDL_SHORT, /* `EDL_EN=1`,等短接建立 */ + ACT_ST_EDL_ENUM, /* `VBUS_EN=1`,等枚举 */ + ACT_ST_CLEANUP, /* 把本序列按下去的东西松开,落不下去就重试(不放弃) */ +} act_state_t; + +typedef enum { + ACT_RES_NONE = 0, + ACT_RES_OK, + /* 硬重启专用:电**已经开着**,只是 500 ms 内没等到 PG(也可能是 PCA9555 + 读不到)。⚠️ 这时**不许**再把这一路关掉:§10.10 里「PCA9555 不应答 = + 失去 FAULT/PG 可见性,**不关输出**」。把「看不见」当成「坏了」去断电, + 等于一片 I/O 扩展器失联就让五台手机一起停机。 */ + ACT_RES_NO_PG, + /* 写 PCA9555 或写 EN 引脚失败(含拿不到 I²C 锁)。序列中止,但收尾照做。 */ + ACT_RES_IO, + /* 被 `t_actuator_abort()` 打断。 */ + ACT_RES_ABORTED, +} act_result_t; + +/* PG 取值回调。返回 false = **这一刻读不到**(PCA9555 失联 / 还没读过一圈), + 与「读到了,是低」是两回事 —— 后者是「电源还没好」,前者是「不知道」。 + 传 NULL 给 `t_actuator_init()` 就是「永远读不到」,硬重启会走完并记 ACT_RES_NO_PG。 */ +typedef bool (*act_pg_fn)(void *ctx, int rail, bool *pg); + +/* ---- 接口 ---------------------------------------------------------------- */ + +/* 幂等,五路全部回到 IDLE。**不碰硬件**:SW-11 的「上电全关」是 + `rail_en_init()`(GPIO)+ `slot_init()`(PCA9555 的输出锁存器先清零) + 干的(A15),不是这里。 */ +void t_actuator_init(act_pg_fn pg, void *ctx); + +/* 三个请求。rail 是 **1~5**。 + HAL_ERR_ARG —— 路号 / 键位 / 时长越界; + HAL_ERR_STATE —— 没 init,或者**这一路正在跑另一个序列**(§10.6 不可重入)。 + 要抢占的话调用方得先 `t_actuator_abort()`,写成显式的。 + ⚠️ 返回非 HAL_OK 时序列**没有开始**,只有一个例外:按下 / 置位那一步真的 + 发到链上但失败了 —— 那时影子可能已经改了,所以那一路会进 CLEANUP 把它撤回来, + 函数仍然返回错误码,但 `t_actuator_busy()` 会短暂为 true。 */ +hal_err_t t_actuator_hard_reset(int rail); +hal_err_t t_actuator_key(int rail, uint32_t keys, uint32_t hold_ms); +hal_err_t t_actuator_edl(int rail); + +/* 打断这一路正在跑的序列:立刻收尾(键松开 / `EDL_EN` 清零),结果记 ACT_RES_ABORTED。 + **不动 `EFUSE_EN` / `VBUS_EN`** —— 硬重启走到一半被打断时,把电再关一次 + 或者再开一次都是自作主张;断电是 A10 的判据和上位机命令的事。 */ +void t_actuator_abort(int rail); + +/* 推一步。返回**下次最晚多久再来**(ms):五路里最近的那个截止时刻。 + 全空闲返回 ACT_IDLE_WAIT_MS。醒早了没关系 —— 每个状态都是拿绝对时刻算的, + 早来就再返回一个剩余时间。(ESP-IDF 默认 `CONFIG_FREERTOS_HZ=100`, + `vTaskDelay(pdMS_TO_TICKS(17))` 只有 1 个 tick,本来就会早醒。) */ +uint32_t t_actuator_step(void); + +/* ---- 观测 ---------------------------------------------------------------- */ + +bool t_actuator_busy(int rail); +act_state_t t_actuator_state(int rail); +act_seq_t t_actuator_seq(int rail); +act_result_t t_actuator_last_result(int rail); +uint32_t t_actuator_done(int rail); /* 走完几个序列(含失败的) */ +uint32_t t_actuator_cleanup_retries(int rail); /* 收尾重试次数,遥测里报出去 */ + +const char *t_actuator_state_str(act_state_t st); +const char *t_actuator_result_str(act_result_t r); + +/* 一行人话,给启动日志和遥测用。返回写进去的字符数(不含结尾 0)。 */ +int t_actuator_format(char *buf, size_t len); + +#ifdef __cplusplus +} +#endif + +#endif /* T_ACTUATOR_H */ diff --git a/firmware/main/t_safety.c b/firmware/main/t_safety.c index 4fc33ed..0231608 100644 --- a/firmware/main/t_safety.c +++ b/firmware/main/t_safety.c @@ -1,279 +1,423 @@ -#include "t_safety.h" - -#include -#include -#include - -#include "hal_i2c_acct.h" -#include "hal_lock.h" -#include "hal_time.h" -#include "rail.h" -#include "rail_cfg.h" -#include "t_actuator.h" -#include "tasks.h" - -/* ---- A10:两条判据 -------------------------------------------------------- - - §10.3 原话:「越界**直接**操作 `EFUSE_EN` / `VBUS_EN`(GPIO 直连,不经任何 - 队列或抽象层)」。所以下面这几个函数直接调 `rail_*_enable()`,不发消息给 - t_actuator —— 发消息就等于把「关电」排在了一个中优先级任务的队列后面。 - - 四条不能改的规矩: - - 1. **判据只在放锁之后跑。** 两根 EN 线是 GPIO,和 I²C 没有关系;拿着总线锁 - 去写引脚只会把别的任务多挡一会儿。而且 `t_actuator_abort()` 会去拿 595 - 那把锁,拿着 I²C 锁进去就是嵌套(`hal_lock_take()` 会直接判 HAL_ERR_STATE)。 - - 2. **先 `t_actuator_abort()`,再断电。** 反过来有个真实的洞:硬重启的 - `HR_OFF` 状态会在 4 s 后自己把 `EFUSE_EN` 抬回去,于是「关掉的路」几秒钟 - 之后又带电了。abort 之后那一路只会走收尾(只碰 595),再也够不到 EN 线。 - 代价是 abort 最坏会等 595 那把锁 20 ms(`ACT_LOCK_TIMEOUT_MS`),也就是 - 巡检可能丢一拍 —— 认了:SR-5 原文写着它「仅承担记录与告警,不承担实时保护」, - 真正的过压保护是 eFuse 里的硬件 OVP,不靠这条路径的延迟。 - - 3. **判据只在「这一路该有电」而且「这一圈的值是新鲜的」时武装。** - 关着的路电压本来就是 0,不设这一条的话每一圈都会得出「偏低 3900 mV」; - 而器件失联那一路的值是上一圈的,§10.10 明写着「保持供电,禁用该路的 - SR-5 / SW-8 判据并持续告警」。 - - 4. **一条判据也不许发 I²C 事务。** 判据要的三个量(设定值、两根 EN 的状态、 - 正在跑的序列)全部有影子可查。多读一次就把 §10.3 那 7.8 % 的预算顶穿, - `test_one_cycle_is_exactly_thirteen_transactions()` 会当场变红。 */ - -static safety_ev_fn s_sink; -static void *s_sink_ctx; - -void t_safety_set_event_sink(safety_ev_fn fn, void *ctx) -{ - s_sink = fn; - s_sink_ctx = ctx; -} - -const char *t_safety_ev_str(safety_ev_t ev) -{ - switch (ev) { - case SAFETY_EV_NONE: - return "无"; - case SAFETY_EV_OVER_VOLT: - return "过压关路(SR-5)"; - case SAFETY_EV_UNDER_VOLT: - return "欠压关路(SR-5)"; - case SAFETY_EV_REVERSE: - return "反灌断VBUS(SW-8)"; - case SAFETY_EV_PUSHED_UP: - return "被顶高断VBUS(SW-8)"; - default: - return "?"; - } -} - -/* 记一条。**永远在动作之后调** —— 记录慢一步没关系,断电慢一步不行。 */ -static void emit(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now) -{ - const ina226_vi_t *vi = &s->rail[rail - 1]; - - s->last.t_ms = now; - s->last.rail = (uint8_t)rail; - s->last.type = (uint8_t)ev; - s->last.bus_mv = vi->bus_mv; - s->last.current_ua = vi->current_ua; - s->events++; - - if (s_sink != NULL) { - s_sink(s_sink_ctx, &s->last); /* TODO(A11):这里接环形缓冲 */ - } -} - -/* SR-5:关这一路。**先断 VBUS 再关 eFuse** —— 只关 eFuse 的话,插着 USB 的那几路 - 会停在「手机只靠 VBUS 供着电」,那既不是开也不是关(`rail_all_off()` 里同一条 - 理由)。两个都要试,不因为第一个失败就不关第二个。 */ -static void trip_rail(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now) -{ - t_actuator_abort(rail); /* 规矩 2:必须在断电之前 */ - (void)rail_vbus_enable(rail, false); - (void)rail_efuse_enable(rail, false); - emit(s, rail, ev, now); -} - -/* SW-8:只断这一路的 VBUS,**不碰 eFuse** —— 反灌的能量是从 USB 那一侧来的, - 把直供也一起关掉等于「因为充电器有问题就把手机关机」,而 §10.2 的整节都在讲 - 「范围可控的时候只动出事的那一处」。 */ -static void cut_vbus(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now) -{ - /* 三个序列里只有 EDL 会去写 `VBUS_EN`(`EDL_EN=1 → VBUS_EN=1 → 等枚举`)。 - 它正跑着的话必须打断,否则下一拍它就把 VBUS 又抬回来了。 - 硬重启 / 按键不碰 VBUS,没有理由为一次反灌把它们打断。 */ - if (t_actuator_seq(rail) == ACT_SEQ_EDL) { - t_actuator_abort(rail); - } - (void)rail_vbus_enable(rail, false); - emit(s, rail, ev, now); -} - -static void check_rail(safety_snap_t *s, int rail, uint32_t now) -{ - safety_rail_t *st = &s->st[rail - 1]; - const bool en = rail_efuse_is_on(rail); - - /* `EFUSE_EN` 的上升沿:缓启动窗口从这一刻算起。 */ - if (en && !st->en_on) { - st->en_ms = now; - } - st->en_on = en; - - /* 规矩 3 的三道闸。任何一道不过就把圈数清零 —— 「关着的那几秒」 - 不能算进「持续 20 ms」里,不然一开机就跳闸。 - (缓启动窗口是真按毫秒算的:它对应的是 tON + 斜坡那段物理时间, - 不像「持续 20 ms」那样是在数样本。) */ - if (!en || !s->rail_ok[rail - 1] || (now - st->en_ms) < T_SAFETY_SETTLE_MS) { - st->dev_n = 0; - return; - } - - const int32_t mv = s->rail[rail - 1].bus_mv; - const int32_t ua = s->rail[rail - 1].current_ua; - const int32_t dev = mv - (int32_t)rail_setpoint_mv(rail); - - /* ---- SW-8(先跑):只在 VBUS 通着的时候武装 ------------------------- - 反灌的能量只可能从 VBUS 那一侧来,断开之后判据自然就解除武装了 —— - 这也是「关掉之后不会每 20 ms 再刷一条事件」的机制。 - 先跑它是因为过压这一种情况两条判据都成立:该先撤掉能量来源(VBUS), - 20 ms 后 SR-5 再决定要不要连这一路一起关掉。 */ - if (rail_vbus_is_on(rail)) { - if (ua < T_SAFETY_REV_UA) { - cut_vbus(s, rail, SAFETY_EV_REVERSE, now); - } else if (dev > T_SAFETY_DEV_MV) { - cut_vbus(s, rail, SAFETY_EV_PUSHED_UP, now); - } - } - - /* ---- SR-5:±100 mV 连着 T_SAFETY_DEV_HOLD_CYCLES 圈(= 持续 20 ms)---- */ - if (dev > T_SAFETY_DEV_MV || dev < -T_SAFETY_DEV_MV) { - if (st->dev_n < 0xFF) { - st->dev_n++; - } - if (st->dev_n >= T_SAFETY_DEV_HOLD_CYCLES) { - trip_rail(s, rail, dev > 0 ? SAFETY_EV_OVER_VOLT : SAFETY_EV_UNDER_VOLT, now); - /* ⚠️ 这里**故意不**把 `dev_n` 清零。关掉之后下一圈 `en` 就是 false, - 上面那道闸自己会清;而万一那次写引脚失败了(影子留在 on), - 下一圈 `dev_n` 已经够数,会**立刻再关一次** —— 清零的话得再等两圈。 - 关电路径上「重试快一点」永远是对的那一边。 */ - } - } else { - st->dev_n = 0; /* 「持续」的意思是连续,中间回到范围内就从头数 */ - } -} - -void t_safety_init(safety_snap_t *s) -{ - assert(s != NULL); - memset(s, 0, sizeof(*s)); - /* 游标对齐到「现在」,否则第一圈会把启动期间(器件初始化)的事务算进来。 */ - s->bus_cursor = hal_i2c_acct_bits(); -} - -hal_err_t t_safety_step(safety_snap_t *s) -{ - assert(s != NULL); - s->cycles++; - - hal_err_t err = hal_lock_take(HAL_LOCK_I2C, T_SAFETY_LOCK_TIMEOUT_MS); - if (err != HAL_OK) { - /* 规矩 1:不死等。这一圈作废,20 ms 后重来。 */ - s->skipped++; - s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor); - return err; - } - - hal_err_t first = HAL_OK; - - /* 五路 —— 地址查表,**不许算**(rail_cfg.h 那条禁令)。 */ - for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { - ina226_vi_t vi; - const hal_err_t e = ina226_read_vi(rail_cfg(rail)->ina_addr, &vi); - s->rail_ok[rail - 1] = (e == HAL_OK); - if (e == HAL_OK) { - s->rail[rail - 1] = vi; /* 规矩 2:失败就留着上一圈的值,别清零 */ - } else { - s->i2c_errors++; - if (first == HAL_OK) { - first = e; - } - } - } - - /* 第六片:输入侧。 */ - { - ina226_vi_t vi; - const hal_err_t e = ina226_read_vi(INA226_ADDR_INPUT, &vi); - s->input_ok = (e == HAL_OK); - if (e == HAL_OK) { - s->input = vi; - } else { - s->i2c_errors++; - if (first == HAL_OK) { - first = e; - } - } - } - - /* PCA9555 的两个输入端口一次事务读完(自增指针)。 */ - { - pca9555_in_t in; - const hal_err_t e = pca9555_read_inputs(&in); - s->io_ok = (e == HAL_OK); - if (e == HAL_OK) { - s->io = in; - } else { - s->i2c_errors++; - if (first == HAL_OK) { - first = e; - } - } - } - - hal_lock_give(HAL_LOCK_I2C); - - /* 判据:放锁之后跑,直接写 EFUSE_EN / VBUS_EN(上面那四条规矩)。 - `now` 只取一次 —— 五路要拿同一个时刻去算「持续了多久」。 */ - { - const uint32_t now = hal_time_now_ms(); - for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { - check_rail(s, rail, now); - } - } - - s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor); - return first; -} - -uint32_t t_safety_budget_us(void) -{ - /* 上面那一圈真正发出去的事务,逐条数: - 6 片 INA226 × 2 次寄存器读(母线电压 + 电流) - 1 次 PCA9555 读(一次事务读回两个输入端口) - 每次都是「写 1 字节寄存器号 + 读 2 字节」的形状。 */ - const uint32_t txns = INA226_COUNT * 2u + 1u; - return (uint32_t)hal_i2c_bits_to_us((uint64_t)txns * hal_i2c_txn_bits(1, 2)); -} - -uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms) -{ - if (period_ms == 0) { - return 0; - } - return (uint32_t)(((uint64_t)bus_us * 1000ull) / ((uint64_t)period_ms * 1000ull)); -} - -int t_safety_format(const safety_snap_t *s, char *buf, size_t len) -{ - assert(s != NULL && buf != NULL && len > 0); - const uint32_t period = task_desc(TASK_SAFETY)->period_ms; - const uint32_t pm = t_safety_load_permille(s->bus_us, period); - return snprintf(buf, len, - "t_safety: cycles=%u skipped=%u i2c_err=%u bus=%u us/%u ms load=%u.%u%% " - "events=%u", - (unsigned)s->cycles, (unsigned)s->skipped, (unsigned)s->i2c_errors, - (unsigned)s->bus_us, (unsigned)period, (unsigned)(pm / 10u), - (unsigned)(pm % 10u), (unsigned)s->events); -} +#include "t_safety.h" + +#include +#include +#include + +#include "hal_i2c_acct.h" +#include "hal_time.h" +#include "i2cbus.h" +#include "rail.h" +#include "rail_cfg.h" +#include "slot.h" +#include "t_actuator.h" +#include "tasks.h" + +/* 判据与四条规矩见 t_safety.h。这里只补一句 R2 最容易写错的: + **断 VBUS 在锁内(它是 I²C 上的 PCA9555),关 eFuse 在锁外(它是 GPIO)。** + 两者以前是一对 GPIO,现在不是了。 */ + +static safety_ev_fn s_sink; +static void *s_sink_ctx; + +void t_safety_set_event_sink(safety_ev_fn fn, void *ctx) +{ + s_sink = fn; + s_sink_ctx = ctx; +} + +const char *t_safety_ev_str(safety_ev_t ev) +{ + switch (ev) { + case SAFETY_EV_NONE: + return "无"; + case SAFETY_EV_OVER_VOLT: + return "过压关路(SR-5)"; + case SAFETY_EV_UNDER_VOLT: + return "欠压关路(SR-5)"; + case SAFETY_EV_REVERSE: + return "反灌断VBUS(SW-8)"; + case SAFETY_EV_PUSHED_UP: + return "被顶高断VBUS(SW-8)"; + case SAFETY_EV_MUX_LOST: + return "mux失联全断"; + case SAFETY_EV_SLOT_EMPTY: + return "刀片不在位"; + default: + return "?"; + } +} + +/* 记一条。**永远在动作之后调** —— 记录慢一步没关系,断电慢一步不行。 + `rail` 传 0 表示整板级事件(mux 失联),那时 V/I 记的是输入侧的。 */ +static void emit(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now) +{ + const ina226_vi_t *vi = (rail >= 1) ? &s->rail[rail - 1] : &s->input; + + s->last.t_ms = now; + s->last.rail = (uint8_t)rail; + s->last.type = (uint8_t)ev; + s->last.bus_mv = vi->bus_mv; + s->last.current_ua = vi->current_ua; + s->events++; + + if (s_sink != NULL) { + s_sink(s_sink_ctx, &s->last); + } +} + +/* ---- 锁内:一路的读 + 判据 ----------------------------------------------- */ + +typedef struct { + safety_snap_t *s; + int rail; + uint32_t now; /* 本圈统一的时刻:五路要拿同一个时刻去算「持续了多久」 */ +} cycle_arg_t; + +/* SW-8:只断这一路的 VBUS,**不碰 eFuse** —— 反灌的能量是从 USB 那一侧来的, + 把直供也一起关掉等于「因为充电器有问题就把手机关机」。 + ⚠️ 就在锁内、通道已选好的此刻断(规矩 3)。 */ +static void cut_vbus_locked(safety_snap_t *s, int rail, safety_ev_t ev) +{ + safety_rail_t *st = &s->st[rail - 1]; + (void)slot_vbus_enable_locked(rail, false); + /* 三个序列里只有 EDL 会去写 `VBUS_EN`,它正跑着的话必须打断,否则下一拍 + 它就把 VBUS 又抬回来了。abort 会走 I²C(收尾要写 Port0),所以**留到锁外**。 */ + if (t_actuator_seq(rail) == ACT_SEQ_EDL) { + st->pend_abort = true; + } + st->pend_ev = (uint8_t)ev; +} + +/* SR-5:关这一路。**先断 VBUS(这里、锁内)再关 eFuse(锁外)** —— + 只关 eFuse 的话,插着 USB 的那几路会停在「手机只靠 VBUS 供着电」, + 那既不是开也不是关。 */ +static void trip_rail_locked(safety_snap_t *s, int rail, safety_ev_t ev) +{ + safety_rail_t *st = &s->st[rail - 1]; + (void)slot_vbus_enable_locked(rail, false); + st->pend_abort = true; + st->pend_efuse = true; + st->pend_ev = (uint8_t)ev; +} + +static void judge_locked(safety_snap_t *s, int rail, uint32_t now) +{ + safety_rail_t *st = &s->st[rail - 1]; + const bool en = rail_efuse_is_on(rail); + + /* `EFUSE_EN` 的上升沿:缓启动窗口从这一刻算起。 */ + if (en && !st->en_on) { + st->en_ms = now; + } + st->en_on = en; + + /* 三道闸。任何一道不过就把圈数清零 —— 「关着的那几秒」不能算进 + 「持续 20 ms」里,不然一开机就跳闸。 */ + if (!en || !s->rail_ok[rail - 1] || (now - st->en_ms) < T_SAFETY_SETTLE_MS) { + st->dev_n = 0; + return; + } + + const int32_t mv = s->rail[rail - 1].bus_mv; + const int32_t ua = s->rail[rail - 1].current_ua; + const int32_t dev = mv - (int32_t)rail_setpoint_mv(rail); + + /* ---- SW-8(先跑):只在 VBUS 通着的时候武装 ------------------------- + 反灌的能量只可能从 VBUS 那一侧来,断开之后判据自然就解除武装了。 + 先跑它是因为过压那种情况两条判据都成立:该先撤掉能量来源(VBUS), + 20 ms 后 SR-5 再决定要不要连这一路一起关掉。 */ + if (slot_vbus_is_on(rail)) { + if (ua < T_SAFETY_REV_UA) { + cut_vbus_locked(s, rail, SAFETY_EV_REVERSE); + } else if (dev > T_SAFETY_DEV_MV) { + cut_vbus_locked(s, rail, SAFETY_EV_PUSHED_UP); + } + } + + /* ---- SR-5:±100 mV 连着 T_SAFETY_DEV_HOLD_CYCLES 圈(= 持续 20 ms)---- */ + if (dev > T_SAFETY_DEV_MV || dev < -T_SAFETY_DEV_MV) { + if (st->dev_n < 0xFF) { + st->dev_n++; + } + if (st->dev_n >= T_SAFETY_DEV_HOLD_CYCLES) { + trip_rail_locked(s, rail, dev > 0 ? SAFETY_EV_OVER_VOLT : SAFETY_EV_UNDER_VOLT); + /* ⚠️ 这里**故意不**把 `dev_n` 清零。关掉之后下一圈 `en` 就是 false, + 上面那道闸自己会清;而万一那次写引脚失败了(影子留在 on), + 下一圈 `dev_n` 已经够数,会**立刻再关一次**。 */ + } + } else { + st->dev_n = 0; /* 「持续」的意思是连续,中间回到范围内就从头数 */ + } +} + +/* 一路的锁内正文:读 V/I → 读两个输入端口 → 跑判据。 + ⚠️ 器件读失败**不提前返回**:PCA9555 读不到不代表 INA226 也读不到, + 而判据只要有新鲜的电压就能出(规矩 2)。 */ +static hal_err_t cycle_rail(void *arg) +{ + cycle_arg_t *a = (cycle_arg_t *)arg; + safety_snap_t *s = a->s; + const int rail = a->rail; + hal_err_t first = HAL_OK; + + ina226_vi_t vi; + hal_err_t e = ina226_read_vi(rail_cfg(rail)->ina_addr, &vi); + s->rail_ok[rail - 1] = (e == HAL_OK); + if (e == HAL_OK) { + s->rail[rail - 1] = vi; /* 失败就留着上一圈的值,别清零 */ + } else { + s->i2c_errors++; + first = e; + } + + pca9555_in_t in; + e = slot_read_inputs_locked(rail, &in); + s->io_ok[rail - 1] = (e == HAL_OK); + if (e == HAL_OK) { + s->io[rail - 1] = in; + } else { + s->i2c_errors++; + if (first == HAL_OK) { + first = e; + } + } + + /* 判据就在这里跑:断 VBUS 要在本路通道选好、锁还持着的此刻(规矩 3)。 */ + judge_locked(s, rail, a->now); + return first; +} + +static hal_err_t cycle_input(void *arg) +{ + safety_snap_t *s = (safety_snap_t *)arg; + ina226_vi_t vi; + const hal_err_t e = ina226_read_vi(INA226_ADDR_INPUT, &vi); + + s->input_ok = (e == HAL_OK); + if (e == HAL_OK) { + s->input = vi; + } else { + s->i2c_errors++; + } + return e; +} + +void t_safety_init(safety_snap_t *s) +{ + assert(s != NULL); + memset(s, 0, sizeof(*s)); + for (int i = 0; i < PHONERAIL_RAIL_COUNT; i++) { + s->present[i] = true; /* 与 slot.c 的初值一致:读到之前当它在位 */ + } + /* 游标对齐到「现在」,否则第一圈会把启动期间(器件初始化)的事务算进来。 */ + s->bus_cursor = hal_i2c_acct_bits(); + s->txn_cursor = hal_i2c_acct_txns(); +} + +/* ---- 锁外:把锁内攒下的动作做掉 ------------------------------------------ */ + +static void apply_pending(safety_snap_t *s, uint32_t now) +{ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + safety_rail_t *st = &s->st[rail - 1]; + if (st->pend_ev == (uint8_t)SAFETY_EV_NONE) { + continue; + } + + /* 顺序:先 abort 再关 eFuse。反过来有个真实的洞 —— 硬重启的 `HR_OFF` + 状态会在 4 s 后自己把 `EFUSE_EN` 抬回去,于是「关掉的路」几秒钟之后 + 又带电了。abort 之后那一路只会走收尾(只写 Port0),再也够不到 EN 线。 */ + if (st->pend_abort) { + t_actuator_abort(rail); + } + if (st->pend_efuse) { + (void)rail_efuse_enable(rail, false); + } + emit(s, rail, (safety_ev_t)st->pend_ev, now); + + st->pend_ev = (uint8_t)SAFETY_EV_NONE; + st->pend_efuse = false; + st->pend_abort = false; + } +} + +/* ⭐ R2:刀片不在位就不许带电(计划 §5.8)。空槽上电 = 金手指裸露带 12V。 + 只在「本来是开着的」时候动作,否则每圈都会对着五个空槽刷事件。 */ +static void check_present(safety_snap_t *s, uint32_t now) +{ + /* ⚠️ 先把五根脚重读一遍再判。`slot_present()` 读的是缓存(读失败时要能 + 保留上一次的值),不 poll 的话缓存停在 `slot_prsnt_init()` 那一刻, + 现象是**拔掉刀片永远判不出来**,而且一个错误码都不会有。 + 这一步是纯 GPIO,不占总线,所以每圈都做。 */ + slot_prsnt_poll(); + + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + s->present[rail - 1] = slot_present(rail); + if (s->present[rail - 1] || !rail_efuse_is_on(rail)) { + continue; + } + t_actuator_abort(rail); + (void)rail_efuse_enable(rail, false); + /* VBUS 也尽力断一次。⚠️ 刀片**真的**被拔了的话这一步必然 NAK + (PCA9555 跟着刀片一起走了),那没关系;它挡的是另一种情形: + `PRSNT#` 接触不良 / 金手指 B31 虚接 —— 刀片还在、PCA9555 还应答, + 这时只关 eFuse 会留下「直供断了但 USB 还灌着」的半开状态。 + 放在关 eFuse **之后**:断电优先,而这一步要走 I²C、可能等锁。 */ + (void)slot_vbus_enable(rail, false); + emit(s, rail, SAFETY_EV_SLOT_EMPTY, now); + } +} + +/* ⭐ R2:mux 是单点(计划 §5.8)。它一挂,五路的电压电流**全部看不见**, + SR-5 的整条监控链断了 —— 这时「继续供电但看不见」比「停机」危险。 + ⚠️ 先脉冲一次 `MUX_RST#` 再判:一次总线毛刺就停五台机器,代价太大。 */ +static void check_mux(safety_snap_t *s, uint32_t now) +{ + if (i2cbus_select_fail_streak() < T_SAFETY_MUX_FAIL_N) { + s->mux_step = 0; /* 选中过一次 ⇒ 这一轮结束(判据数的是「连续」,不是「累计」) */ + return; + } + if (s->mux_step == 0) { + /* 这一轮还没试过复位 —— 先脉冲一次 `MUX_RST#`,下一圈再看。 + ⚠️ 这一步走到哪了要记在 `mux_step` 里,**不能拿 `i2cbus_recoveries()` 和 + `s->mux_recovers` 比**:复位之后把后者同步成前者,两个数就永远相等, + 于是每一圈都走这条分支 —— `MUX_RST#` 每 20 ms 脉冲一次、五路永远断不掉。 */ + (void)i2cbus_recover(); + s->mux_recovers++; + s->mux_step = 1; + return; + } + if (s->mux_step >= 2) { + /* 这一轮已经全断过了。再断一次没有任何用处,而每 20 ms 刷一条 MUX_LOST + 会把事件日志那块 flash 写穿 —— 判据「一直在响」和判据「不灵」一样糟。 + 等某一次选通成功(streak 归零)之后才重新武装。 */ + return; + } + /* 复位过了还是连续失败:认定失联。 */ + s->mux_step = 2; + s->mux_trips++; + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + t_actuator_abort(rail); + } + (void)rail_all_off(); /* 前半段(断 VBUS)多半也会失败,那没关系 —— eFuse 是 GPIO */ + emit(s, 0, SAFETY_EV_MUX_LOST, now); +} + +/* 本圈还允许在等锁上花多久:单次上限与「整圈预算的余额」取小的那个。 + ⚠️ 这里量的是**墙钟**,所以事务本身花掉的时间也算在内 —— 那是对的: + 预算要挡住的是「这一圈把整个周期占满」,而不只是「等锁等太久」。 */ +static uint32_t lock_budget_left(uint32_t t0) +{ + const uint32_t spent = hal_time_now_ms() - t0; + if (spent >= T_SAFETY_LOCK_BUDGET_MS) { + return 0; + } + const uint32_t left = T_SAFETY_LOCK_BUDGET_MS - spent; + return left < T_SAFETY_LOCK_TIMEOUT_MS ? left : T_SAFETY_LOCK_TIMEOUT_MS; +} + +hal_err_t t_safety_step(safety_snap_t *s) +{ + assert(s != NULL); + s->cycles++; + + const uint32_t now = hal_time_now_ms(); + hal_err_t first = HAL_OK; + bool skipped = false; + + /* 本圈还能在等锁上花多久(`T_SAFETY_LOCK_BUDGET_MS` 那一段的理由)。 + 返回 0 表示预算用完了 —— 剩下的路这一圈不读了。 */ + const uint32_t t0 = now; + + /* 五路,一路一次 `xact`(通道查表,**不许算** —— rail_cfg.h 那条禁令)。 */ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + const uint32_t budget = lock_budget_left(t0); + if (budget == 0) { + skipped = true; /* 整圈预算用完:剩下的路本圈作废,下一拍重来 */ + break; + } + cycle_arg_t a = { s, rail, now }; + const hal_err_t e = i2cbus_xact(slot_mux_ch(rail), budget, cycle_rail, &a); + if (e == HAL_ERR_TIMEOUT) { + skipped = true; /* 规矩 1:不死等。这一路这一圈作废 */ + } + if (e != HAL_OK && first == HAL_OK) { + first = e; + } + } + + /* 第六片:输入侧,挂在主干上(不在任何通道后面)。 */ + { + const uint32_t budget = lock_budget_left(t0); + if (budget == 0) { + skipped = true; + } else { + const hal_err_t e = i2cbus_xact(I2CBUS_CH_TRUNK, budget, cycle_input, s); + if (e == HAL_ERR_TIMEOUT) { + skipped = true; + } + if (e != HAL_OK && first == HAL_OK) { + first = e; + } + } + } + + if (skipped) { + s->skipped++; + } + + /* 放锁之后:关 eFuse(GPIO)、打断序列、记事件,再跑两条整板级判据。 */ + apply_pending(s, now); + check_present(s, now); + check_mux(s, now); + + s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor); + { + const uint32_t t = hal_i2c_acct_txns(); + s->txns = t - s->txn_cursor; + s->txn_cursor = t; + } + return first; +} + +uint32_t t_safety_cycle_txns(void) +{ + /* 每路:选通道(1) + INA226 读 V(1) + 读 I(1) + PCA9555 读两个输入端口(1) = 4 + 主干:选通道(1) + INA226 读 V(1) + 读 I(1) = 3 */ + return (uint32_t)PHONERAIL_RAIL_COUNT * 4u + 3u; +} + +uint32_t t_safety_budget_us(void) +{ + /* 逐条数,按事务形状分两类: + mux 选通 —— 纯写 1 字节(**没有寄存器指针**,见 tca9548a.h) + 寄存器读 —— 写 1 字节寄存器号 + 读 2 字节 */ + const uint32_t selects = (uint32_t)PHONERAIL_RAIL_COUNT + 1u; /* 五路 + 回主干 */ + const uint32_t reads = t_safety_cycle_txns() - selects; + const uint64_t bits = (uint64_t)selects * hal_i2c_txn_bits(1, 0) + + (uint64_t)reads * hal_i2c_txn_bits(1, 2); + return (uint32_t)hal_i2c_bits_to_us(bits); +} + +uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms) +{ + if (period_ms == 0) { + return 0; + } + return (uint32_t)(((uint64_t)bus_us * 1000ull) / ((uint64_t)period_ms * 1000ull)); +} + +int t_safety_format(const safety_snap_t *s, char *buf, size_t len) +{ + assert(s != NULL && buf != NULL && len > 0); + const uint32_t period = task_desc(TASK_SAFETY)->period_ms; + const uint32_t pm = t_safety_load_permille(s->bus_us, period); + return snprintf(buf, len, + "t_safety: cycles=%u skipped=%u i2c_err=%u txns=%u bus=%u us/%u ms " + "load=%u.%u%% events=%u mux(rst=%u trip=%u)", + (unsigned)s->cycles, (unsigned)s->skipped, (unsigned)s->i2c_errors, + (unsigned)s->txns, (unsigned)s->bus_us, (unsigned)period, + (unsigned)(pm / 10u), (unsigned)(pm % 10u), (unsigned)s->events, + (unsigned)s->mux_recovers, (unsigned)s->mux_trips); +} diff --git a/firmware/main/t_safety.h b/firmware/main/t_safety.h index b898104..5d10e0c 100644 --- a/firmware/main/t_safety.h +++ b/firmware/main/t_safety.h @@ -1,158 +1,217 @@ -/* `t_safety` —— 20 ms 巡检任务的**一个周期**(需求 §10.3 第一行)。 - - 一个周期干四件事:拿 I²C 锁 → 读 6 片 INA226 的 V+I 与 PCA9555 两个输入端口 - → 放锁 → **跑判据**(A10:SR-5 / SW-8,见本文件下半段)。 - - 分成「一个周期是个函数」而不是直接写成 `while(1)`,是为了让它在宿主机上跑得起来: - 宿主机没有 FreeRTOS,任务体是死循环就一行也测不了。ESP 侧的 `while(1)` - 在 app_main.c 里,只有三行(拿节拍 / 调这个 / 睡到下一拍)。 - - ---- 三条不能改的规矩 ---------------------------------------------------- - 1. **不许死等锁。** 拿不到就跳过这一周期并计数(`skipped`)。死等的现象是 - 巡检整个停摆,而它是优先级最高的那个任务 —— 停摆了没有任何人会发现。 - 2. **一个器件读失败不能中断这一圈。** 六片里坏一片,剩下五路的判据照常要出。 - 失联 ≠ 断电(§10.2),失联更不等于「别的路也不看了」。 - 3. **越界要断电时直接写 `EFUSE_EN` / `VBUS_EN` 引脚**(§10.3 原话:不经任何队列 - 或抽象层)。所以判据就写在这个函数里,不是发个消息给 t_actuator。 */ -#ifndef T_SAFETY_H -#define T_SAFETY_H - -#include -#include -#include - -#include "hal_err.h" -#include "ina226.h" -#include "pca9555.h" -#include "phonerail.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* 等 I²C 锁最多等这么久。取周期的 1/4:等过头这一周期就废了, - 而**废一个周期**(20 ms 后重来)比**赖着不走**(把最高优先级任务钉死)好得多。 */ -#define T_SAFETY_LOCK_TIMEOUT_MS 5 - -/* ---- A10:两条判据的阈值。**每一个都有出处,改了就是改需求** ------------ - `test_thresholds_match_the_documents()` 会拦。 */ - -/* SR-5 / SW-2:「电压偏离设定 >±100 mV」。比的是 INA226 读回来的母线电压与 - **该路 DAC 的设定值**(`rail_setpoint_mv()`),不是 3900 这个常数 —— - SW-4 允许运维单路微调,拿常数比会把一台调到 4.0 V 的机器当成过压。 */ -#define T_SAFETY_DEV_MV 100 - -/* SR-5 的「且持续 20 ms」——「持续」在这里数的是**圈**,不是墙钟毫秒: - 连着两圈新鲜样本都越界才算,而成立的那一刻就是断电的那一刻。 - - ⚠️ **不要改回按墙钟算**(`now - 第一次看见 >= 20 ms`)。巡检的采样时刻带着 - 亚毫秒残差:一圈自己要占 1.56 ms 总线,而「睡到下一拍」是按整毫秒算的, - 于是相邻两次判据的时刻实测是 **21 / 19 ms 交替**。拿 `>= 20 ms` 去判, - 有一半的机会 19 ms 那一次不算数、要等到第三圈 —— 也就是 40 ms 才关路, - 直接超了 §12.1 的验收,而且是**间歇性**的:同一个故障注两次,一次过一次不过。 - 数圈没有这个毛病,而「两圈」在 20 ms 的周期下就是 20 ms。 - - ⚠️ SR-5 说「持续 20 ms 才算」,§12.1 的验收说「20 ms 内关路」—— - 字面上互相顶着,只有「从第一次看见算起、下一圈就断电」同时满足两条。 - 注意这是从**第一次看见**算的:物理事件最早可能发生在上一圈之后, - 所以对着外部世界的最坏延迟是 2 个周期。这不要紧 —— SR-5 原文就写着 - 「仅承担记录与告警,不承担实时保护」,真正的过压保护是 eFuse 里的硬件 OVP。 */ -#define T_SAFETY_DEV_HOLD_CYCLES 2 - -/* 上面那个圈数折算成毫秒 = (圈数 − 1) × 巡检周期。需求 SR-5 写的就是这个 20 ms, - `test_thresholds_match_the_documents()` 拿它和任务表里的周期对账 —— - 哪天巡检周期改了,这两个数会当场对不上,而不是悄悄把判据变成 40 ms。 */ -#define T_SAFETY_DEV_HOLD_MS 20 - -/* SW-8 的反灌判据:设计文档 §15.2 对策 2 原话「if I < −50 mA → 立即拉低 VBUS_EN」。 - 离噪声很远:INA226 电流 LSB 是 0.5 mA,分流失调 ±10 µV / 5 mΩ ≈ ±2 mA, - −50 mA 是它的 25 倍。§12.1 的验收注入 −60 mA,落在阈值外侧。 */ -#define T_SAFETY_REV_UA (-50000) - -/* 反灌**不做确认窗口**(一圈看见就断),这和上面 20 ms 的过压判据是有意不对称的: - · 误断 VBUS 的代价是丢一次 USB 数据,手机还由直供电着,没有任何风险; - · 漏判的代价是能量泵回 12V 母线,**连累另外四台**(设计文档 §15.2 末尾)。 - 一圈就断 ⇒ 最坏 40 ms(事件发生 + 一圈),SW-8 给的预算是 200 ms,5 倍余量。 */ -#define T_SAFETY_REV_BUDGET_MS 200 - -/* `EFUSE_EN` 拉高之后多久才开始判这一路的电压。tON 5.1 ms + 缓启动斜坡 11.5 ms - ≈ 17 ms(设计文档 §5 / §7.4),取 3 个巡检周期。 - 没有这个窗口的现象是**每次上电都自己把自己关掉**:斜坡走到一半时 - 实测电压离设定值差着好几百 mV,判据不认识「正在爬」和「掉下去了」的区别。 */ -#define T_SAFETY_SETTLE_MS 60 - -/* ---- A10:事件(§10.7 的「单条」形状:时间戳 + 路号 + 类型 + 当时的 V/I)---- - A11 会把这条记录塞进环形缓冲,这里只负责**产生**它。故意先把记录类型定下来, - A11 就只剩「缓冲 + 落盘」,不用回头再动判据这一层。 */ - -typedef enum { - SAFETY_EV_NONE = 0, - SAFETY_EV_OVER_VOLT, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */ - SAFETY_EV_UNDER_VOLT, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */ - SAFETY_EV_REVERSE, /* SW-8:电流转负(I < −50 mA)→ 断这一路 VBUS */ - SAFETY_EV_PUSHED_UP, /* SW-8:VBUS 期间电压被顶高 >+100 mV → 断这一路 VBUS */ -} safety_ev_t; - -typedef struct { - uint32_t t_ms; /* 上电起算的毫秒(§10.7:上位机连上后下发墙钟做偏移) */ - uint8_t rail; /* 1~5 */ - uint8_t type; /* safety_ev_t */ - int32_t bus_mv; /* 出事那一刻的实测值,不是设定值 */ - int32_t current_ua; -} safety_event_t; /* 16 B —— §10.7「单条 16 B 量级」 */ - -/* 事件出口。A11 在这里接环形缓冲;不接就只有快照里的 `last` 与计数。 - ⚠️ 回调是在**断电之后**才调的(先保命,再记账),而且此刻两把锁都没拿着 —— - 接进来的东西不许阻塞,也不许回头调 t_safety_*。 */ -typedef void (*safety_ev_fn)(void *ctx, const safety_event_t *ev); -void t_safety_set_event_sink(safety_ev_fn fn, void *ctx); - -const char *t_safety_ev_str(safety_ev_t ev); - -/* 每路的判据状态。全在快照里,`t_safety_init()` 一并清干净。 */ -typedef struct { - bool en_on; /* 上一圈看到的 `EFUSE_EN` 影子,用来认它的上升沿 */ - uint32_t en_ms; /* 上升沿的时刻,缓启动窗口从这里算(这个是真按毫秒的) */ - uint8_t dev_n; /* 连着几圈越界了。回到范围内 / 这一路被关掉就清零 */ -} safety_rail_t; - -typedef struct { - /* 这一圈读回来的原始量。`ok` 为 false 时对应的值是**上一圈的**,不是 0 —— - 判据必须先看 ok 再看值,拿一个没更新的电压去判过压是最难查的那种错。 */ - ina226_vi_t rail[PHONERAIL_RAIL_COUNT]; - bool rail_ok[PHONERAIL_RAIL_COUNT]; - ina226_vi_t input; /* 输入侧 U13(0x43),整板功率预算 */ - bool input_ok; - pca9555_in_t io; /* 五路 FLT# / PGOOD + Buck PG 或 + INA ALERT 或 */ - bool io_ok; - - safety_rail_t st[PHONERAIL_RAIL_COUNT]; /* 每路的判据状态(内部用) */ - safety_event_t last; /* 最近一条事件;`last.type == SAFETY_EV_NONE` = 还没出过 */ - uint32_t events; /* 出过几条事件 */ - - uint32_t cycles; /* 跑过几圈(含被跳过的) */ - uint32_t skipped; /* 其中因为拿不到 I²C 锁而整圈跳过的 */ - uint32_t i2c_errors; /* 累计器件级失败次数(一圈里可能不止一次) */ - uint32_t bus_us; /* **上一圈**在 I²C 上真正花掉的时间 */ - uint64_t bus_cursor; /* 内部:位数游标,别在外面改 */ -} safety_snap_t; - -void t_safety_init(safety_snap_t *s); -hal_err_t t_safety_step(safety_snap_t *s); - -/* 按 §10.3 的算式静态算出来的「一圈该占总线多久」,单位 µs。 - 它和 `s->bus_us` 应当**一模一样** —— 不一样只有两种可能:巡检里多读/少读了东西, - 或者有别的任务在同一圈里插了事务进来。测试拿这两个数对账。 */ -uint32_t t_safety_budget_us(void); - -/* 占用率(千分之)。20 ms 周期、1560 µs → 78‰。§10.3 手算的是「约 8 %」。 */ -uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms); - -/* 一行人话,给启动日志和遥测用。返回写进去的字符数(不含结尾 0)。 */ -int t_safety_format(const safety_snap_t *s, char *buf, size_t len); - -#ifdef __cplusplus -} -#endif - -#endif /* T_SAFETY_H */ +/* `t_safety` —— 20 ms 巡检任务的**一个周期**(需求 §10.3 第一行)。 + + ---- R2 之后一个周期是什么样 ---------------------------------------------- + + R1:一把 I²C 锁包住整圈,读 6 片 INA226 + 1 片 PCA9555,放锁,再跑判据。 + R2:五路各在**自己的 mux 通道**上,所以一圈是**五次 `i2cbus_xact` + 一次主干**: + + for 每一路: xact(本路通道) { 读 INA226 的 V/I → 读 PCA9555 两个输入端口 + → 跑本路判据(必要时**就在这里**断 VBUS) } + xact(主干) { 读输入侧 INA226 } + 放锁之后: PRSNT# 轮询 → 关 eFuse / abort 序列 / 记事件 → mux 失联判据 + + 每路一次 `xact` 而不是一把大锁包住整圈,是为了给 `t_actuator` 留得进来的缝 + (R2 之后按键也走 I²C,计划 §5.6)。 + + 分成「一个周期是个函数」而不是直接写成 `while(1)`,是为了让它在宿主机上跑得起来。 + + ---- 四条不能改的规矩 ---------------------------------------------------- + 1. **不许死等锁。** 拿不到就跳过这一路并计数(`skipped`)。死等的现象是 + 巡检整个停摆,而它是优先级最高的那个任务 —— 停摆了没有任何人会发现。 + 2. **一个器件读失败不能中断这一圈。** 某一路坏了,剩下四路的判据照常要出。 + 失联 ≠ 断电(§10.2),失联更不等于「别的路也不看了」。 + 3. **断 VBUS 必须在锁内、在本路的 `xact` 里做。** ⚠️ 这条是 R2 新的: + `VBUS_EN` 从 GPIO 变成了刀片 PCA9555 的 P0.4(计划 §5.4), + 放锁之后再拿一次锁去断,中间隔着一次锁争用 —— 而且那时通道可能已经被 + 别人切走了。**`EFUSE_EN` 相反**:它是 GPIO 直连,放锁之后关才对 + (拿着总线锁去写引脚只会把别人多挡一会儿)。 + 4. **判据一条也不许额外发 I²C 事务。** 判据要的三个量(设定值、EN 的状态、 + 正在跑的序列)全部有影子可查。多读一次就把预算顶穿,测试会当场变红。 */ +#ifndef T_SAFETY_H +#define T_SAFETY_H + +#include +#include +#include + +#include "hal_err.h" +#include "ina226.h" +#include "pca9555.h" +#include "phonerail.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* 每一路等 I²C 锁最多等这么久。等过头这一路就废了,而**废一路一圈** + (20 ms 后重来)比**赖着不走**(把最高优先级任务钉死)好得多。 */ +#define T_SAFETY_LOCK_TIMEOUT_MS 5 + +/* ⭐ **整圈等锁的总预算**,R2 必须有的一条。 + + R1 一圈只拿一次锁,所以「单次超时 5 ms」就是全部的账。**R2 一圈要拿六次** + (五路各一次 + 主干一次),单看每次 5 ms 没问题,六次加起来是 **30 ms —— 比 + 20 ms 的周期还长**。总线长期繁忙时的现象不是「偶尔丢一拍」,而是**巡检成片 + 地丢拍、周期整个失准**,而每一次 `xact` 都老老实实在 5 ms 就返回了, + 哪一层都不会报错。 + + 所以除了单次超时,还要有一条整圈的闸:本圈在等锁上花掉的时间超过这个数, + **剩下的路这一圈就不读了**(记 `skipped`)。取周期的一半,留另一半给判据、 + 给 `t_actuator`、给别的任务。 + + ⚠️ 已知的不公平:预算是按顺序花的,所以长期繁忙时**后面几路会比前面几路 + 更常被跳过**。没有做轮转起点,因为 SR-5 判据数的是「连续两圈都越界」, + 某一路偶尔漏一圈只让它晚一圈跳闸;而轮转会让「第几次拿锁」这种可断言的 + 不变量消失,代价比收益大。真正该看的是 `skipped` 有没有持续增长。 */ +#define T_SAFETY_LOCK_BUDGET_MS 10 + +/* ---- A10:判据阈值。**每一个都有出处,改了就是改需求** ------------------ + `test_thresholds_match_the_documents()` 会拦。 */ + +/* SR-5 / SW-2:「电压偏离设定 >±100 mV」。比的是 INA226 读回来的母线电压与 + **该路 DAC 的设定值**(`rail_setpoint_mv()`),不是 3900 这个常数 —— + SW-4 允许运维单路微调,拿常数比会把一台调到 4.0 V 的机器当成过压。 */ +#define T_SAFETY_DEV_MV 100 + +/* SR-5 的「且持续 20 ms」——「持续」在这里数的是**圈**,不是墙钟毫秒: + 连着两圈新鲜样本都越界才算,而成立的那一刻就是断电的那一刻。 + + ⚠️ **不要改回按墙钟算**(`now - 第一次看见 >= 20 ms`)。巡检的采样时刻带着 + 亚毫秒残差:一圈自己要占 2.34 ms 总线,而「睡到下一拍」是按整毫秒算的, + 于是相邻两次判据的时刻实测是 **21 / 19 ms 交替**。拿 `>= 20 ms` 去判, + 有一半的机会 19 ms 那一次不算数、要等到第三圈 —— 也就是 40 ms 才关路, + 直接超了 §12.1 的验收,而且是**间歇性**的。数圈没有这个毛病。 + + ⚠️ SR-5 说「持续 20 ms 才算」,§12.1 的验收说「20 ms 内关路」—— + 字面上互相顶着,只有「从第一次看见算起、下一圈就断电」同时满足两条。 */ +#define T_SAFETY_DEV_HOLD_CYCLES 2 + +/* 上面那个圈数折算成毫秒 = (圈数 − 1) × 巡检周期。需求 SR-5 写的就是这个 20 ms, + `test_thresholds_match_the_documents()` 拿它和任务表里的周期对账。 */ +#define T_SAFETY_DEV_HOLD_MS 20 + +/* SW-8 的反灌判据:设计文档 §15.2 对策 2 原话「if I < −50 mA → 立即拉低 VBUS_EN」。 + 离噪声很远:INA226 电流 LSB 是 0.5 mA,分流失调 ±10 µV / 5 mΩ ≈ ±2 mA, + −50 mA 是它的 25 倍。§12.1 的验收注入 −60 mA,落在阈值外侧。 */ +#define T_SAFETY_REV_UA (-50000) + +/* 反灌**不做确认窗口**(一圈看见就断),这和 20 ms 的过压判据是有意不对称的: + · 误断 VBUS 的代价是丢一次 USB 数据,手机还由直供电着,没有任何风险; + · 漏判的代价是能量泵回 12V 母线,**连累另外四台**。 + 一圈就断 ⇒ 最坏 40 ms + 一次 I²C 事务(≈0.16 ms),SW-8 给的预算是 200 ms。 */ +#define T_SAFETY_REV_BUDGET_MS 200 + +/* `EFUSE_EN` 拉高之后多久才开始判这一路的电压。tON 5.1 ms + 缓启动斜坡 11.5 ms + ≈ 17 ms(设计文档 §5 / §7.4),取 3 个巡检周期。 + 没有这个窗口的现象是**每次上电都自己把自己关掉**。 */ +#define T_SAFETY_SETTLE_MS 60 + +/* ⭐ R2 新增:**连续**多少次选不中 mux 通道才认定它失联(计划 §5.8 与 §11)。 + ⚠️ 这个数是「先按 3 写、上板实测总线误码率之后再定」的待定项,不是算出来的。 + 太小(1):一次总线毛刺就停掉五台手机;太大(10):失联之后还有 200 ms + 在拿旧数据跑判据。判定成立之后**先脉冲一次 `MUX_RST#`**,再不行才全断。 */ +#define T_SAFETY_MUX_FAIL_N 3 + +/* ---- A10:事件(§10.7 的「单条」形状) ----------------------------------- + A11 把这条记录塞进环形缓冲,这里只负责**产生**它。 */ + +typedef enum { + SAFETY_EV_NONE = 0, + SAFETY_EV_OVER_VOLT, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */ + SAFETY_EV_UNDER_VOLT, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */ + SAFETY_EV_REVERSE, /* SW-8:电流转负(I < −50 mA)→ 断这一路 VBUS */ + SAFETY_EV_PUSHED_UP, /* SW-8:VBUS 期间电压被顶高 >+100 mV → 断这一路 VBUS */ + /* ⭐ R2 新增两条(计划 §5.8)。放在**末尾**:`evlog` 那边的数字是 flash 格式。 */ + SAFETY_EV_MUX_LOST, /* TCA9548A 连续选不中 ⇒ 五路全部断电(单点) */ + SAFETY_EV_SLOT_EMPTY, /* `PRSNT#` 为高(刀片不在位)⇒ 这一路不许带电 */ +} safety_ev_t; + +typedef struct { + uint32_t t_ms; /* 上电起算的毫秒(§10.7:上位机连上后下发墙钟做偏移) */ + uint8_t rail; /* 1~5;**0 = 整板级**(mux 失联就是 0) */ + uint8_t type; /* safety_ev_t */ + int32_t bus_mv; /* 出事那一刻的实测值,不是设定值 */ + int32_t current_ua; +} safety_event_t; /* 16 B —— §10.7「单条 16 B 量级」 */ + +/* 事件出口。A11 在这里接环形缓冲。 + ⚠️ 回调是在**动作之后**才调的(先保命,再记账),而且此刻锁已经放掉了 —— + 接进来的东西不许阻塞,也不许回头调 t_safety_*。 */ +typedef void (*safety_ev_fn)(void *ctx, const safety_event_t *ev); +void t_safety_set_event_sink(safety_ev_fn fn, void *ctx); + +const char *t_safety_ev_str(safety_ev_t ev); + +/* 每路的判据状态。全在快照里,`t_safety_init()` 一并清干净。 */ +typedef struct { + bool en_on; /* 上一圈看到的 `EFUSE_EN` 影子,用来认它的上升沿 */ + uint32_t en_ms; /* 上升沿的时刻,缓启动窗口从这里算(这个是真按毫秒的) */ + uint8_t dev_n; /* 连着几圈越界了。回到范围内 / 这一路被关掉就清零 */ + + /* 本圈锁内判据留给锁外的两件事(规矩 3:VBUS 锁内断,eFuse 锁外关)。 */ + uint8_t pend_ev; /* safety_ev_t,0 = 没有 */ + bool pend_efuse; /* 锁外要把 `EFUSE_EN` 拉低 */ + bool pend_abort; /* 锁外要打断这一路正在跑的序列 */ +} safety_rail_t; + +typedef struct { + /* 这一圈读回来的原始量。`ok` 为 false 时对应的值是**上一圈的**,不是 0 —— + 判据必须先看 ok 再看值,拿一个没更新的电压去判过压是最难查的那种错。 */ + ina226_vi_t rail[PHONERAIL_RAIL_COUNT]; + bool rail_ok[PHONERAIL_RAIL_COUNT]; + + /* ⭐ R2:**每路一份** PCA9555 输入(R1 是全板一片,所以只有一份)。 */ + pca9555_in_t io[PHONERAIL_RAIL_COUNT]; + bool io_ok[PHONERAIL_RAIL_COUNT]; + + /* ⭐ R2:`PRSNT#` 快照(纯 GPIO,不占总线)。 */ + bool present[PHONERAIL_RAIL_COUNT]; + + ina226_vi_t input; /* 输入侧 U13(0x43,主干),整板功率预算 */ + bool input_ok; + + safety_rail_t st[PHONERAIL_RAIL_COUNT]; + safety_event_t last; /* 最近一条事件;`last.type == SAFETY_EV_NONE` = 还没出过 */ + uint32_t events; + + uint32_t cycles; /* 跑过几圈(含被跳过的) */ + uint32_t skipped; /* 其中至少有一路因为拿不到锁被跳过的圈数 */ + uint32_t i2c_errors; /* 累计器件级失败次数(一圈里可能不止一次) */ + uint32_t mux_recovers; /* 脉冲过几次 `MUX_RST#` */ + uint32_t mux_trips; /* 因 mux 失联全断过几次 */ + /* mux 失联判据走到哪一步了:0 = 没事 / 1 = 这一轮复位过了 / 2 = 这一轮已经全断。 + ⚠️ 少了这个「走到哪一步」,两段式就退化成**死循环**:每一圈都满足 + 「连续失败够数」,于是每 20 ms 脉冲一次 `MUX_RST#`(永远断不掉), + 或者每 20 ms 全断一次并刷一条事件(把 evlog 那块 flash 写穿)。 + 两种现象都不是「判据不灵」,而是「判据一直在响」。 + 选中过一次通道(streak 归零)就回到 0,下次失联重新走两段。 */ + uint8_t mux_step; + uint32_t bus_us; /* **上一圈**在 I²C 上真正花掉的时间 */ + uint32_t txns; /* **上一圈**真正发出去的事务数(拿它和预算对账) */ + uint64_t bus_cursor; /* 内部:位数游标,别在外面改 */ + uint32_t txn_cursor; /* 内部:事务数游标 */ +} safety_snap_t; + +void t_safety_init(safety_snap_t *s); +hal_err_t t_safety_step(safety_snap_t *s); + +/* 按上面那一圈静态算出来的「一圈该占总线多久」,单位 µs。 + ⚠️ R2 里它含 **6 次 mux 选通**(五路各一次 + 回主干一次)。 + 它和 `s->bus_us` 应当**一模一样** —— 不一样只有两种可能:巡检里多读/少读了 + 东西,或者有别的任务在同一圈里插了事务进来。测试拿这两个数对账。 */ +uint32_t t_safety_budget_us(void); + +/* 一圈的名义事务数(R2 = 5×(选通+2+1) + (选通+2) = 23)。 */ +uint32_t t_safety_cycle_txns(void); + +/* 占用率(千分之)。20 ms 周期、2340 µs → 117‰。R1 是 78‰(计划 §5.5)。 */ +uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms); + +/* 一行人话,给启动日志和遥测用。返回写进去的字符数(不含结尾 0)。 */ +int t_safety_format(const safety_snap_t *s, char *buf, size_t len); + +#ifdef __cplusplus +} +#endif + +#endif /* T_SAFETY_H */ diff --git a/firmware/main/t_thermal.c b/firmware/main/t_thermal.c new file mode 100644 index 0000000..eb8b2e5 --- /dev/null +++ b/firmware/main/t_thermal.c @@ -0,0 +1,233 @@ +/* `t_thermal` 的实现。判据与出处全部写在 `t_thermal.h` 的文件头,这里只放代码。 */ +#include "t_thermal.h" + +#include + +#include "board_pins.h" +#include "evlog.h" +#include "hal_gpio.h" +#include "hal_pwm.h" +#include "hal_time.h" + +/* 占空比有下限 ⇒ 窗口的墙钟长度有上界 = `TACH_ON_MS × 1000 / DUTY_MIN`。 + 这两条断言钉的是同一件事:**下限为 0 时导通时间永远攒不满,停转判据会静默失效** + (不报错、不告警,只是再也不报停转)。所以下限只能在这里被改小一点点, + 改到让窗口超过 30 s 就编译不过。 */ +_Static_assert(T_THERMAL_DUTY_MIN > 0, "占空比下限为 0:TACH 窗口永远攒不满,停转判据会静默失效"); +_Static_assert((uint64_t)T_THERMAL_TACH_ON_MS * 1000u / T_THERMAL_DUTY_MIN <= T_THERMAL_TACH_MAX_MS, + "占空比下限太低:TACH 窗口会拖过 T_THERMAL_TACH_MAX_MS,风扇停了很久才报得出来"); + +static therm_temp_fn s_temp_fn; +static therm_tach_fn s_tach_fn; +static void *s_ctx; +static bool s_inited; + +/* 风扇侧占空比 → 真正写进 `hal_pwm` 的值。Q6 是低边管,栅极高 = 把风扇的 + PWM 线拉低 ⇒ 反相(`t_thermal.h` 硬件那三条的第 2 条)。 */ +static uint16_t gpio_duty_of(uint16_t fan_duty) +{ +#if T_THERMAL_PWM_INVERTED + return (uint16_t)(1000u - fan_duty); +#else + return fan_duty; +#endif +} + +uint16_t t_thermal_duty_for(int32_t milli_c, bool valid) +{ + /* 读不到温度 = 满速。需求 §10.10:**不要按「读不到温度 = 不热」处理**。 */ + if (!valid) { + return 1000; + } + if (milli_c <= T_THERMAL_TEMP_LO_MC) { + return T_THERMAL_DUTY_MIN; + } + if (milli_c >= T_THERMAL_TEMP_HI_MC) { + return 1000; + } + /* 分段线性。分子最大 = 25000 × 700 = 1.75e7,int32 装得下(不用 64 位)。 */ + const int32_t span_mc = T_THERMAL_TEMP_HI_MC - T_THERMAL_TEMP_LO_MC; + const int32_t span_duty = 1000 - T_THERMAL_DUTY_MIN; + return (uint16_t)(T_THERMAL_DUTY_MIN + (milli_c - T_THERMAL_TEMP_LO_MC) * span_duty / span_mc); +} + +static hal_err_t apply_duty(thermal_snap_t *s, uint16_t fan_duty) +{ + const uint16_t g = gpio_duty_of(fan_duty); + const hal_err_t e = hal_pwm_set_permille(T_THERMAL_PWM_CH, g); + if (e != HAL_OK) { + s->pwm_errors++; + return e; + } + s->duty = fan_duty; + s->gpio_duty = g; + return HAL_OK; +} + +hal_err_t t_thermal_init(thermal_snap_t *s, therm_temp_fn temp, therm_tach_fn tach, void *ctx) +{ + if (s == NULL) { + return HAL_ERR_ARG; + } + memset(s, 0, sizeof(*s)); + s_temp_fn = temp; + s_tach_fn = tach; + s_ctx = ctx; + + /* ① `FAN_EN` 先配成输出、先写 0 —— 设计文档 §22.6 的上电顺序。 + 先关一下不是多余的:复位态是「不转」靠的是 R749 下拉,而**配成输出的那一刻** + 引脚就开始驱动了,不先写就是让上一次跑飞留下的电平决定 12V 通断。 */ + hal_err_t e = hal_gpio_config(BOARD_PIN_FAN_EN, HAL_GPIO_OUTPUT); + if (e != HAL_OK) { + return e; + } + e = hal_gpio_write(BOARD_PIN_FAN_EN, 0); + if (e != HAL_OK) { + return e; + } + + /* ② 配 PWM,并先按**满速**输出:还没读到第一个温度之前,宁可吵一秒。 */ + e = hal_pwm_config(T_THERMAL_PWM_CH, BOARD_PIN_FAN_PWM, T_THERMAL_PWM_HZ); + if (e != HAL_OK) { + return e; + } + e = apply_duty(s, 1000); + if (e != HAL_OK) { + return e; + } + + /* ③ 最后才给 12V。⚠️ 这一行就是「固件必须主动开」那件事本身: + 删掉它之后温度闭环、PWM、遥测全部照常工作,只是风扇一动不动。 */ + e = hal_gpio_write(BOARD_PIN_FAN_EN, 1); + if (e != HAL_OK) { + return e; + } + s->fan_en = true; + s->en_ms = hal_time_now_ms(); + s->last_ms = s->en_ms; + + s_inited = true; + return HAL_OK; +} + +/* 一路 TACH 的窗口累加 + 结算。`dt_ms` 是本圈实测的时间,`duty` 是**这段时间里 + 实际在输出的**占空比(也就是上一圈定下来的那个),不是刚算出来的新值。 */ +static void tach_step(thermal_snap_t *s, int idx, uint32_t dt_ms, uint16_t duty) +{ + therm_tach_state_t *t = &s->fan[idx]; + + uint32_t now_edges = 0; + if (s_tach_fn == NULL || !s_tach_fn(s_ctx, idx, &now_edges)) { + /* 计数器这一刻读不到:本圈不累加也不判。失联 ≠ 停转。 */ + return; + } + if (!t->have_last) { + t->last_edges = now_edges; + t->have_last = true; + return; + } + const uint32_t delta = now_edges - t->last_edges; /* uint32 相减,回绕安全 */ + t->last_edges = now_edges; + + /* 启动宽限期内一直重置窗口:这时候风扇还在起转,脉冲本来就没有或很稀。 */ + if (s->fan_en && hal_time_elapsed_ms(s->en_ms) < T_THERMAL_SPINUP_MS) { + t->win_edges = 0; + t->win_on_ms = 0; + t->win_ms = 0; + return; + } + + t->win_edges += delta; + t->win_ms += dt_ms; + /* **导通时间**:脉冲只可能出现在这段时间里,判据看的就是它(TR-6,见头文件)。 */ + t->win_on_ms += (uint32_t)((uint64_t)dt_ms * duty / 1000u); + + if (t->win_on_ms < T_THERMAL_TACH_ON_MS) { + return; /* 还没攒够导通时间,接着攒 —— 这期间**不判**,包括不判停转 */ + } + + /* ---- 结算 ---- */ + t->windows++; + /* RPM 按**墙钟**窗口算(四线风扇的 TACH 是连续的,12V 常供)。 + ⚠️ 万一最终装的是三线低边调速风扇,脉冲只在导通期出现,这个数会按占空比 + 偏低 —— 那时可信的只有下面那条停转判据,RPM 要改用 `win_on_ms` 当分母。 + 风扇定型(§22.6 的 MPN 待补)之后回来改这一行。 */ + if (t->win_ms > 0) { + t->rpm = (uint32_t)((uint64_t)t->win_edges * 60000u / ((uint64_t)T_THERMAL_TACH_PPR * t->win_ms)); + t->rpm_valid = true; + } + + const bool stalled_now = (t->win_edges < T_THERMAL_TACH_MIN_EDGES); + if (stalled_now && !t->stalled) { + t->stalls++; + /* 只在**上升沿**记一条:停转是个持续状态,每个窗口记一条会把 256 条的 + RAM 缓冲刷光,把过压/反灌那些真正救命的事件挤掉(`evlog.h` 的 LOST)。 + `rail = 0` 是整板级;`arg` 是风扇号;`bus_mv` 借位放算出来的 RPM。 */ + evlog_put(0, LOG_EV_FAN_STALL, (uint8_t)idx, (int32_t)t->rpm, 0); + } + t->stalled = stalled_now; + + t->win_edges = 0; + t->win_on_ms = 0; + t->win_ms = 0; +} + +hal_err_t t_thermal_step(thermal_snap_t *s) +{ + if (s == NULL) { + return HAL_ERR_ARG; + } + if (!s_inited) { + return HAL_ERR_STATE; + } + + const uint32_t now_ms = hal_time_now_ms(); + const uint32_t dt_ms = hal_time_elapsed_ms(s->last_ms); + s->last_ms = now_ms; + s->cycles++; + + /* 这段 dt 里真正在输出的是**上一圈**定下的占空比。先记下来再去改它 —— + 用新值去算刚过去的那段导通时间,就是在拿未来的事实解释过去的脉冲数。 */ + const uint16_t prev_duty = s->duty; + + /* ---- 温度 → 占空比(先做:散热是这一包的活,事件与转速都在它后面)---- */ + s->temp_valid = false; + s->temp_max_mc = 0; + for (int i = 0; i < T_THERMAL_NTC_COUNT; i++) { + int32_t mc = 0; + s->temp_ok[i] = (s_temp_fn != NULL) && s_temp_fn(s_ctx, i, &mc); + if (!s->temp_ok[i]) { + continue; + } + s->temp_mc[i] = mc; + /* 两路取**高**:一路在电感区、一路在 MOS 区(设计 §8.2), + 凉的那一路完全可能是「那半边没在用」。取平均会把一个 90 °C 的热点 + 和一个 30 °C 的凉点平成 60 °C —— 一个哪儿都不存在、但看着很健康的数。 */ + if (!s->temp_valid || mc > s->temp_max_mc) { + s->temp_max_mc = mc; + s->temp_valid = true; + } + } + if (!s->temp_valid) { + s->temp_errors++; + } + (void)apply_duty(s, t_thermal_duty_for(s->temp_max_mc, s->temp_valid)); + + /* ---- 过温降载:本模块只**报**,关路是限功率那一包的事 ---- */ + if (s->temp_valid) { + if (!s->derate && s->temp_max_mc >= T_THERMAL_DERATE_MC) { + s->derate = true; + s->derates++; + /* `bus_mv` = 温度 m°C(`evlog.h` 对这个类型的约定),`arg` = 占空比 %。 */ + evlog_put(0, LOG_EV_THERMAL_DERATE, (uint8_t)(s->duty / 10u), s->temp_max_mc, 0); + } else if (s->derate && s->temp_max_mc <= T_THERMAL_DERATE_CLEAR_MC) { + s->derate = false; /* 解除不记事件:解除不是故障 */ + } + } + + /* ---- 两路 TACH ---- */ + for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) { + tach_step(s, i, dt_ms, prev_duty); + } + return HAL_OK; +} diff --git a/firmware/main/t_thermal.h b/firmware/main/t_thermal.h new file mode 100644 index 0000000..86a4b9f --- /dev/null +++ b/firmware/main/t_thermal.h @@ -0,0 +1,243 @@ +/* `t_thermal` —— 1 s 热管理任务的**一个周期**(需求 §10.3 第二行、计划 A16 / R-6)。 + + 一个周期干四件事:读两路温度 → 算风扇占空比 → 结算 TACH 采样窗口 → 记事件。 + 和 `t_safety` 一样写成「一个周期是一个函数」而不是 `while(1)`:死循环在宿主机上 + 一行也跑不到,而这一包的全部价值就是下面那几条判据。 + + ---- R2 的风扇硬件(**已按板上 PCB 网络逐脚核实**,设计文档 §22.6 FAN-R2)---- + + +12V ──Q7(P-MOS)── FAN_12V_SW ── J26.2 / J27.2(两只风扇的 12V) + Q7.G ←─ Q8(N-MOS) ←─ R748 ←─ FAN_EN(IO48) + FAN_PWM(IO47) ─R733─ Q6.G,Q6.D = FAN_PWM_L → J26.4 / J27.4(**一路喂两只**) + J26.3 / J27.3 ─→ Q9 / Q10 栅极,漏极上拉到 +3V3 → FAN_TACH_IN(IO21) / FAN_TACH_OUT(IO14) + + 由此来的**三件固件必须知道的事**,每一件写反了都不会报错: + + 1. **`FAN_EN` 复位态 = 风扇不转。** R749 把 Q8 栅极拉到地、R746 把 Q7 栅极拉回源极, + 所以 MCU 高阻(复位、下载、跑飞)时 12V 是断的 —— 这是 SR-6「复位安全」要的, + 代价是**固件必须主动把它拉高**。忘了拉高的现象是「一切正常,就是不散热」: + 温度闭环照常算占空比、PWM 照常输出、TACH 读不到脉冲被启动宽限盖住, + 功能测试全绿,只有在机器满载跑了半小时之后才以「手机降频」的形式暴露。 + + 2. **PWM 是反相的。** Q6 是低边 N-MOS,栅极高 ⇒ 把风扇的 PWM 线**拉低** + ⇒ `D_gpio = 1 − D_fan`。本模块对外一律用**风扇侧**占空比(`duty`), + 只在写 `hal_pwm_set_permille()` 的那一行做反相(`gpio_duty`)。 + 写反了的现象是「越热转得越慢」—— 而遥测里占空比在跟着温度往上走,完全正常。 + + 3. **TACH 是反相的。** 开集三极管 Q9/Q10 各接一路,风扇 TACH 拉低 ⇒ GPIO 读到高。 + **数边沿没问题,任何「按电平判它转没转」的写法都是反的。** 所以本模块只吃 + 「累计边沿数」,一次电平也不读 —— 见 `therm_tach_fn`。 + + ---- 温度从哪来:**没有 ADC 的 HAL,也不打算加**(R-6 的一个决定)---------- + + R2 把载板整个删了,LM75 跟着没了,温度只剩两路 NTC(IO4/IO5,ADC1)。 + 本项目的 HAL 只有 gpio / i2c / pwm / time / store / lock 六样,**没有 ADC**。 + 这里**不加第七样**,理由两条: + · ESP32-S3 的 ADC 校准(`esp_adc_cal` 曲线拟合 + eFuse 里的校准值)在没有板子 + 的时候一行也验不了,加进 HAL 只会得到一个「sim 里永远对、板上还没试过」的接口; + · HAL 每多一个概念,后面每一次会话都要多读一遍。 + 所以温度**由外部喂进来**(`therm_temp_fn`),和 `t_actuator` 的 PG 回调同一个形状。 + ADC 采样 + NTC 查表留到上板那一包(Track B),那时它是一个几十行的 + `ntc.c`,接进这个回调即可,本模块一个字不用改。TACH 计数器同理(板上是 PCNT)。 + + ---- 采样窗口:这一包最容易写错的地方(需求 TR-6)------------------------ + + 低边调速的三线风扇在 PWM 关断期间**根本没有回流**,TACH 那一路是死的; + 于是「这个 1 s 窗口里没数到脉冲 ⇒ 停转」在占空比低的时候会**稳定误报**。 + (FAN-R2 换成四线风扇之后 12V 是常供的、TACH 连续,但判据不能建立在 + 「买到的一定是四线风扇」上 —— 而且四线风扇的脉冲只会**更多**,不会更少。) + + 本模块选的办法是 **③「窗口长度与占空比挂钩」**:判据不看墙钟走了多久, + 而是看这个窗口里**攒够了多少导通时间**(`T_THERMAL_TACH_ON_MS`)。 + 导通时间是脉冲真正可能出现的时间,所以「窗口里该有多少个脉冲」与占空比无关, + 门限就能是一个常数。占空比 100 % 时窗口 3 s,30 % 时自动拉长到 10 s。 + + 没选另外两个的理由: + · ①「窗口跨足够多个 PWM 周期」解决不了问题 —— 25 kHz 下 1 s 已经跨了 25000 个 + 周期,照样会误报。误报的根源不是周期数少,是**期望脉冲数随占空比变**。 + · ②「只在导通期计数」要求固件跟着 PWM 相位走。25 kHz、一个周期 40 µs, + 软件跟不上;真做只能上硬件(PCNT 的 control 脚接 PWM),那是板上那一包的事, + 而且它解决的是同一个问题的硬件版本 —— 本模块的判据在那种硬件下照样成立。 + + ⚠️ 与之配套的是**占空比下限** `T_THERMAL_DUTY_MIN`:占空比真掉到 0, + 导通时间永远攒不满,这条判据就变成了「永远不判」。所以闭环的下限不是 0; + 真要停风扇只能撤 `FAN_EN`(§22.6:0 % PWM 时四线风扇仍转 3300 RPM)。 + + ---- 事件:入队,不落盘 -------------------------------------------------- + `evlog_put()` 是几条指令的入队,任何任务都能调;`evlog_flush()` 是唯一碰 flash + 的地方,只有 `t_log` 调(`evlog.h` 文件头)。本模块一次也不碰 flash。 */ +#ifndef T_THERMAL_H +#define T_THERMAL_H + +#include +#include + +#include "hal_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- 硬件常数(有出处的那些)-------------------------------------------- */ + +/* 风扇 PWM 走 `hal_pwm` 的 0 号通道 —— 全板只有这一路 PWM(`hal_pwm.h` 文件头)。 */ +#define T_THERMAL_PWM_CH 0 + +/* 25 kHz:四线风扇 PWM 的通行规格(21~28 kHz),也是 `hal_pwm_esp.c` 与 + `test_hal_sim.c` 里已经在用的数。落进音频带(<20 kHz)的现象是**啸叫**。 */ +#define T_THERMAL_PWM_HZ 25000 + +/* ⚠️ Q6 反相(设计文档 §22.6「栅极高 = 风扇 PWM 线低」,已按板上网表核实: + `R733: FAN_PWM → Q6.G`、`Q6.D = FAN_PWM_L → J26.4/J27.4`)。 */ +#define T_THERMAL_PWM_INVERTED 1 + +/* 每转几个 TACH 脉冲。⚠️ **待上板实测**:§22.6 原话「2PPR 也来自系列波形示例」, + 而 2026-09-13 换成 60×60×10 薄风扇之后连 MPN 都还没定。 + 它只影响报出去的 RPM 数值,**不影响停转判据**(那条只看「有没有脉冲」)。 */ +#define T_THERMAL_TACH_PPR 2 + +/* ---- 闭环阈值 ------------------------------------------------------------ + ⚠️ 下面四个**全部是「待上板实测后填」**,不是算出来的: + R2 把测温点从「进风侧空气」挪到了「两排手机之间的排母走廊」,设计文档明说 + 「风扇闭环(§22.6)的阈值要按新位置重新定 —— 这是挪件的代价,不是 bug」, + 而新位置的温升曲线只有上板才有。这里给的是**形状**(分段线性 + 迟滞), + 数值先按常识填、单列在这里,上板实测之后只改这四行。 */ + +/* 低于它就按下限转(安静优先)。 */ +#define T_THERMAL_TEMP_LO_MC 40000 /* 40.000 °C —— 待实测 */ +/* 高于它满速。 */ +#define T_THERMAL_TEMP_HI_MC 65000 /* 65.000 °C —— 待实测 */ +/* 过温降载门限(FR-13「超阈值先降流再关路」)。本模块只**报**,不动电源 —— + 真降载要按整板功率优先级关路,那是限功率那一包的事(计划 §5.9)。 */ +#define T_THERMAL_DERATE_MC 80000 /* 80.000 °C —— 待实测 */ +/* 降载解除门限。⚠️ 必须**低于**上面那个:没有迟滞的现象是温度在门限上抖, + 每秒记一条降载事件,把 256 条的 RAM 环形缓冲在 4 分钟内刷光(`evlog.h`), + 真正有用的过压/反灌事件被挤掉 —— 而那时日志里看起来「记录得非常完整」。 */ +#define T_THERMAL_DERATE_CLEAR_MC 70000 /* 70.000 °C —— 待实测 */ + +/* 占空比下限(千分比)。风扇侧口径。见文件头「与之配套的是占空比下限」。 + ⚠️ 待上板实测:低了 TACH 窗口拉得太长(判停转变慢),高了吵。 */ +#define T_THERMAL_DUTY_MIN 300 + +/* ---- TACH 采样窗口 ------------------------------------------------------ */ + +/* 一个判据窗口要攒够这么多**导通时间**(不是墙钟时间)。见文件头。 + 3 s:占空比 100 % 时 3 s 判一次,30 % 下限时 10 s 判一次 —— 风扇停了之后 + 最坏 10 s 才报出来,而机箱的热时间常数是分钟级,够用。 */ +#define T_THERMAL_TACH_ON_MS 3000 + +/* 窗口里少于这么多个边沿 ⇒ 判它停了。 + 余量巨大是有意的:§22.6 给的最慢转速是 0 % PWM 下的 3300 RPM, + 3300 RPM × 2 PPR × 3 s 导通 = **330 个脉冲**,门限 4 是它的 1/80。 + 这条判据要答的是「有没有在转」,不是「转得够不够快」—— 转速不足是另一回事 + (风扇老化/积灰),那要等 §22.6 把额定转速定下来才判得了。 */ +#define T_THERMAL_TACH_MIN_EDGES 4 + +/* 窗口墙钟长度的**上界**。窗口按导通时间结算,所以它的实际长度是 + `T_THERMAL_TACH_ON_MS × 1000 / duty`,最长出现在占空比压到下限的时候 + (3 s ÷ 30 % = 10 s)。这个 30 s 是允许它拉到多长的红线: + 拉过头 = 风扇停了很久才报出来。 + + ⚠️ 这条不是运行期判断,是 `t_thermal.c` 里的**编译期断言**。 + 把占空比下限改小(比如将来限功率想让风扇让电)会同时拉长这个窗口, + 改到 0 的时候停转判据就**静默失效**了 —— 不报错、不告警、再也不报停转。 + 写成运行期兜底反而更糟:那段代码在下限 >0 时永远走不到,测不了也就不可信。 */ +#define T_THERMAL_TACH_MAX_MS 30000 + +/* `FAN_EN` 拉高之后的启动宽限:这段时间内不判停转,窗口一直重置。 + 设计文档 §22.6「使能后给启动宽限,关电后屏蔽停转告警」。 + 没有这个窗口的现象是**每次上电必报一条风扇停转**,然后大家就开始无视这个事件。 + + ⚠️ 数值**待实测**(薄风扇的起转时间厂家没给),但它有一个下界,不能随便往小写: + **必须长于「播种一圈 + 一个完整判据窗口」**,也就是 + `period_ms + T_THERMAL_TACH_ON_MS`(1 s 节拍下 = 4 s),否则第一个窗口在宽限 + 到期之前根本结算不了,这条宽限就是一段**永远走不到的死代码** —— + 写成 3 s 时把整个宽限判断删掉,测试一条都不会红(本包实测过)。取 5 s。 */ +#define T_THERMAL_SPINUP_MS 5000 + +/* ---- 外部喂进来的两样东西 ------------------------------------------------ */ + +#define T_THERMAL_NTC_COUNT 2 /* IO4 = RT601、IO5 = RT602 */ +#define T_THERMAL_FAN_COUNT 2 /* 0 = 进风 J26(TACH → IO21)、1 = 排风 J27(IO14) */ + +typedef enum { + THERM_FAN_IN = 0, /* J26 进风,TACH 走 `BOARD_PIN_FAN_TACH_IN` */ + THERM_FAN_OUT = 1, /* J27 排风,TACH 走 `BOARD_PIN_FAN_TACH_OUT` */ +} therm_fan_t; + +/* 温度取值回调,单位**毫摄氏度**(整数,不引浮点)。 + 返回 false = 这一刻读不到(ADC 还没起来 / NTC 开路)。 + ⚠️ 两路**都**读不到时本模块把风扇开到 100 %,不是维持原样 —— + 需求 §10.10 对「载板温度读不到」给的处置就是「风扇转到 100 % 并告警, + 不要按『读不到温度 = 不热』处理」。 */ +typedef bool (*therm_temp_fn)(void *ctx, int idx, int32_t *milli_c); + +/* TACH 取值回调:返回**累计**边沿数(自己回绕,uint32 相减是回绕安全的)。 + 板上这个数来自 PCNT / GPIO 中断,宿主机上来自假风扇。 + 返回 false = 计数器这一刻读不到(那就跳过这一路本圈的累加,不判停转)。 + + ⚠️ 这里收的是**计数**不是**电平**:接收电路反相(Q9/Q10), + 「电平高 = 在转」和「电平低 = 在转」都是错的,而且两种写法都有一半的时间蒙对。 */ +typedef bool (*therm_tach_fn)(void *ctx, int idx, uint32_t *edges); + +/* ---- 快照 ---------------------------------------------------------------- */ + +typedef struct { + /* 本窗口的三个累加量。窗口在「导通时间攒满」或「墙钟超时」时结算并清零。 */ + uint32_t win_edges; /* 数到的边沿 */ + uint32_t win_on_ms; /* **导通**时间:判据用的就是它 */ + uint32_t win_ms; /* 墙钟时长:只用来算 RPM 和兜底超时 */ + + uint32_t last_edges; /* 上一圈读回来的累计值 */ + bool have_last; /* 第一圈只播种,不作差(不然第一圈会白捡一大堆边沿) */ + + uint32_t rpm; /* 上一个**结算完**的窗口算出来的转速 */ + bool rpm_valid; /* 还没结算过任何窗口时为 false —— 0 RPM 与「还不知道」不是一回事 */ + bool stalled; /* 当前判定 */ + uint32_t stalls; /* 判出过几次(只数上升沿,和事件条数一致) */ + uint32_t windows; /* 结算过几个窗口 */ +} therm_tach_state_t; + +typedef struct { + int32_t temp_mc[T_THERMAL_NTC_COUNT]; + bool temp_ok[T_THERMAL_NTC_COUNT]; + int32_t temp_max_mc; /* 两路里**高的那一个**:一点凉不代表另一点不烫 */ + bool temp_valid; /* 至少有一路读到了 */ + + uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径 */ + uint16_t gpio_duty; /* 真写进 hal_pwm 的值(Q6 反相 ⇒ 1000 − duty) */ + + bool fan_en; /* `FAN_EN` 的命令值 */ + uint32_t en_ms; /* 拉高的时刻,启动宽限从这里算 */ + + bool derate; /* 正在过温降载 */ + uint32_t derates; /* 进入过几次(只数上升沿) */ + + therm_tach_state_t fan[T_THERMAL_FAN_COUNT]; + + uint32_t cycles; /* 跑过几圈 */ + uint32_t last_ms; /* 上一圈的时刻,dt 从这里算 —— 不假设「一圈正好 1 s」 */ + uint32_t pwm_errors; /* 写 PWM 失败次数(板上不该发生,发生了要报出去) */ + uint32_t temp_errors; /* 两路温度**全部**读不到的圈数 */ +} thermal_snap_t; + +/* 把快照清干净 + 起硬件: + ① `FAN_EN` 配成输出并**先拉低**、② 配 PWM 并写满速、③ 再把 `FAN_EN` 拉高。 + 顺序来自设计文档 §22.6「先 FAN_EN=0、配好 PWM 与两个计数器,再使能供电」。 + `temp` / `tach` 传 NULL 就是「永远读不到」(温度读不到 ⇒ 满速,见上)。 */ +hal_err_t t_thermal_init(thermal_snap_t *s, therm_temp_fn temp, therm_tach_fn tach, void *ctx); + +/* 一个周期。周期取 `task_desc(TASK_THERMAL)->period_ms`(1 s,`tasks.c` 里定死), + 但本函数**不假设**自己是按时被调的:dt 一律用 `hal_time_elapsed_ms()` 实测, + 所以任务被挤晚了只会让窗口跟着变长,不会把导通时间算错。 */ +hal_err_t t_thermal_step(thermal_snap_t *s); + +/* 温度 → 风扇侧占空比。分段线性,纯函数(不碰硬件、不记事件),单独可测。 + `valid == false` ⇒ 满速。单调**不降**是这条曲线唯一不能破的性质。 */ +uint16_t t_thermal_duty_for(int32_t milli_c, bool valid); + +#ifdef __cplusplus +} +#endif + +#endif /* T_THERMAL_H */ diff --git a/firmware/test/test_ch224a.c b/firmware/test/test_ch224a.c new file mode 100644 index 0000000..9d9fd95 --- /dev/null +++ b/firmware/test/test_ch224a.c @@ -0,0 +1,321 @@ +/* R-0:CH224A 驱动(底板 U36,USB-PD 受电芯片,挂在 mux 通道 5 上)。 + + 板上它只干一件事:`R7` 120 k 硬件请求 20 V,固件**不参与协商**, + 只读出「当前这个充电器在 20 V 档最多能给多少电流」,换算成整板功率上限 + (计划 §5.9 / R1 §8.3 的降载)。 + + 这份测试守三件事: + 1. `0x50` 的单位是 **50 mA/LSB** —— 把单位记错,60 W 的充电器会被当成 6 W + (整板立刻降到只能开一路)或者 600 W(降载形同虚设,输入侧过流跳闸); + 2. **读失败时不许动调用方的 `*ma`** —— 旧的上限比一个 0 可信; + 3. ⛔ **驱动永远不写 `0x0A`**(`0x0A = 5` 是请求 28 V EPR, + 板上 `D3` 是 SMBJ22A、VRWM 22 V,会把 TVS 打到雪崩)。 + + ⚠️ `CH224A_ADDR_DEFAULT`(0x23)**还没有被实物证实**(计划 §11 的待定项), + 所以驱动的每个接口都收 addr 参数,这份测试也从不把 0x23 当成已知事实 —— + 见 test_every_entry_point_takes_the_address_because_it_is_unconfirmed。 */ +#include "ch224a.h" + +#include "hal_i2c.h" +#include "sim_hal.h" +#include "unity.h" + +/* B1 上板扫描之前,测试里用哪个地址都行 —— 这正是要证明的事之一。 + 这里**故意不用** CH224A_ADDR_DEFAULT,免得哪天 0x23 被证伪时 + 这一整份测试跟着一起红。 */ +#define TEST_ADDR 0x22 + +/* ---- 窃听口:这颗器件到底被写过什么 -------------------------------------- */ + +/* `hal_i2c_write_read()` 的「寄存器指针」那一段在假总线上也算一次 write, + 所以断言 `n_write == 0` 是做不到的(只读驱动也得把指针写出去)。 + 退而求其次,但更严: + · `s_touched[r]` —— 寄存器指针曾经指向过 r; + · `s_data_bytes` —— 指针后面**跟过几个数据字节**(只读驱动必须恒为 0)。 + 「写 0x0A = 5」这个动作会同时踩中两条:s_touched[0x0A] 和 s_data_bytes。 */ +static uint8_t s_touched[256]; +static uint32_t s_data_bytes; + +static void spy(sim_i2c_dev_t *d, sim_i2c_ev_t ev) +{ + if (ev != SIM_I2C_EV_POST_WRITE) { + return; + } + if (d->last_write_len >= 1) { + s_touched[d->last_write[0]] = 1; + } + if (d->last_write_len > 1) { + s_data_bytes += (uint32_t)d->last_write_len - 1u; + } +} + +void setUp(void) +{ + sim_hal_reset(); + for (int i = 0; i < 256; i++) { + s_touched[i] = 0; + } + s_data_bytes = 0; +} + +void tearDown(void) {} + +static sim_i2c_dev_t *mount(uint8_t addr) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL)); + sim_i2c_dev_t *d = sim_i2c_add(addr, 1); + TEST_ASSERT_NOT_NULL(d); + d->hook = spy; + return d; +} + +/* ---- 单位换算:50 mA/LSB ------------------------------------------------- */ + +/* ★ 三个真实档位。20 V 档下 60 W / 65 W / 100 W 分别是 3000 / 3250 / 5000 mA, + 除以 50 正好是 60 / 65 / 100 —— **寄存器值的数字恰好等于瓦数**, + 这个巧合很容易让人误以为「这个寄存器读出来就是瓦数」而把 ×50 省掉。 + 省掉的后果:60 W 充电器被当成 60 mA,整板功率上限 1.2 W,五路一个都开不起来; + 反过来把单位当成 10 mA/LSB,100 W 的充电器会被算成 20 W —— 同样是静默的, + 现象只是「插了 100 W 的充电器也只敢开两路」,没有任何报错。 */ +static void test_current_register_is_50ma_per_lsb(void) +{ + TEST_ASSERT_EQUAL_UINT32(3000, ch224a_ma_from_reg(60)); /* 60 W @20 V */ + TEST_ASSERT_EQUAL_UINT32(3250, ch224a_ma_from_reg(65)); /* 65 W @20 V */ + TEST_ASSERT_EQUAL_UINT32(5000, ch224a_ma_from_reg(100)); /* 100 W @20 V,要 e-marker 线 */ + + TEST_ASSERT_EQUAL_UINT32(0, ch224a_ma_from_reg(0)); + TEST_ASSERT_EQUAL_UINT32(50, ch224a_ma_from_reg(1)); /* 1 LSB = 50 mA */ + TEST_ASSERT_EQUAL_UINT32(12750, ch224a_ma_from_reg(255)); /* 满量程不溢出 */ + + /* 逐点线性,没有偏移:写成 (reg+1)*50 之类的实现在 reg=0 上就露馅。 */ + for (unsigned r = 0; r <= 255u; r++) { + TEST_ASSERT_EQUAL_UINT32(r * 50u, ch224a_ma_from_reg((uint8_t)r)); + } +} + +/* ★ 功率上限按 **20 V** 档换算,不是 5 V 也不是 12 V。 + 板上是 `R7` 一颗电阻把档位定死在 20 V,降压 `U37` 再变 12 V —— + 拿 12 V(母线电压)去乘是最自然的一个错,而且算出来的数看着也很合理 + (100 W 的充电器会被算成 60 W),于是最坏工况下真的会过载跳闸。 */ +static void test_power_limit_is_twenty_volts_times_the_current(void) +{ + TEST_ASSERT_EQUAL_UINT32(60000, ch224a_power_limit_mw(3000)); + TEST_ASSERT_EQUAL_UINT32(65000, ch224a_power_limit_mw(3250)); + TEST_ASSERT_EQUAL_UINT32(100000, ch224a_power_limit_mw(5000)); + TEST_ASSERT_EQUAL_UINT32(0, ch224a_power_limit_mw(0)); + + /* 档位是 20 000 mV,不是 12 000 / 5 000。 */ + TEST_ASSERT_EQUAL_UINT32(20000, CH224A_FIXED_MV); + TEST_ASSERT_EQUAL_UINT32(20u * 1234u, ch224a_power_limit_mw(1234)); + + /* 满量程 12750 mA = 255 W。中间过程若用 32 位相乘 + (20000 × 12750 = 255 000 000)还没溢出,但离 uint32 上限只剩一个数量级 —— + 驱动里用 uint64 中转就是为这条。 */ + TEST_ASSERT_EQUAL_UINT32(255000, ch224a_power_limit_mw(ch224a_ma_from_reg(255))); + + /* 两个函数串起来:寄存器 → 功率,整条链一次对完。 */ + TEST_ASSERT_EQUAL_UINT32(60000, ch224a_power_limit_mw(ch224a_ma_from_reg(60))); + TEST_ASSERT_EQUAL_UINT32(100000, ch224a_power_limit_mw(ch224a_ma_from_reg(100))); +} + +/* ---- 总线上的行为 -------------------------------------------------------- */ + +/* 读到什么就换算什么,而且只发一次事务(写指针 + 读一个字节)。 */ +static void test_read_max_ma_converts_what_the_chip_says(void) +{ + sim_i2c_dev_t *d = mount(TEST_ADDR); + sim_i2c_set_reg(TEST_ADDR, CH224A_REG_CURRENT, 65); /* 65 W 的砖 */ + + uint32_t ma = 0; + TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_max_ma(TEST_ADDR, &ma)); + TEST_ASSERT_EQUAL_UINT32(3250, ma); + TEST_ASSERT_EQUAL_UINT32(1, d->n_read); + TEST_ASSERT_EQUAL_HEX8(CH224A_REG_CURRENT, d->last_write[0]); /* 读的是 0x50 */ + + /* 换一块 100 W 的:同一个接口,不需要重新 init。 */ + sim_i2c_set_reg(TEST_ADDR, CH224A_REG_CURRENT, 100); + TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_max_ma(TEST_ADDR, &ma)); + TEST_ASSERT_EQUAL_UINT32(5000, ma); + TEST_ASSERT_EQUAL_UINT32(100000, ch224a_power_limit_mw(ma)); +} + +/* ★ 读失败时 `*ma` **一个字节都不许动**。 + 写成「先清零再读」的后果:总线上一次毛刺 → 上限变成 0 mA → 降载逻辑认为 + 这块砖一点电都给不了 → 五路全部降载甚至停机。而下一个周期读成功了又恢复, + 现象是「偶尔全部掉一下」,日志里除了一条 NAK 什么线索都没有。 + 旧的上限即使过期了也比一个 0 可信 —— 充电器不会在运行期被换掉。 */ +static void test_a_failed_read_leaves_the_previous_limit_untouched(void) +{ + mount(TEST_ADDR); + sim_i2c_set_reg(TEST_ADDR, CH224A_REG_CURRENT, 100); + + uint32_t ma = 3000; /* 上一轮读到的 60 W */ + sim_i2c_inject_nak(TEST_ADDR, 1); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ch224a_read_max_ma(TEST_ADDR, &ma)); + TEST_ASSERT_EQUAL_UINT32(3000, ma); /* ★ 没被改成 0,也没被改成 5000 */ + + /* 器件失联(芯片挂了 / PD 口拔了)同样如此。 */ + sim_i2c_set_present(TEST_ADDR, false); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ch224a_read_max_ma(TEST_ADDR, &ma)); + TEST_ASSERT_EQUAL_UINT32(3000, ma); + + /* 恢复之后才更新。 */ + sim_i2c_set_present(TEST_ADDR, true); + TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_max_ma(TEST_ADDR, &ma)); + TEST_ASSERT_EQUAL_UINT32(5000, ma); +} + +/* 状态寄存器是 0x09,而且**不校验内容** —— 这颗芯片没有 ID 寄存器, + 状态字的每一个值都是合法的。拿状态字当 ID 校验的后果是 + 「协商到某个档位时启动自检就过不去」,而那完全取决于插的是哪块砖。 */ +static void test_probe_accepts_any_status_byte(void) +{ + sim_i2c_dev_t *d = mount(TEST_ADDR); + + for (unsigned v = 0; v <= 255u; v++) { + sim_i2c_set_reg(TEST_ADDR, CH224A_REG_STATUS, (uint16_t)v); + TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_probe(TEST_ADDR)); + uint8_t st = 0xAA; + TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_status(TEST_ADDR, &st)); + TEST_ASSERT_EQUAL_HEX8((uint8_t)v, st); + } + TEST_ASSERT_EQUAL_HEX8(CH224A_REG_STATUS, d->last_write[0]); + + sim_i2c_set_present(TEST_ADDR, false); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ch224a_probe(TEST_ADDR)); +} + +/* NULL 出参一律 HAL_ERR_ARG,而且**在碰总线之前**就返回。 */ +static void test_null_outputs_are_rejected_before_touching_the_bus(void) +{ + sim_i2c_dev_t *d = mount(TEST_ADDR); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, ch224a_read_status(TEST_ADDR, NULL)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, ch224a_read_max_ma(TEST_ADDR, NULL)); + TEST_ASSERT_EQUAL_UINT32(0, d->n_read); + TEST_ASSERT_EQUAL_UINT32(0, d->n_write); +} + +/* ---- ⛔ 那条禁令 --------------------------------------------------------- */ + +/* ⭐⭐ **社会性用例:驱动里不存在任何写 `0x0A` 的接口。** + + C 里没法断言「某个函数不存在」,所以这里从**线上**反过来证: + 把驱动的每一个公开接口都跑一遍(而且是反复跑),然后断言 + + · 寄存器指针**从来没有指向过 0x0A**; + · 指针后面**从来没有跟过数据字节**(`s_data_bytes == 0`)—— 也就是说 + 这颗器件在整个测试跑完之后,一次真正的「写寄存器」都没有发生过; + · 驱动碰过的寄存器只有 `0x09` 和 `0x50` 两个,一个不多。 + + ⚠️ 为什么不是直接断言 `n_write == 0`:`hal_i2c_write_read()` 的 + 「发寄存器指针」那一段在假总线上也计一次 write,只读驱动也躲不掉。 + `n_write == 0` 在这颗器件上永远做不到,所以换成上面三条 —— 它们更严: + `n_write == 0` 拦不住「写 0x0A 用的是 write_read」,`s_data_bytes == 0` 能。 + + 为什么这件事值得单列一条用例:`0x0A = 5` 是「请求 28 V EPR」。 + 板上 `D3` 是 SMBJ22A(VRWM 22 V),`U37` 的 EN 分压按 20 V 设计 —— + 28 V 会把 TVS 打到雪崩,一次就报废。这个动作**不会报错**, + 它是 CH224A 完全合法的一条命令,芯片会老老实实去协商。 + 所以防线不是「叮嘱别用」,是**根本不提供接口**(和 `sr595` 不提供抬 `OE#`、 + `pca9555` 不提供把接地脚配成输出,是同一条规矩)。 + 这条用例的作用是:哪天有人「顺手加个 ch224a_request_mv()」,这里立刻红。 */ +static void test_the_driver_never_writes_a_single_data_byte(void) +{ + mount(TEST_ADDR); + sim_i2c_set_reg(TEST_ADDR, 0x0A, 0x5A); /* 哨兵:谁动了它谁露馅 */ + sim_i2c_set_reg(TEST_ADDR, CH224A_REG_CURRENT, 60); + + /* 把公开接口全跑一遍,跑够多次(万一有人写成「第 N 次才配置一下」)。 */ + for (int i = 0; i < 16; i++) { + uint8_t st = 0; + uint32_t ma = 0; + TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_probe(TEST_ADDR)); + TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_status(TEST_ADDR, &st)); + TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_max_ma(TEST_ADDR, &ma)); + TEST_ASSERT_EQUAL_UINT32(3000, ma); + TEST_ASSERT_TRUE(ch224a_addr_ok(TEST_ADDR)); + (void)ch224a_ma_from_reg(60); + (void)ch224a_power_limit_mw(3000); + } + + /* ★ 一个数据字节都没写出去 = 这是一颗只读器件。 */ + TEST_ASSERT_EQUAL_UINT32(0, s_data_bytes); + /* ★ 指针从没指向过 0x0A。 */ + TEST_ASSERT_EQUAL_UINT8(0, s_touched[0x0A]); + /* ★ 哨兵没被动过。 */ + TEST_ASSERT_EQUAL_HEX16(0x5A, sim_i2c_get_reg(TEST_ADDR, 0x0A)); + /* ★ 碰过的寄存器只有这两个。 */ + for (int r = 0; r < 256; r++) { + const uint8_t want = (r == CH224A_REG_STATUS || r == CH224A_REG_CURRENT) ? 1u : 0u; + TEST_ASSERT_EQUAL_UINT8(want, s_touched[r]); + } + + /* 这条检查本身不是句空话:手工写一次 0x0A = 5(也就是那个禁令动作), + 窃听口必须立刻照出来。抓不到的话上面那四条断言全是摆设。 */ + const uint8_t evil[2] = { 0x0A, 5 }; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(TEST_ADDR, evil, sizeof(evil))); /* 器件不报错 */ + TEST_ASSERT_EQUAL_UINT32(1, s_data_bytes); + TEST_ASSERT_EQUAL_UINT8(1, s_touched[0x0A]); + TEST_ASSERT_EQUAL_HEX16(5, sim_i2c_get_reg(TEST_ADDR, 0x0A)); /* 28 V 就是这么请求的 */ +} + +/* ---- 地址 ---------------------------------------------------------------- */ + +/* ⚠️ **0x23 还没有被实物证实**(计划 §11)。这条用例钉住的是 + 「驱动不把地址写死」这件事,而不是 0x23 本身: + 所有接口都收 addr 参数,换一个地址照样跑得通。 + B1 上板扫描时把通道 5 上真正扫到的那个地址填回 `CH224A_ADDR_DEFAULT` + 并同步改计划 §5.2 —— 在那之前,用 0x23 扫不到是**预期结果,不是固件 bug**。 */ +static void test_every_entry_point_takes_the_address_because_it_is_unconfirmed(void) +{ + /* 器件蹲在 0x22,`CH224A_ADDR_DEFAULT` 上什么也没有。 */ + mount(0x22); + sim_i2c_set_reg(0x22, CH224A_REG_CURRENT, 60); + + uint8_t st = 0; + uint32_t ma = 0; + TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_probe(0x22)); + TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_status(0x22, &st)); + TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_max_ma(0x22, &ma)); + TEST_ASSERT_EQUAL_UINT32(3000, ma); + + /* 默认值上扫不到 —— 这就是 B1 之前应当看到的现象。 */ + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ch224a_probe(CH224A_ADDR_DEFAULT)); + + /* 顺带确认默认值还是那个待确认的 0x23(改了就该同时改计划 §5.2)。 */ + TEST_ASSERT_EQUAL_HEX8(0x23, CH224A_ADDR_DEFAULT); + TEST_ASSERT_TRUE(ch224a_addr_ok(CH224A_ADDR_DEFAULT)); +} + +/* 地址冲突表:主干上的 4 个 + mux 自己 + 刀片支路上的 2 个。 + ⚠️ 与 `tca9548a_addr_ok()` 不同,这里**连 0x20 / 0x40 也要查** —— + CH224A 挂在通道 5 上,选到通道 5 的那一刻它和主干是连通的; + 要是它和某片刀片同址,那更是直接把刀片的 INA226 盖住。 */ +static void test_addr_ok_rejects_every_address_already_on_the_board(void) +{ + static const uint8_t taken[] = { 0x20, 0x40, 0x43, 0x4A, 0x4B, 0x70 }; + for (unsigned i = 0; i < sizeof(taken); i++) { + TEST_ASSERT_FALSE(ch224a_addr_ok(taken[i])); + } + TEST_ASSERT_TRUE(ch224a_addr_ok(0x22)); + TEST_ASSERT_TRUE(ch224a_addr_ok(0x23)); + TEST_ASSERT_TRUE(ch224a_addr_ok(0x7f)); + TEST_ASSERT_FALSE(ch224a_addr_ok(0x80)); /* 7 位地址,0x80 起就不是地址了 */ + TEST_ASSERT_FALSE(ch224a_addr_ok(0xFF)); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_current_register_is_50ma_per_lsb); + RUN_TEST(test_power_limit_is_twenty_volts_times_the_current); + RUN_TEST(test_read_max_ma_converts_what_the_chip_says); + RUN_TEST(test_a_failed_read_leaves_the_previous_limit_untouched); + RUN_TEST(test_probe_accepts_any_status_byte); + RUN_TEST(test_null_outputs_are_rejected_before_touching_the_bus); + RUN_TEST(test_the_driver_never_writes_a_single_data_byte); + RUN_TEST(test_every_entry_point_takes_the_address_because_it_is_unconfirmed); + RUN_TEST(test_addr_ok_rejects_every_address_already_on_the_board); + return UNITY_END(); +} diff --git a/firmware/test/test_evlog.c b/firmware/test/test_evlog.c index 32667b5..f4a7e10 100644 --- a/firmware/test/test_evlog.c +++ b/firmware/test/test_evlog.c @@ -76,19 +76,45 @@ static void test_flash_format_is_frozen(void) TEST_ASSERT_EQUAL_INT(11, LOG_EV_FAN_STALL); TEST_ASSERT_EQUAL_INT(12, LOG_EV_REBOOT); TEST_ASSERT_EQUAL_INT(13, LOG_EV_CMD_REJECT); - TEST_ASSERT_EQUAL_INT(14, LOG_EV_COUNT); + /* ⭐ R2 新增的三条(计划 §5.8 / §5.9)。追加在末尾,前面 13 个一个都没动 —— + 所以 R1 那块板子上跑了半年的 flash 拿到 R2 固件上仍然读得对。 + ⚠️ 这三行不是「补全」,它们是钉子:谁要是图省事把 MUX_LOST 插到 + OVER_VOLT 前面("安全事件排一起好看"),历史记录里的 3 就变成了 mux 失联, + 而读日志的工具、板子、测试全都不会报一个字。 */ + 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); } /* §10.7 的「必记事件」有八类,A10 只产生其中两类。剩下的号先占住, - 免得 A12/A15/A16 各自往中间插一个,把 flash 里的旧记录挪了位。 */ + 免得 A12/A15/A16 各自往中间插一个,把 flash 里的旧记录挪了位。 + + ⭐ 这条用例的上界是 `LOG_EV_COUNT`,所以 R2 追加的 14/15/16 **自动**被它盖住: + 往枚举里加一条却忘了在 `evlog_ev_str()` 里加一行,这里立刻红。 + (`evlog_ev_str()` 的 switch 没有 `default`,`-Wswitch` 本来也会拦一道; + 但只要有人手贱补一个 `default: return "?"`,编译器那一道就没了 —— 这条还在。) */ static void test_every_event_type_has_a_name(void) { + const char *names[LOG_EV_COUNT]; for (int i = 0; i < LOG_EV_COUNT; i++) { const char *s = evlog_ev_str((log_ev_t)i); TEST_ASSERT_NOT_NULL(s); TEST_ASSERT_TRUE_MESSAGE(s[0] != '?', "有事件类型没给名字"); + names[i] = s; } TEST_ASSERT_EQUAL_STRING("?", evlog_ev_str((log_ev_t)LOG_EV_COUNT)); + + /* 名字还得**两两不同**。加新类型时最省事的写法是复制上一行的 `case` —— + 复制完忘了改字符串,上面那个循环一个字都不会说。真板上的现象是 + 日志里两种完全不同的事件打出同一行字(比如「刀片不在位」显示成 + 「mux失联全断」),排查的人会照着那行字去查错的那条链。 */ + for (int i = 0; i < LOG_EV_COUNT; i++) { + for (int j = i + 1; j < LOG_EV_COUNT; j++) { + TEST_ASSERT_FALSE_MESSAGE(strcmp(names[i], names[j]) == 0, + "两个事件类型共用了同一个名字"); + } + } } /* `safety_ev_t` 与 `log_ev_t` 是两套编号,中间是一个**显式的 switch**。 @@ -108,6 +134,105 @@ static void test_the_safety_mapping_is_explicit(void) TEST_ASSERT_NOT_EQUAL((int)SAFETY_EV_PUSHED_UP, (int)LOG_EV_PUSHED_UP); } +/* ⭐ R2:把「显式 switch」这件事**用具体数字**钉死。 + + 上面那条用例全用符号写(`LOG_EV_REVERSE`),它证明得了「对应关系没接错」, + 证明不了「这个函数不是一个偏移」—— 因为把 switch 换成 + `return (log_ev_t)(LOG_EV_OVER_VOLT + safety_ev - 1)` 之后, + R1 那四条(1→3 2→4 3→5 4→6)的符号断言**全部照样通过**。 + + R2 追加的两条恰好是这个写法的照妖镜:`safety_ev_t` 里它们排在 5/6, + 而 `log_ev_t` 里它们是 14/15(前面还隔着 A12/A15/A16 占的七个号)。 + 偏移写法会把它们算成 7 和 8 —— 也就是 `LOG_EV_EFUSE_FAULT` 与 `LOG_EV_PG_CHANGE`。 + **这个错在功能测试里完全看不见**:日志照写、条数不少、CRC 也对, + 只有事后读日志的人会看到「五路全断」那一刻记的是「eFuse故障」。 + + 所以下面每一对都写成裸数字:两个枚举里任何一边动了号,这条都红。 */ +static void test_the_safety_mapping_is_a_table_not_an_offset(void) +{ + /* 先把两边的号本身钉住 —— 不然「偏移对上了」可能是因为有人挪了 safety 那边。 */ + TEST_ASSERT_EQUAL_INT(0, SAFETY_EV_NONE); + TEST_ASSERT_EQUAL_INT(1, SAFETY_EV_OVER_VOLT); + TEST_ASSERT_EQUAL_INT(2, SAFETY_EV_UNDER_VOLT); + TEST_ASSERT_EQUAL_INT(3, SAFETY_EV_REVERSE); + TEST_ASSERT_EQUAL_INT(4, SAFETY_EV_PUSHED_UP); + TEST_ASSERT_EQUAL_INT(5, SAFETY_EV_MUX_LOST); + TEST_ASSERT_EQUAL_INT(6, SAFETY_EV_SLOT_EMPTY); + + /* 逐对:safety 号 → flash 号,两边都是裸数字。 */ + TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(0)); /* NONE */ + TEST_ASSERT_EQUAL_INT(3, (int)evlog_from_safety(1)); /* OVER_VOLT */ + TEST_ASSERT_EQUAL_INT(4, (int)evlog_from_safety(2)); /* UNDER_VOLT */ + TEST_ASSERT_EQUAL_INT(5, (int)evlog_from_safety(3)); /* REVERSE */ + TEST_ASSERT_EQUAL_INT(6, (int)evlog_from_safety(4)); /* PUSHED_UP */ + TEST_ASSERT_EQUAL_INT(14, (int)evlog_from_safety(5)); /* MUX_LOST */ + TEST_ASSERT_EQUAL_INT(15, (int)evlog_from_safety(6)); /* SLOT_EMPTY */ + + /* 而且这张表**在数学上不可能**是一个偏移:前四对差 2,后两对差 9。 + 写死这一条,是为了让「改成偏移」这件事在用例名上就说得清楚, + 而不是靠上面某一行恰好不满足。 */ + TEST_ASSERT_EQUAL_INT(2, (int)evlog_from_safety(1) - (int)SAFETY_EV_OVER_VOLT); + TEST_ASSERT_EQUAL_INT(9, (int)evlog_from_safety(5) - (int)SAFETY_EV_MUX_LOST); + + /* 越过表尾的号一律 NONE。偏移写法在这里会算出一个**越界但合法**的 + `log_ev_t`(7 → 记成 eFuse 故障,200 → 记成 202 这种根本没有的号)。 */ + TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(7)); + TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(200)); + TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(255)); +} + +/* ⭐ R2 的两条新判据要真的落得了盘、读得回来。 + + 这里刻意走**完整的一条链**:`safety_ev_fn` 回调 → RAM 环 → flash → 重启 → 读回。 + 只断言 `evlog_from_safety()` 的返回值是不够的 —— `evlog_put_safety()` 里 + `r.rail = ev->rail` 这一行同样能出错,而 mux 失联是**整板级事件(rail = 0)**, + 正好落在「路号是 1~5」这个到处都在假设的前提之外: + 谁要是在入队或读回的路上顺手做一次 `rail - 1` 当下标、或者把 0 「修正」成 1, + 日志里五路全断那一刻就记成了「CH1 出事」,而板子、条数、CRC 全都正常。 */ +static void test_the_r2_events_round_trip_through_flash(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + const safety_ev_fn sink = evlog_put_safety; /* 按 A10 的出口类型接 */ + t_safety_set_event_sink(sink, NULL); + + /* mux 失联:**整板级**,rail = 0,五路全断那一刻的输入侧读数。 */ + const safety_event_t mux = { + .t_ms = 50000, .rail = 0, .type = SAFETY_EV_MUX_LOST, + .bus_mv = 12050, .current_ua = 4300000, + }; + /* 刀片不在位:**单路**,rail = 5(那一路正好是 PRSNT# 不顺排的 IO7)。 */ + const safety_event_t empty = { + .t_ms = 50007, .rail = 5, .type = SAFETY_EV_SLOT_EMPTY, + .bus_mv = 0, .current_ua = 0, + }; + sim_time_advance_ms(88888); /* 记账在断电之后:时间戳不许换成「现在」 */ + sink(NULL, &mux); + sink(NULL, &empty); + + TEST_ASSERT_EQUAL_UINT32(3, evlog_flush(0)); /* BOOT + 这两条 */ + + /* 断电重启:这两条必须是从 **flash** 里读回来的,不是还留在 RAM 里。 */ + sim_hal_reboot(); + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, sim_store_erases(), "重启把日志擦了"); + + const evlog_rec_t a = read_one(1); + TEST_ASSERT_EQUAL_INT_MESSAGE(14, a.type, "mux 失联没按 14 号落盘"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, a.rail, "整板级事件被安到了某一路头上"); + TEST_ASSERT_EQUAL_UINT32(50000, a.t_ms); + TEST_ASSERT_EQUAL_INT32(12050, a.bus_mv); + TEST_ASSERT_EQUAL_INT32(4300000, a.current_ua); + + const evlog_rec_t b = read_one(2); + TEST_ASSERT_EQUAL_INT_MESSAGE(15, b.type, "刀片不在位没按 15 号落盘"); + TEST_ASSERT_EQUAL_UINT8(5, b.rail); + TEST_ASSERT_EQUAL_UINT32(50007, b.t_ms); + + /* 读日志的人看到的是名字,不是数字 —— 顺带确认这两条打得出人话来。 */ + TEST_ASSERT_EQUAL_STRING("mux失联全断", evlog_ev_str((log_ev_t)a.type)); + TEST_ASSERT_EQUAL_STRING("刀片不在位", evlog_ev_str((log_ev_t)b.type)); +} + /* A10 的事件出口是 `safety_ev_fn`。A11 要能**原样**接上去(HANDOFF 里 「只接 sink,不动判据」那条),所以这里就按那个函数指针类型调一次。 */ static void test_the_sink_plugs_into_the_safety_layer_unchanged(void) @@ -622,6 +747,8 @@ int main(void) RUN_TEST(test_flash_format_is_frozen); RUN_TEST(test_every_event_type_has_a_name); RUN_TEST(test_the_safety_mapping_is_explicit); + RUN_TEST(test_the_safety_mapping_is_a_table_not_an_offset); + RUN_TEST(test_the_r2_events_round_trip_through_flash); RUN_TEST(test_the_sink_plugs_into_the_safety_layer_unchanged); RUN_TEST(test_capacity_meets_the_requirement); RUN_TEST(test_putting_events_never_touches_flash); diff --git a/firmware/test/test_hal_i2c_sim.c b/firmware/test/test_hal_i2c_sim.c index e299ebf..9e3f77d 100644 --- a/firmware/test/test_hal_i2c_sim.c +++ b/firmware/test/test_hal_i2c_sim.c @@ -1,12 +1,25 @@ /* A2 验收主项:能在假总线上挂一个器件并读回预置值。 - 顺带把「器件失联 / 偶发 NAK」这两种后面 A15 要用的故障注入也测掉。 */ + 顺带把「器件失联 / 偶发 NAK」这两种后面 A15 要用的故障注入也测掉。 + + ---- R2 新增:假总线有了 `bus` 与一颗假 mux ------------------------------ + 五片刀片**完全同构**(同址 0x40 / 0x20),真板上区分它们的唯一手段是底板 + TCA9548A 的通道。假总线如果只按地址查表,五片就塌成一片 —— + 于是「选错通道」这一类 R2 最危险的静默错误在宿主机上一次也复现不出来。 + 所以器件带一个 `bus`(-1 主干 / 0~7 支路),本文件最后两条用例测的就是它: + **关着的支路一律 NAK**、**同时开两路会被记账**。 + 原有那几条用的仍是 `sim_i2c_add()`(挂主干),语义没变。 */ #include #include "sim_hal.h" #include "unity.h" -#define INA226_CH1 0x40 /* 设计文档 §8.2 的地址表;A3 才会真的写驱动 */ -#define PCA9555 0x20 +#define INA226_RAIL 0x40 /* R2:五片刀片上的那片,五片同址(计划 §5.2) */ +#define INA226_INPUT 0x43 /* R2:底板输入侧 U13,挂**主干** */ +#define PCA9555 0x20 +#define MUX 0x70 /* TCA9548A(底板 U35) */ +#define MUX_RST_PIN 40 /* `MUX_RST#` 接的 GPIO,本文件用不到,给个真值免得误导 */ + +#define INA226_CH1 INA226_RAIL /* 原有用例沿用的名字 */ void setUp(void) { sim_hal_reset(); } void tearDown(void) {} @@ -120,6 +133,92 @@ static void test_address_out_of_range(void) TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, hal_i2c_probe(0x80)); } +/* ---- R2:mux 支路 --------------------------------------------------------- + 控制字是**掩码不是编号**,而且写 mux 是**一个裸字节**(不带寄存器号)。 + 这两件事都在 tca9548a.h 的文件头里;这里直接按线上的样子发。 */ +static void select_mask(uint8_t mask) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(MUX, &mask, 1)); +} + +/* ⭐ 通道**关着**的时候,挂在那条支路上的器件一律 NAK —— 真器件就是这个行为: + 支路被断开,从机根本收不到地址。 + + ⚠️ 假总线要是不做这一层(只按地址查表),五片刀片就塌成一片: + 「忘了选通道」「选错通道」「选通道失败却照读」这三种错在宿主机上**全都是绿的**, + 而它们在真板上的现象是「读到的是另一台手机的电压电流」—— + 量纲、范围、变化趋势全部正常,没有任何一层会报错。 */ +static void test_device_behind_a_closed_channel_naks(void) +{ + init_bus(); + sim_i2c_add(INA226_INPUT, 2); /* 主干上那片,永远看得见 */ + sim_i2c_add_ch(2, INA226_RAIL, 2); /* 槽 3 的刀片 */ + sim_i2c_add_ch(3, INA226_RAIL, 2); /* 槽 4 的刀片,**同一个地址** */ + sim_i2c_add_mux(MUX, MUX_RST_PIN); + sim_i2c_set_reg_ch(2, INA226_RAIL, 0x02, 0x1111); + sim_i2c_set_reg_ch(3, INA226_RAIL, 0x02, 0x2222); + + /* 上电全关:两片刀片谁也不应答,而主干那片照常。 */ + TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch()); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, hal_i2c_probe(INA226_RAIL)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(INA226_INPUT)); + + /* 选中槽 3:同一个地址,读回来的必须是**那一片**的寄存器。 */ + select_mask(1u << 2); + TEST_ASSERT_EQUAL_INT(2, sim_i2c_mux_ch()); + const uint8_t ptr = 0x02; + uint8_t rd[2] = { 0, 0 }; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write_read(INA226_RAIL, &ptr, 1, rd, sizeof(rd))); + TEST_ASSERT_EQUAL_HEX16(0x1111, (uint16_t)((rd[0] << 8) | rd[1])); + + /* 换成槽 4:地址一个字没改,读回来的却是另一片 —— 这就是「选错通道」的全貌。 */ + select_mask(1u << 3); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write_read(INA226_RAIL, &ptr, 1, rd, sizeof(rd))); + TEST_ASSERT_EQUAL_HEX16(0x2222, (uint16_t)((rd[0] << 8) | rd[1])); + /* 槽 3 那片这时**一次都没有**再被访问过。 */ + TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_dev_ch(2, INA226_RAIL)->n_read); + + /* 关回全断:两片都不见了,主干那片还在。 */ + select_mask(0); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, hal_i2c_probe(INA226_RAIL)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(INA226_INPUT)); + + /* 全程只开过一路,不许有「两片一起应答」。 */ + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); +} + +/* ⭐ 同一个地址同时看得见两片 = **通道开重了**(控制字是掩码,写 0x03 不会报错)。 + 真板上那是两片一起应答,主机读回来的是两片的**线与** —— + 数值在量程里、随负载变化、还特别稳,谁也看不出这是两台手机的数据与在一起了。 + 所以这个计数必须恒为 0;它非 0 的那一刻,就是所有电流读数开始悄悄偏小的那一刻。 */ +static void test_two_channels_open_at_once_is_counted_as_ambiguous(void) +{ + init_bus(); + sim_i2c_add_ch(0, INA226_RAIL, 2); + sim_i2c_add_ch(1, INA226_RAIL, 2); + sim_i2c_add_mux(MUX, MUX_RST_PIN); + + select_mask(1u << 0); /* 规规矩矩只开一路 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(INA226_RAIL)); + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); + + select_mask(0x03); /* 两路一起开:写下去**不报错**,器件也不抗议 */ + TEST_ASSERT_EQUAL_INT_MESSAGE(-2, sim_i2c_mux_ch(), "多于一路不是一种用法,是 bug"); + TEST_ASSERT_EQUAL_HEX8(0x03, sim_i2c_mux_mask()); + + /* 事务照样「成功」—— 这正是它难查的地方。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(INA226_RAIL)); + TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_ambiguous()); + uint8_t rd[2]; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_read(INA226_RAIL, rd, sizeof(rd))); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, sim_i2c_ambiguous(), "每一次事务都记一笔"); + + /* 收回成一路,计数就不再涨(它是「发生过几次」,不是一个状态位)。 */ + select_mask(1u << 1); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(INA226_RAIL)); + TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_ambiguous()); +} + int main(void) { UNITY_BEGIN(); @@ -132,5 +231,7 @@ int main(void) RUN_TEST(test_transient_nak_then_recovers); RUN_TEST(test_partial_register_length_rejected); RUN_TEST(test_address_out_of_range); + RUN_TEST(test_device_behind_a_closed_channel_naks); + RUN_TEST(test_two_channels_open_at_once_is_counted_as_ambiguous); return UNITY_END(); } diff --git a/firmware/test/test_i2cbus.c b/firmware/test/test_i2cbus.c new file mode 100644 index 0000000..fcf582f --- /dev/null +++ b/firmware/test/test_i2cbus.c @@ -0,0 +1,612 @@ +/* R-0:`i2cbus` —— 带 mux 的 I²C 原子事务层(计划 §4.3)。 + + ⭐ 这一份是 R2 最重要的一个测试文件,因为它守的是**唯一一类不会报错的错**: + + R2 之后,一次「读 CH3 的电压」在总线上是两件事 —— 先写 TCA9548A 选通道, + 再读 0x40 的寄存器。五片刀片完全同构,五片上的 INA226 都是 0x40。 + 所以通道选错时:地址对、应答正常、寄存器号对、数值在量程内、 + 还会跟着负载变化 —— **读到的是另一台手机的数据,而每一层都返回 HAL_OK。** + + 这个文件里的每一条用例都是在证明「这一类错发生时有人会红」。 + 凡是只能靠 `i2cbus` 自己的影子变量看出来的事,一律再补一条**数据层面**的断言: + 影子变量说自己对不算数,要让假器件吐出另一片的读数才算数。 + + 假板子的布置(与真板一致): + · 0x70 mux,`MUX_RST#` 接 BOARD_PIN_MUX_RST_N; + · 0x43 主干上的输入侧 INA226(全关时唯一还看得见的 INA226); + · 0x40 通道 0~4 各一片 —— 五片同址,正是 D8 的设计。 + 五片的母线电压预置成**五个都很正常的电池电压**(3.75 / 4.20 / 3.80 / 4.00 / 4.10 V): + 选错通道时读回来的值不会超量程、不会是 0、也不会是 0xFFFF, + 所以「加个范围检查就能发现」这条退路在这块板上是不存在的。 */ +#include "i2cbus.h" + +#include "board_pins.h" +#include "hal_gpio.h" +#include "hal_i2c.h" +#include "hal_lock.h" +#include "ina226.h" +#include "sim_hal.h" +#include "tca9548a.h" +#include "unity.h" + +#define TRUNK_INA 0x43 +#define BLADE_INA 0x40 +#define N_BLADE 5 + +/* 通道 n 上那片刀片 INA226 的 BUS_V 原始值。bus_mv = raw × 1.25 mV。 */ +static const uint16_t k_raw[N_BLADE] = { 3000, 3360, 3040, 3200, 3280 }; +static const int32_t k_mv[N_BLADE] = { 3750, 4200, 3800, 4000, 4100 }; +#define TRUNK_RAW 9600 /* 12.0 V —— 主干那片量的是 12 V 母线 */ +#define TRUNK_MV 12000 + +/* ---- 脚手架 -------------------------------------------------------------- */ + +typedef struct { + uint8_t addr; + uint32_t calls; + int32_t mv; + hal_err_t inner; /* 嵌套那条用例用 */ +} ctx_t; + +/* 事务正文:读一次母线电压。**只调 hal_i2c_\***,通道是上层选好的。 */ +static hal_err_t cb_read_mv(void *arg) +{ + ctx_t *c = (ctx_t *)arg; + c->calls++; + return ina226_read_bus_mv(c->addr, &c->mv); +} + +/* 在事务里调 `i2cbus_recover()` —— 它自己要拿锁、还要睡 1 ms。 */ +static hal_err_t cb_recover_inside(void *arg) +{ + ctx_t *c = (ctx_t *)arg; + c->calls++; + c->inner = i2cbus_recover(); + return HAL_OK; +} + +/* 在事务里再调一次 `i2cbus_xact()` —— 头文件末尾那条警告。 */ +static hal_err_t cb_nested(void *arg) +{ + ctx_t *c = (ctx_t *)arg; + c->calls++; + static ctx_t inner_ctx; + inner_ctx.addr = BLADE_INA; + c->inner = i2cbus_xact(1, 10, cb_read_mv, &inner_ctx); + return HAL_OK; +} + +static void mount(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); + sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N); + + sim_i2c_add(TRUNK_INA, 2); + sim_i2c_set_reg(TRUNK_INA, INA226_REG_BUS_V, TRUNK_RAW); + for (int ch = 0; ch < N_BLADE; ch++) { + sim_i2c_add_ch(ch, BLADE_INA, 2); + sim_i2c_set_reg_ch(ch, BLADE_INA, INA226_REG_BUS_V, k_raw[ch]); + } +} + +static void mount_ready(void) +{ + mount(); + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); +} + +/* 读一路,返回码必须是 HAL_OK。 */ +static int32_t read_blade(int ch) +{ + ctx_t c = { BLADE_INA, 0, -1, HAL_OK }; + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_xact(ch, 10, cb_read_mv, &c)); + TEST_ASSERT_EQUAL_UINT32(1, c.calls); + return c.mv; +} + +void setUp(void) { sim_hal_reset(); } + +/* ⭐ 全文件恒为 0:同一地址同时看得见两片 = 通道开重了。 + 真板上那是两片 INA226 一起应答、读回来是线与 —— 数值正常、还很稳, + 除了这个计数没有任何东西会告诉你。 */ +void tearDown(void) { TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); } + +/* ---- init ---------------------------------------------------------------- */ + +/* init 之后:`MUX_RST#` 是高(不复位)、通道全关、影子 = 主干。 + ⚠️ `sim_i2c_mux_resets()` 这时是 **1**,不是 0:`hal_gpio_config()` 配成输出的 + 那一刻脚上是 0(ESP-IDF 也是这个行为),紧接着才被写成 1。 + 这个窗口只有几条指令、此刻总线上还没有人,是 i2cbus.c 里那段注释认下的代价。 + 后面所有涉及复位次数的断言都从这个基线上算增量。 */ +static void test_init_leaves_the_mux_closed_and_out_of_reset(void) +{ + mount(); + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); + + TEST_ASSERT_EQUAL_INT(I2CBUS_CH_TRUNK, i2cbus_channel()); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_i2c_mux_mask()); + TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch()); + TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(BOARD_PIN_MUX_RST_N)); + TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_mux_resets()); /* 见上面那段注释 */ + + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_selects()); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fails()); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_recoveries()); + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); /* 认件那一段的锁放回去了 */ +} + +/* mux 没认出来照样要 init 完(计划 §5.8:那是运行期降级,不是固件写错)。 + ⚠️ 关键在于**影子必须是 UNKNOWN 而不是主干** —— 认件都失败了, + 凭什么相信器件里的通道是全关的。写成 TRUNK 的话, + 插着的第一笔主干事务会跳过选通,发到一个状态不明的 mux 后面。 */ +static void test_init_survives_a_missing_mux_but_trusts_nothing(void) +{ + mount(); + sim_i2c_set_present(TCA9548A_ADDR, false); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_init()); + TEST_ASSERT_EQUAL_INT(I2CBUS_CH_UNKNOWN, i2cbus_channel()); + + /* 仍然可用:事务会去试选通道,失败了如实往上报。 */ + ctx_t c = { BLADE_INA, 0, -1, HAL_OK }; + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(0, 10, cb_read_mv, &c)); + TEST_ASSERT_EQUAL_UINT32(0, c.calls); /* 选通道没成,正文一次都不许跑 */ +} + +/* ---- ⭐ 本文件的主项:选谁读谁 ------------------------------------------- */ + +/* ⭐⭐ 验收主项。五片刀片同址 0x40,`i2cbus_xact(ch, ...)` 必须选谁读谁。 + 这条挂掉 = R2 的整条监控链在报别人的数据。 */ +static void test_xact_reads_the_blade_you_asked_for(void) +{ + mount_ready(); + + for (int ch = 0; ch < N_BLADE; ch++) { + TEST_ASSERT_EQUAL_INT32(k_mv[ch], read_blade(ch)); + TEST_ASSERT_EQUAL_INT(ch, sim_i2c_mux_ch()); /* 线上真的切过去了 */ + TEST_ASSERT_EQUAL_INT(ch, i2cbus_channel()); + } + /* 倒着再来一遍:证明上面不是「碰巧按顺序自增」。 */ + for (int ch = N_BLADE - 1; ch >= 0; ch--) { + TEST_ASSERT_EQUAL_INT32(k_mv[ch], read_blade(ch)); + } +} + +/* ⭐⭐ 反证:**绕过 `i2cbus` 直接读会读到另一台手机的数据,而且一切正常。** + + 这是 R2 固件最危险的错误形态,也是 §4.3 那条「应用层不许直接调 hal_i2c_*」 + 规矩的全部理由。剧本就是最自然的那一种写错: + + 1. 巡检刚读完 CH2(通道 1),mux 停在通道 1; + 2. 有人写了 `ina226_read_bus_mv(0x40, &mv)` —— 他心里想的是 CH1, + 毕竟 R1 时代这么写了一年,0x40 就是「那片 INA226」; + 3. 返回 HAL_OK,拿到 4200 mV。 + + 4200 mV 是一个**完全正常**的电池电压:在量程内、在告警阈值内、 + 会随负载变化、多读几次还很稳。没有任何一层会报错: + · HAL 不会 —— 器件应答了; + · 驱动不会 —— 寄存器号对、转换公式对; + · 安全判据不会 —— 3.5~4.2 V 是合法区间; + · `sim_i2c_ambiguous()` 也不会 —— 只有一片在应答,它就是"对"的那一片。 + 唯一能发现这件事的办法,是像下面这样把两片的值预置成不同的数去对。 + + ⚠️ 这条用例**证明的是错误存在且沉默**,不是在验收某个正确行为 —— + 所以它断言的是「读到的是 CH2 的值,不是 CH1 的值」。 + 哪天它变红了,说明有人给底层加了通道校验,那是好事,改这条用例即可。 */ +static void test_bypassing_i2cbus_silently_reads_the_wrong_phone(void) +{ + mount_ready(); + + /* 1. 巡检读了 CH2(通道 1)。 */ + TEST_ASSERT_EQUAL_INT32(k_mv[1], read_blade(1)); + TEST_ASSERT_EQUAL_INT(1, sim_i2c_mux_ch()); + + /* 2~3. 有人以为自己在读 CH1(通道 0),直接调驱动。 */ + int32_t mv = -1; + const hal_err_t err = ina226_read_bus_mv(BLADE_INA, &mv); + + TEST_ASSERT_EQUAL_INT(HAL_OK, err); /* ★ 不报错 */ + TEST_ASSERT_EQUAL_INT32(k_mv[1], mv); /* ★ 拿到的是 CH2 的 4200 mV */ + TEST_ASSERT_NOT_EQUAL(k_mv[0], mv); /* ★ 不是他以为的 CH1 */ + TEST_ASSERT_TRUE(mv >= 3500 && mv <= 4200); /* ★ 而且落在完全合法的区间里 */ + + /* 同一件事换个方向说:通道停在哪,0x40 就是哪一片。 */ + TEST_ASSERT_EQUAL_INT32(k_mv[3], read_blade(3)); + TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_bus_mv(BLADE_INA, &mv)); + TEST_ASSERT_EQUAL_INT32(k_mv[3], mv); +} + +/* 主干也是一个「通道」:全关的时候 0x43 看得见、0x40 一片都看不见。 + 写成「主干 = 不选通道」(也就是保持上一次的通道)的话, + 读输入侧 0x43 会在某片刀片还挂着的支路上进行 —— 0x43 在主干上依然应答, + 所以**照样不报错**,只是这时候整条总线上同时挂着一片 0x40, + 离「通道开重了」只差一个人手滑。 */ +static void test_trunk_is_a_channel_too(void) +{ + mount_ready(); + + TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2)); /* 先把通道支到 2 */ + + ctx_t c = { TRUNK_INA, 0, -1, HAL_OK }; + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_xact(I2CBUS_CH_TRUNK, 10, cb_read_mv, &c)); + TEST_ASSERT_EQUAL_INT32(TRUNK_MV, c.mv); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_i2c_mux_mask()); /* ★ 支路真的全断开了 */ + TEST_ASSERT_EQUAL_INT(I2CBUS_CH_TRUNK, i2cbus_channel()); + + /* 这时候任何一片刀片的 0x40 都该是不应答的。 */ + int32_t mv = -1; + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_read_bus_mv(BLADE_INA, &mv)); +} + +/* ---- 影子:省事务,但省出来的不变量要守住 -------------------------------- */ + +/* 同一个通道连着两次事务,第二次不重发选通字节。 + §5.5 的账就靠这个:t_safety 每 20 ms 一圈五路,无条件重发一圈多 5 次事务。 */ +static void test_a_second_xact_on_the_same_channel_skips_the_select(void) +{ + mount_ready(); + sim_i2c_dev_t *mux = sim_i2c_dev(TCA9548A_ADDR); + TEST_ASSERT_NOT_NULL(mux); + + TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2)); + TEST_ASSERT_EQUAL_UINT32(1, i2cbus_selects()); + const uint32_t n_write = mux->n_write; + + TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2)); + TEST_ASSERT_EQUAL_UINT32(1, i2cbus_selects()); /* ★ 没有 +1 */ + TEST_ASSERT_EQUAL_UINT32(n_write, mux->n_write); /* ★ mux 上一个字节都没多收 */ + + /* 换一路就必须重发。 */ + TEST_ASSERT_EQUAL_INT32(k_mv[3], read_blade(3)); + TEST_ASSERT_EQUAL_UINT32(2, i2cbus_selects()); +} + +/* ⭐⭐ 选通失败必须让影子作废。 + + 剧本: + 1. 读 CH1(通道 0)成功,影子 = 0; + 2. 去读 CH2(通道 1),选通字节被 NAK 掉 —— 失败的写**不改变器件状态**, + mux 还停在通道 0(这条事实在 test_tca9548a.c 里单独钉过); + 3. 现实里紧接着发生什么是**不知道**的:mux 可能自己复位了、 + 可能那一笔写其实落进去了只是 ACK 丢了、也可能另一次毛刺把它挪到了别处。 + 这里用一次裸写把它挪到通道 1 来代表「不知道」的一个具体实例; + 4. 再去读 CH1(通道 0)。 + + 影子作废了:第 4 步重发选通字节 → 读回 CH1 的 3750 mV。 + 影子没作废(把 `s_ch = I2CBUS_CH_UNKNOWN` 那一行删掉):影子还是 0、命中、 + **跳过选通** → 事务发到通道 1 上 → 读回 4200 mV,返回 HAL_OK。 + 那正是本文件开头那类「读到另一台手机」的错,只不过是固件自己制造的。 */ +static void test_a_failed_select_invalidates_the_shadow(void) +{ + mount_ready(); + + /* 1 */ + TEST_ASSERT_EQUAL_INT32(k_mv[0], read_blade(0)); + TEST_ASSERT_EQUAL_UINT32(1, i2cbus_selects()); + + /* 2 */ + sim_i2c_inject_nak(TCA9548A_ADDR, 1); + ctx_t c = { BLADE_INA, 0, -1, HAL_OK }; + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(1, 10, cb_read_mv, &c)); + TEST_ASSERT_EQUAL_UINT32(0, c.calls); /* 选通没成,正文一次都不许跑 */ + TEST_ASSERT_EQUAL_UINT32(2, i2cbus_selects()); + TEST_ASSERT_EQUAL_UINT32(1, i2cbus_select_fails()); + TEST_ASSERT_EQUAL_INT(I2CBUS_CH_UNKNOWN, i2cbus_channel()); + TEST_ASSERT_EQUAL_HEX8(0x01, sim_i2c_mux_mask()); /* 器件没被动过,还在通道 0 */ + + /* 3:代表「失败之后通道到底在哪,谁也不知道」。 */ + const uint8_t elsewhere = 0x02; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(TCA9548A_ADDR, &elsewhere, 1)); + TEST_ASSERT_EQUAL_INT(1, sim_i2c_mux_ch()); + + /* 4:必须重发选通,读回来的必须是 CH1 自己的值。 */ + TEST_ASSERT_EQUAL_INT32(k_mv[0], read_blade(0)); + TEST_ASSERT_EQUAL_UINT32(3, i2cbus_selects()); /* ★ 又 +1 = 真的重发了 */ + TEST_ASSERT_EQUAL_INT(0, sim_i2c_mux_ch()); +} + +/* 选通失败时正文一次都不许跑 —— 换个角度再钉一次: + 跑了的话事务会落在**上一次**那条支路上,而那条支路上的器件会照常应答。 + 这也是 `xact` 的返回码契约:「选通道的错误码 —— fn 一次都没被调」。 */ +static void test_the_body_never_runs_on_a_channel_that_was_not_selected(void) +{ + mount_ready(); + TEST_ASSERT_EQUAL_INT32(k_mv[4], read_blade(4)); + + sim_i2c_inject_nak(TCA9548A_ADDR, 1); + ctx_t c = { BLADE_INA, 0, -12345, HAL_OK }; + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(2, 10, cb_read_mv, &c)); + + TEST_ASSERT_EQUAL_UINT32(0, c.calls); + TEST_ASSERT_EQUAL_INT32(-12345, c.mv); /* 调用方的值一个字节没动 */ +} + +/* ---- 连续失败计数:§5.8「mux 失联」的判据读的就是它 ---------------------- */ + +/* ⭐ **连续**失败,成功一次就清零。 + 写成累计量的后果:一次总线毛刺的账会一直留着,板子跑上几天几夜, + `select_fails` 迟早爬过阈值 —— 于是某个凌晨五台手机一起断电, + 而那一刻总线其实是好的。这种误停机在实验室里永远复现不出来。 */ +static void test_fail_streak_is_consecutive_and_one_success_clears_it(void) +{ + mount_ready(); + + sim_i2c_inject_nak(TCA9548A_ADDR, 3); + ctx_t c = { BLADE_INA, 0, -1, HAL_OK }; + for (uint32_t i = 1; i <= 3; i++) { + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(0, 10, cb_read_mv, &c)); + TEST_ASSERT_EQUAL_UINT32(i, i2cbus_select_fail_streak()); + TEST_ASSERT_EQUAL_UINT32(i, i2cbus_select_fails()); + } + + /* 第 4 次总线好了。 */ + TEST_ASSERT_EQUAL_INT32(k_mv[0], read_blade(0)); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); /* ★ 清零 */ + TEST_ASSERT_EQUAL_UINT32(3, i2cbus_select_fails()); /* 累计量不清零 */ + + /* 再来一次失败:从 1 开始重新数,不是从 4。 */ + sim_i2c_inject_nak(TCA9548A_ADDR, 1); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(1, 10, cb_read_mv, &c)); + TEST_ASSERT_EQUAL_UINT32(1, i2cbus_select_fail_streak()); + TEST_ASSERT_EQUAL_UINT32(4, i2cbus_select_fails()); +} + +/* 影子命中的那些事务**不算**成功的选通,所以也不该去清 streak —— + 但它们同样不该去累加。这条守的是「反复读同一路时 streak 冻在原地」: + 写成「每次 xact 都清零」的话,mux 失联判据永远等不到阈值。 */ +static void test_a_shadow_hit_neither_counts_nor_clears(void) +{ + mount_ready(); + + TEST_ASSERT_EQUAL_INT32(k_mv[0], read_blade(0)); /* 成功一次,streak = 0 */ + sim_i2c_inject_nak(TCA9548A_ADDR, 1); + ctx_t c = { BLADE_INA, 0, -1, HAL_OK }; + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(1, 10, cb_read_mv, &c)); + TEST_ASSERT_EQUAL_UINT32(1, i2cbus_select_fail_streak()); + + /* 失败之后影子是 UNKNOWN,所以下一次同通道**不会**命中; + 这里先成功一次把影子立起来,再连读两次看 streak 与 selects 都不动。 */ + TEST_ASSERT_EQUAL_INT32(k_mv[1], read_blade(1)); + const uint32_t sel = i2cbus_selects(); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); + TEST_ASSERT_EQUAL_INT32(k_mv[1], read_blade(1)); + TEST_ASSERT_EQUAL_INT32(k_mv[1], read_blade(1)); + TEST_ASSERT_EQUAL_UINT32(sel, i2cbus_selects()); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); +} + +/* ---- 复位 ---------------------------------------------------------------- */ + +/* ⭐ `i2cbus_recover()`:`MUX_RST#` 真的被拉低过,之后影子作废、通道全关。 + 只断言 `i2cbus_channel() == UNKNOWN` 是不够的 —— 那只是自己说自己对。 + 这里同时断言假 mux 的 `resets` +1(脚真的低过)和掩码归 0(器件真的被复位了)。 + + 不作废影子的后果:复位之后器件里的通道是**全关**的,影子却还记着上一路, + 下一次事务跳过选通,落在全关的总线上 —— 现象是「五片刀片一起失联」, + 而且 `i2cbus_recover()` 本来就是为了抢救失联才调的,会变成越救越死。 */ +static void test_recover_pulses_mux_rst_and_forgets_the_channel(void) +{ + mount_ready(); + TEST_ASSERT_EQUAL_INT32(k_mv[3], read_blade(3)); + TEST_ASSERT_EQUAL_HEX8(0x08, sim_i2c_mux_mask()); + + const uint32_t resets = sim_i2c_mux_resets(); + const uint32_t writes = sim_gpio_writes(BOARD_PIN_MUX_RST_N); + + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_recover()); + + TEST_ASSERT_EQUAL_UINT32(resets + 1, sim_i2c_mux_resets()); /* ★ 脚真低过 */ + TEST_ASSERT_EQUAL_UINT32(writes + 2, sim_gpio_writes(BOARD_PIN_MUX_RST_N)); /* 低再高 */ + TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(BOARD_PIN_MUX_RST_N)); /* 收尾必须放开 */ + TEST_ASSERT_EQUAL_HEX8(0x00, sim_i2c_mux_mask()); /* ★ 通道全关 */ + TEST_ASSERT_EQUAL_INT(I2CBUS_CH_UNKNOWN, i2cbus_channel()); /* ★ 影子作废 */ + TEST_ASSERT_EQUAL_UINT32(1, i2cbus_recoveries()); + + /* 复位之后不重新选通的话,刀片就是全部失联 —— 这就是影子必须作废的代价证明。 */ + int32_t mv = -1; + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_read_bus_mv(BLADE_INA, &mv)); + + /* 而经 i2cbus 走就自动恢复:重发选通、读回本路自己的值。 */ + const uint32_t sel = i2cbus_selects(); + TEST_ASSERT_EQUAL_INT32(k_mv[3], read_blade(3)); + TEST_ASSERT_EQUAL_UINT32(sel + 1, i2cbus_selects()); +} + +/* recover 自己要拿锁、而且中间要睡 1 ms,所以**不许在事务的回调里调** —— + 直接判 HAL_ERR_STATE。不拦的话它会持着总线锁去 `hal_time_delay_ms(1)`, + 把巡检整个钉住;而且那 1 ms 里 `MUX_RST#` 是低的, + 外层事务回来之后会在一个刚被复位(通道全关)的 mux 上继续跑。 */ +static void test_recover_is_rejected_inside_a_transaction(void) +{ + mount_ready(); + TEST_ASSERT_EQUAL_INT32(k_mv[0], read_blade(0)); + + const uint32_t resets = sim_i2c_mux_resets(); + ctx_t c = { BLADE_INA, 0, -1, HAL_OK }; + + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_xact(0, 10, cb_recover_inside, &c)); + TEST_ASSERT_EQUAL_UINT32(1, c.calls); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, c.inner); /* ★ 被挡下来了 */ + TEST_ASSERT_EQUAL_UINT32(resets, sim_i2c_mux_resets()); /* ★ 脚一次都没低过 */ + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_recoveries()); + TEST_ASSERT_EQUAL_HEX8(0x01, sim_i2c_mux_mask()); /* 通道也没被关掉 */ + + /* 事务外面调是合法的。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_recover()); + TEST_ASSERT_EQUAL_UINT32(resets + 1, sim_i2c_mux_resets()); +} + +/* ---- 两条「别把自己锁死」 ------------------------------------------------ */ + +/* ⭐ 嵌套调用返回 HAL_ERR_STATE,而且**不死锁**:外层事务照常跑完、锁照常放回去。 + 不拦的话会在同一个任务里第二次 take 同一把锁 —— sim 上是 HAL_ERR_STATE, + FreeRTOS 上(非递归的 mutex)就是那个任务当场停住, + 现象是 `t_safety` 整个不跑了,而看门狗之外没有任何日志。 */ +static void test_a_nested_xact_is_rejected_and_does_not_deadlock(void) +{ + mount_ready(); + ctx_t c = { BLADE_INA, 0, -1, HAL_OK }; + const uint32_t sel = i2cbus_selects(); + + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_xact(0, 10, cb_nested, &c)); + + TEST_ASSERT_EQUAL_UINT32(1, c.calls); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, c.inner); /* ★ 内层被挡在拿锁之前 */ + TEST_ASSERT_EQUAL_UINT32(sel + 1, i2cbus_selects()); /* 内层一个字节都没发 */ + + /* 锁真的放回去了:拿得到就说明没漏。 */ + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + hal_lock_give(HAL_LOCK_I2C); + + /* 外层结束之后总线恢复正常,不是一次性坏掉。 */ + TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2)); +} + +/* ⭐ 拿不到锁时返回 HAL_ERR_TIMEOUT,而且**什么也没做**。 + 「什么也没做」是调用方(t_safety)敢直接跳过这一周期的前提: + 要是这里已经把通道切过去了、或者把影子改了,跳过这一周期之后 + 下一个拿到锁的人会在一个被人改过的通道上开工 —— 又是一次静默的读错片。 */ +static void test_a_lock_timeout_changes_nothing_at_all(void) +{ + mount_ready(); + sim_i2c_dev_t *mux = sim_i2c_dev(TCA9548A_ADDR); + TEST_ASSERT_NOT_NULL(mux); + + TEST_ASSERT_EQUAL_INT32(k_mv[1], read_blade(1)); /* 通道停在 1 */ + + const uint32_t sel = i2cbus_selects(); + const uint32_t nw = mux->n_write; + const uint8_t mask = sim_i2c_mux_mask(); + const int shadow = i2cbus_channel(); + + /* 1 号任务持着 I²C 锁,0 号任务来跑一次事务。 */ + sim_lock_set_task(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + sim_lock_set_task(0); + + ctx_t c = { BLADE_INA, 0, -777, HAL_OK }; + TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, i2cbus_xact(3, 5, cb_read_mv, &c)); + + TEST_ASSERT_EQUAL_UINT32(0, c.calls); /* ★ 正文没跑 */ + TEST_ASSERT_EQUAL_INT32(-777, c.mv); /* ★ 调用方的值没动 */ + TEST_ASSERT_EQUAL_UINT32(sel, i2cbus_selects()); /* ★ 没发选通 */ + TEST_ASSERT_EQUAL_UINT32(nw, mux->n_write); /* ★ mux 一个字节没收 */ + TEST_ASSERT_EQUAL_HEX8(mask, sim_i2c_mux_mask()); /* ★ 通道没被动 */ + TEST_ASSERT_EQUAL_INT(shadow, i2cbus_channel()); /* ★ 影子没被动 */ + TEST_ASSERT_EQUAL_UINT32(1, i2cbus_lock_timeouts()); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); /* 等锁失败 ≠ 选通失败 */ + + /* 1 号放掉之后一切照旧。 */ + sim_lock_set_task(1); + hal_lock_give(HAL_LOCK_I2C); + sim_lock_set_task(0); + TEST_ASSERT_EQUAL_INT32(k_mv[3], read_blade(3)); +} + +/* ---- 参数与状态 ---------------------------------------------------------- */ + +/* 没 init 就调 → HAL_ERR_STATE,而且一个字节都不许发。 + `hal_gpio_config()` 还没跑过,这时候碰总线等于在 `MUX_RST#` 悬空的情况下开工。 + + ⚠️ **这一条必须是 main() 里的第一条**:`i2cbus` 没有 deinit 接口, + `s_inited` 是文件级 static,`sim_hal_reset()` 管不着它 —— + 一旦前面有任何一条跑过 `i2cbus_init()`,「没 init」这个状态就再也造不出来了。 + (真板上也一样:init 一次之后就没有回头路,所以这不是设计缺陷。) */ +static void test_xact_before_init_is_a_state_error(void) +{ + mount(); /* 挂好器件,但**不** i2cbus_init() */ + sim_i2c_dev_t *mux = sim_i2c_dev(TCA9548A_ADDR); + TEST_ASSERT_NOT_NULL(mux); + + ctx_t c = { BLADE_INA, 0, -1, HAL_OK }; + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, i2cbus_xact(0, 10, cb_read_mv, &c)); + TEST_ASSERT_EQUAL_UINT32(0, c.calls); + TEST_ASSERT_EQUAL_UINT32(0, mux->n_write); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, i2cbus_recover()); +} + +/* 通道号越界要在拿锁之前挡掉。 + ⚠️ 特别是 `I2CBUS_CH_UNKNOWN`(-2):它是一个**内部状态**,不是一个能传进来的通道。 + 放过去的话 `1u << -2` 是未定义行为,而 x86 上多半"正常"跑出一个值来。 */ +static void test_xact_rejects_bad_arguments_before_taking_the_lock(void) +{ + mount_ready(); + sim_i2c_dev_t *mux = sim_i2c_dev(TCA9548A_ADDR); + TEST_ASSERT_NOT_NULL(mux); + const uint32_t nw = mux->n_write; + ctx_t c = { BLADE_INA, 0, -1, HAL_OK }; + + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, i2cbus_xact(I2CBUS_CH_UNKNOWN, 10, cb_read_mv, &c)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, i2cbus_xact(-3, 10, cb_read_mv, &c)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, i2cbus_xact(TCA9548A_CH_COUNT, 10, cb_read_mv, &c)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, i2cbus_xact(0, 10, NULL, &c)); + + TEST_ASSERT_EQUAL_UINT32(0, c.calls); + TEST_ASSERT_EQUAL_UINT32(nw, mux->n_write); + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_lock_timeouts()); +} + +/* `i2cbus_forget_channel()` 只是作废影子,**不碰器件** —— + 它是给 recover 和测试用的。作废之后下一次事务必须重发选通。 */ +static void test_forget_channel_only_drops_the_shadow(void) +{ + mount_ready(); + TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2)); + TEST_ASSERT_EQUAL_UINT32(1, i2cbus_selects()); + + i2cbus_forget_channel(); + TEST_ASSERT_EQUAL_INT(I2CBUS_CH_UNKNOWN, i2cbus_channel()); + TEST_ASSERT_EQUAL_HEX8(0x04, sim_i2c_mux_mask()); /* 器件没被动 */ + + TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2)); + TEST_ASSERT_EQUAL_UINT32(2, i2cbus_selects()); /* 重发了 */ +} + +/* 巡检的形状:五路各一次 xact,来回跑 20 圈。 + 每一圈每一路读回来的都必须是自己那一片,而且**通道永远只开一路**。 + 这一条是前面所有断言的"耐久版"——单次对不代表跑久了还对 + (影子写成 static 局部、计数溢出、掩码累加而不是覆盖,都要跑几圈才现形)。 */ +static void test_a_full_sweep_stays_correct_for_many_rounds(void) +{ + mount_ready(); + + for (int round = 0; round < 20; round++) { + for (int ch = 0; ch < N_BLADE; ch++) { + TEST_ASSERT_EQUAL_INT32(k_mv[ch], read_blade(ch)); + TEST_ASSERT_EQUAL_INT(ch, sim_i2c_mux_ch()); /* 恰好一路,不是 -2 */ + } + } + /* 一圈五次选通,20 圈 = 100 次;没有多也没有少。 */ + TEST_ASSERT_EQUAL_UINT32(100, i2cbus_selects()); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fails()); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_lock_timeouts()); +} + +int main(void) +{ + UNITY_BEGIN(); + /* ⚠️ 这一条必须排在最前面,理由见它自己的注释(i2cbus 没有 deinit)。 */ + RUN_TEST(test_xact_before_init_is_a_state_error); + RUN_TEST(test_init_leaves_the_mux_closed_and_out_of_reset); + RUN_TEST(test_init_survives_a_missing_mux_but_trusts_nothing); + RUN_TEST(test_xact_reads_the_blade_you_asked_for); + RUN_TEST(test_bypassing_i2cbus_silently_reads_the_wrong_phone); + RUN_TEST(test_trunk_is_a_channel_too); + RUN_TEST(test_a_second_xact_on_the_same_channel_skips_the_select); + RUN_TEST(test_a_failed_select_invalidates_the_shadow); + RUN_TEST(test_the_body_never_runs_on_a_channel_that_was_not_selected); + RUN_TEST(test_fail_streak_is_consecutive_and_one_success_clears_it); + RUN_TEST(test_a_shadow_hit_neither_counts_nor_clears); + RUN_TEST(test_recover_pulses_mux_rst_and_forgets_the_channel); + RUN_TEST(test_recover_is_rejected_inside_a_transaction); + RUN_TEST(test_a_nested_xact_is_rejected_and_does_not_deadlock); + RUN_TEST(test_a_lock_timeout_changes_nothing_at_all); + RUN_TEST(test_xact_rejects_bad_arguments_before_taking_the_lock); + RUN_TEST(test_forget_channel_only_drops_the_shadow); + RUN_TEST(test_a_full_sweep_stays_correct_for_many_rounds); + return UNITY_END(); +} diff --git a/firmware/test/test_ina226.c b/firmware/test/test_ina226.c index 3404402..27e6a34 100644 --- a/firmware/test/test_ina226.c +++ b/firmware/test/test_ina226.c @@ -1,10 +1,28 @@ -/* A3:INA226 驱动 + 六片地址表。 - 两个验收主项:①六个地址就是 0x40/41/44/45/42/43;②通道号 ≠ 地址低位。 */ +/* A3:INA226 驱动 + 地址表(**R2 版**,计划 §5.2)。 + + 两个验收主项: + ① 总线上只有**两个** INA226 地址:刀片那五片全是 `0x40`,底板输入侧 `U13` 是 `0x43`; + ② ⭐ **五路同址不是 bug** —— 区分是哪一路的是 **mux 通道**,不是地址。 + + ---- R1 → R2 换掉了什么 -------------------------------------------------- + R1 是「六片六个地址、一条总线」,于是那时的验收主项是「通道号 ≠ 地址低位」, + 拦的是有人写出 `0x40 + rail`。R2 把五路做成五片完全同构的刀片(D8), + 地址 strap 全部焊死在 0x40,**`0x40 + rail` 这行代码现在写出来会当场读不到器件**, + 一上板就暴露。真正接替它的坑是通道:选错通道读到的是**另一台手机**的电压电流, + 量纲、范围、变化趋势全都正常,没有任何一层会报错。 + 所以那条用例在这里被换成了「五路地址必须全相同」, + 而「五路通道必须两两不同」那一半在 `test_rail_cfg.c` 里。 */ #include "ina226.h" #include "sim_hal.h" #include "unity.h" -void setUp(void) { sim_hal_reset(); } +void setUp(void) +{ + sim_hal_reset(); + for (uint8_t a = 0x40; a <= 0x4F; a++) { /* 对调登记是驱动里的静态表,不清会串到别的用例 */ + ina226_set_reversed(a, false); + } +} void tearDown(void) {} /* 挂一片假 INA226 并预置 ID,返回地址。 */ @@ -19,41 +37,58 @@ static uint8_t mount(uint8_t addr) /* ---- 地址表 -------------------------------------------------------------- */ -/* ⭐ 验收主项一:设计文档 §8.2 的六个地址,逐个钉死。 */ -static void test_addr_table_is_the_one_from_8_2(void) +/* ⭐ 验收主项一:总线上出现过的 INA226 地址只有两个,逐个钉死。 + 出处:刀片 `U3` 的 pin1/pin2(A1/A0)都接 GND ⇒ 0x40;底板 `U13` pin1=GND、 + pin2=SCL ⇒ 0x43(`.claude/r2/_blade_pcb_nets.json` / `_base_pcb_nets.json`)。 */ +static void test_addr_list_is_the_two_from_5_2(void) { - TEST_ASSERT_EQUAL_HEX8(0x40, ina226_rail_addr(1)); - TEST_ASSERT_EQUAL_HEX8(0x41, ina226_rail_addr(2)); - TEST_ASSERT_EQUAL_HEX8(0x44, ina226_rail_addr(3)); - TEST_ASSERT_EQUAL_HEX8(0x45, ina226_rail_addr(4)); - TEST_ASSERT_EQUAL_HEX8(0x42, ina226_rail_addr(5)); + static const uint8_t want[INA226_ADDR_COUNT] = { 0x40, 0x43 }; + TEST_ASSERT_EQUAL_INT_MESSAGE(2, INA226_ADDR_COUNT, "R2 只有两个地址,不是六个"); + TEST_ASSERT_EQUAL_HEX8_ARRAY(want, ina226_addr_list, INA226_ADDR_COUNT); + TEST_ASSERT_EQUAL_HEX8(0x40, INA226_ADDR_RAIL); TEST_ASSERT_EQUAL_HEX8(0x43, INA226_ADDR_INPUT); - static const uint8_t want[INA226_COUNT] = { 0x40, 0x41, 0x44, 0x45, 0x42, 0x43 }; - TEST_ASSERT_EQUAL_HEX8_ARRAY(want, ina226_all_addr, INA226_COUNT); + /* **物理片数仍然是 6**。两个数不一样,正是 R2 的全部意思 —— + 把 `INA226_COUNT` 当成「扫描要扫几个地址」用,就会去扫 0x44/0x45 那种 + R2 板上根本不存在的地址,然后得出「四片 INA226 失联」的结论。 */ + TEST_ASSERT_EQUAL_INT(6, INA226_COUNT); + TEST_ASSERT_EQUAL_INT(5, INA226_RAIL_COUNT); } -/* ⭐ 验收主项二:`0x40 + ch` 这行代码一定会被写出来(需求 §10.4), - 这条测试就是拦它的 —— 五路里有三路对不上,把表改成算式必红。 */ -static void test_channel_index_is_not_the_address_low_bits(void) +/* ⭐ 验收主项二:**五路的地址全是 0x40 —— 这不是 bug。** + 五片刀片完全同构(D8),地址 strap 焊死;区分是哪一路的是 + `rail_cfg(rail)->mux_ch`,光有地址是选不中任何一路的。 + + ⚠️ 写错了会怎样:把这张表改回「每路一个地址」(R1 的记忆), + CH2~CH5 一上板就读不到器件 —— 这一半会当场暴露,不可怕。 + 可怕的是反过来:既然五路同址,任何「拿到地址就直接读」的代码 + 读的都是**上一次选中的那一路**,五路数字全部正常、还很稳,只是有几路是同一份。 + 这条用例只能钉住地址这一半,另一半(通道两两不同)在 test_rail_cfg.c 里。 */ +static void test_all_five_rails_share_one_address(void) { - int mismatches = 0; for (int rail = 1; rail <= INA226_RAIL_COUNT; rail++) { - uint8_t naive = (uint8_t)(0x40 + (rail - 1)); - if (ina226_rail_addr(rail) != naive) { - mismatches++; + TEST_ASSERT_EQUAL_HEX8_MESSAGE(INA226_ADDR_RAIL, ina226_rail_addr(rail), + "五路同址是设计(D8),不是笔误"); + } + /* 输入侧那片**必须**不同址:它挂主干,和刀片那片同时可见。 */ + TEST_ASSERT_NOT_EQUAL(INA226_ADDR_RAIL, INA226_ADDR_INPUT); + + /* R1 那行 `0x40 + rail` 现在对 CH1 仍然「碰巧对」,对其余四路全错 —— + 写下来是为了让「拿 CH1 试一下没问题」这条老路在这里就断掉。 */ + int naive_ok = 0; + for (int rail = 1; rail <= INA226_RAIL_COUNT; rail++) { + if (ina226_rail_addr(rail) == (uint8_t)(0x40 + rail - 1)) { + naive_ok++; } } - TEST_ASSERT_EQUAL_INT_MESSAGE(3, mismatches, "CH3/CH4/CH5 三路必须与顺排不同"); - - /* 反过来也钉一遍:拿地址低位算回通道号同样会算错。 */ - TEST_ASSERT_NOT_EQUAL(0x42, ina226_rail_addr(3)); /* CH3 是 0x44,不是 0x42 */ - TEST_ASSERT_NOT_EQUAL(0x44, ina226_rail_addr(5)); /* CH5 是 0x42,不是 0x44 */ + TEST_ASSERT_EQUAL_INT_MESSAGE(1, naive_ok, "只有 CH1 碰巧和 0x40+ch 对得上"); } +/* 撞车检查的对象在 R2 里换了一批:主干上的 DAC63204(0x4A/0x4B) 与 TCA9548A(0x70), + 加上刀片支路上的 PCA9555(0x20)。**0x48 的 LM75 不在里面** —— R2 没有载板。 */ static void test_addr_table_has_no_collisions(void) { - TEST_ASSERT_TRUE(ina226_addr_table_ok()); /* 含与 DAC/PCA9555/LM75 的撞车检查 */ + TEST_ASSERT_TRUE(ina226_addr_table_ok()); } /* ---- 认件 ---------------------------------------------------------------- */ @@ -66,28 +101,37 @@ static void test_probe_accepts_real_ina226(void) /* 应答了但 ID 不对 = 贴错件或地址跳线焊错,必须和「没这个器件」区分开。 */ static void test_probe_rejects_wrong_die_id(void) { - uint8_t a = mount(0x41); + uint8_t a = mount(INA226_ADDR_RAIL); sim_i2c_set_reg(a, INA226_REG_DIE_ID, 0x1234); TEST_ASSERT_EQUAL_INT(HAL_ERR_BUS, ina226_probe(a)); } static void test_probe_absent_device_naks(void) { - mount(0x40); - TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x44)); /* 没挂 */ + mount(INA226_ADDR_RAIL); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(INA226_ADDR_INPUT)); /* 没挂 */ sim_i2c_set_present(0x40, false); TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x40)); /* 失联 */ } -/* 需求 §12.1 步 2:六片全应答。 */ -static void test_all_six_answer_scan(void) +/* 需求 §12.1 步 2 的 R2 版:扫描只会扫到**两个**地址。 + ⚠️ 这里挂的两片都在主干上,因为本文件测的是驱动、不带 mux; + 真板上 0x40 那片在支路后面,**只有对应通道开着的时候才应答** —— + 所以「扫一圈看到几片 INA226」在 R2 上回答不了「五路都在不在」。 + 那件事要靠逐通道扫(i2cbus + slot),不在本文件的范围里。 */ +static void test_only_two_addresses_answer_a_plain_scan(void) { - for (int i = 0; i < INA226_COUNT; i++) { - mount(ina226_all_addr[i]); + for (int i = 0; i < INA226_ADDR_COUNT; i++) { + mount(ina226_addr_list[i]); } - for (int i = 0; i < INA226_COUNT; i++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_probe(ina226_all_addr[i])); + for (int i = 0; i < INA226_ADDR_COUNT; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_probe(ina226_addr_list[i])); } + /* R1 的那四个地址在 R2 板上根本不存在 —— 照着老表扫会得出「四片失联」。 */ + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x41)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x42)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x44)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x45)); } static void test_bus_not_initialised_propagates(void) @@ -101,7 +145,7 @@ static void test_bus_not_initialised_propagates(void) static void test_init_writes_config_and_calibration(void) { - uint8_t a = mount(0x45); + uint8_t a = mount(INA226_ADDR_INPUT); TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_init(a)); TEST_ASSERT_EQUAL_HEX16(INA226_CONFIG, sim_i2c_get_reg(a, INA226_REG_CONFIG)); TEST_ASSERT_EQUAL_HEX16(2048, sim_i2c_get_reg(a, INA226_REG_CALIB)); @@ -130,7 +174,7 @@ static void test_check_catches_lost_calibration(void) /* 满载一路:4.010 V / 4 A / 16 W。5 mΩ + 0.5 mA LSB 下分流码与电流码同值。 */ static void test_readings_at_full_load(void) { - uint8_t a = mount(0x44); + uint8_t a = mount(INA226_ADDR_RAIL); sim_i2c_set_reg(a, INA226_REG_BUS_V, 3208); /* ×1.25 mV */ sim_i2c_set_reg(a, INA226_REG_SHUNT_V, 8000); /* ×2.5 µV = 20 mV */ sim_i2c_set_reg(a, INA226_REG_CURRENT, 8000); /* ×0.5 mA */ @@ -148,7 +192,7 @@ static void test_readings_at_full_load(void) 而判据是「电流为负」,于是保护永远不触发。 */ static void test_reverse_current_is_negative(void) { - uint8_t a = mount(0x42); + uint8_t a = mount(INA226_ADDR_RAIL); sim_i2c_set_reg(a, INA226_REG_CURRENT, (uint16_t)(int16_t)-120); /* −60 mA */ sim_i2c_set_reg(a, INA226_REG_SHUNT_V, (uint16_t)(int16_t)-120); /* −300 µV */ @@ -177,7 +221,7 @@ static void test_bus_voltage_rounding(void) 靠自增省事的写法在这里会绿,到板上四个量全读成同一个。 */ static void test_pointer_is_rewritten_before_every_read(void) { - uint8_t a = mount(0x41); + uint8_t a = mount(INA226_ADDR_RAIL); ina226_sample_t s = { 0, 0, 0, 0 }; sim_i2c_dev(a)->n_write = 0; sim_i2c_dev(a)->n_read = 0; @@ -204,7 +248,7 @@ static void test_read_vi_uses_two_transactions(void) /* 器件中途失联:错误必须一路传上去,不能返回一个看着正常的 0。 */ static void test_read_propagates_nak(void) { - uint8_t a = mount(0x45); + uint8_t a = mount(INA226_ADDR_INPUT); sim_i2c_inject_nak(a, -1); ina226_vi_t vi = { 123, 456 }; TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_read_vi(a, &vi)); @@ -218,16 +262,81 @@ static void test_null_output_rejected(void) TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, ina226_read_bus_mv(0x40, NULL)); } +/* ---- Kelvin 对调接的片子(R2 底板 U13)--------------------------------- */ + +/* ⭐ R2 底板 U13 的 IN+ 接下游:正常供电时芯片读出负值。登记后驱动取反, + 调用方看到正值 —— 否则 SW-8 会把正常供电当成反灌。母线电压 / 功率是无符号量,不动。 */ +static void test_reversed_chip_reads_positive_in_normal_flow(void) +{ + uint8_t a = mount(INA226_ADDR_INPUT); + ina226_set_reversed(a, true); + TEST_ASSERT_TRUE(ina226_is_reversed(a)); + sim_i2c_set_reg(a, INA226_REG_BUS_V, 9600); /* 12.000 V */ + sim_i2c_set_reg(a, INA226_REG_SHUNT_V, (uint16_t)(int16_t)-8000); /* 芯片读 −20 mV */ + sim_i2c_set_reg(a, INA226_REG_CURRENT, (uint16_t)(int16_t)-8000); /* 芯片读 −4 A */ + sim_i2c_set_reg(a, INA226_REG_POWER, 3840); /* 48 W,恒正 */ + + ina226_sample_t s = { 0, 0, 0, 0 }; + TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_all(a, &s)); + TEST_ASSERT_EQUAL_INT32(12000, s.bus_mv); + TEST_ASSERT_EQUAL_INT32(20000, s.shunt_uv); + TEST_ASSERT_EQUAL_INT32(4000000, s.current_ua); + TEST_ASSERT_EQUAL_INT32(48000, s.power_mw); + + ina226_vi_t vi = { 0, 0 }; + TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_vi(a, &vi)); + TEST_ASSERT_EQUAL_INT32(4000000, vi.current_ua); +} + +/* 对调片子上真反灌:芯片读正值 → 驱动给出负值,SW-8 照样触发。 + 没登记的片子(CH 路)不受影响。 */ +static void test_reversed_only_affects_that_chip(void) +{ + uint8_t in = mount(INA226_ADDR_INPUT); + uint8_t ch = mount(0x40); + ina226_set_reversed(in, true); + sim_i2c_set_reg(in, INA226_REG_CURRENT, 120); /* 芯片读 +60 mA = 反灌 */ + sim_i2c_set_reg(ch, INA226_REG_CURRENT, 120); + + int32_t ua_in = 0, ua_ch = 0; + TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_current_ua(in, &ua_in)); + TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_current_ua(ch, &ua_ch)); + TEST_ASSERT_EQUAL_INT32(-60000, ua_in); + TEST_ASSERT_EQUAL_INT32(60000, ua_ch); + TEST_ASSERT_FALSE(ina226_is_reversed(ch)); +} + +/* 取消登记恢复原样;地址越界(不是 INA226 的 0x40~0x4F)的调用被忽略、不越界写。 */ +static void test_reversed_can_be_cleared_and_ignores_bad_addr(void) +{ + uint8_t a = mount(INA226_ADDR_RAIL); + ina226_set_reversed(a, true); + ina226_set_reversed(a, false); + TEST_ASSERT_FALSE(ina226_is_reversed(a)); + sim_i2c_set_reg(a, INA226_REG_CURRENT, (uint16_t)(int16_t)-120); + int32_t ua = 0; + TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_current_ua(a, &ua)); + TEST_ASSERT_EQUAL_INT32(-60000, ua); + + ina226_set_reversed(0x20, true); /* PCA9555 的地址 */ + ina226_set_reversed(0x50, true); + TEST_ASSERT_FALSE(ina226_is_reversed(0x20)); + TEST_ASSERT_FALSE(ina226_is_reversed(0x50)); + for (uint8_t b = 0x40; b <= 0x4F; b++) { + TEST_ASSERT_FALSE(ina226_is_reversed(b)); + } +} + int main(void) { UNITY_BEGIN(); - RUN_TEST(test_addr_table_is_the_one_from_8_2); - RUN_TEST(test_channel_index_is_not_the_address_low_bits); + RUN_TEST(test_addr_list_is_the_two_from_5_2); + RUN_TEST(test_all_five_rails_share_one_address); RUN_TEST(test_addr_table_has_no_collisions); RUN_TEST(test_probe_accepts_real_ina226); RUN_TEST(test_probe_rejects_wrong_die_id); RUN_TEST(test_probe_absent_device_naks); - RUN_TEST(test_all_six_answer_scan); + RUN_TEST(test_only_two_addresses_answer_a_plain_scan); RUN_TEST(test_bus_not_initialised_propagates); RUN_TEST(test_init_writes_config_and_calibration); RUN_TEST(test_check_catches_lost_calibration); @@ -238,5 +347,8 @@ int main(void) RUN_TEST(test_read_vi_uses_two_transactions); RUN_TEST(test_read_propagates_nak); RUN_TEST(test_null_output_rejected); + RUN_TEST(test_reversed_chip_reads_positive_in_normal_flow); + RUN_TEST(test_reversed_only_affects_that_chip); + RUN_TEST(test_reversed_can_be_cleared_and_ignores_bad_addr); return UNITY_END(); } diff --git a/firmware/test/test_pca9555.c b/firmware/test/test_pca9555.c index c22388f..d84c777 100644 --- a/firmware/test/test_pca9555.c +++ b/firmware/test/test_pca9555.c @@ -1,230 +1,578 @@ -/* A6:PCA9555 驱动。 - 验收主项:**方向寄存器永远不会把 Port0[7] / Port1[7:5] 配成输出** —— - 那四个脚在板上直接接地,配成输出等于对地短路,而真器件既不报错也不 NAK。 - 第二主项:FLT / PG / BUCK_PG / INA_ALERT 三种极性一个都不许写反。 */ +/* A6(**R2 版**):刀片 PCA9555 驱动 —— 每片刀片一颗,五片同址 0x20, + 靠底板 TCA9548A 分通道;**P0.0~P0.4 是输出**。 + + R1 那版测试的前提(一颗、16 脚全是输入)一条都不成立了,所以整篇重写。 + + 本文件要证明的三件「真会出事」的事,按危险程度排: + + ⭐① **初始化顺序就是安全本身。** Output Port 复位值是 0xFF,Config 复位值 + 也是 0xFF(全输入)。只要先改方向、后写锁存器,那一刻 P0.0~P0.4 一起 + 输出 1 = 三个键全按下 + EDL 短接 + VBUS 上电,五台手机一起被按住电源键, + **而 I²C 从头到尾一切正常**:不 NAK、不超时、回读也对。 + 拦网是 `sim_pca9555_unsafe_enable()`,必须恒为 0。 + ⭐② **接地脚永远不许被配成输出。** P0.5~7 / P1.4~7 在刀片上直接接地、不串 + 限流电阻,配成输出就是对地短路 —— 真器件不报错、不 NAK,只会发烫。 + Port1 更严:bit0~3 接的是 eFuse / Buck / INA 的**开漏输出**,配成输出 + 是两个驱动器对打,一位都不许。 + ⭐③ **五片的影子互不串。** 这是 R2 新增的失败形态:影子记到了另一片上, + 下一次写会把那一片的键"顺手"改掉,而五路电压电流全部正常。 + + 断言一律打在假器件的可观测面(`sim_pca9555_*`)上。对着 `pca9555_shadow()` + 断言等于自己证明自己 —— 影子和线上真实发生的事分家,正是这里要抓的错。 + + ⚠️ 本驱动**不碰 mux**(契约:通道由调用方先选好),所以测试自己拿 + `tca9548a_select()` 选通道。约定 idx i ↔ 通道 i(只是本文件的约定, + 路号 → 通道的真表在 `rail_cfg.c`,那是 `test_slot.c` 的事)。 */ #include "pca9555.h" + +#include "board_pins.h" +#include "i2cbus.h" #include "sim_hal.h" +#include "tca9548a.h" #include "unity.h" -void setUp(void) { sim_hal_reset(); } -void tearDown(void) {} +/* ---- 脚手架 -------------------------------------------------------------- */ static void mount(void) { - sim_pca9555_attach(PCA9555_ADDR); + sim_hal_reset(); TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); + sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N); + for (int i = 0; i < PCA9555_SLOT_COUNT; i++) { + sim_pca9555_attach_ch(i, i, PCA9555_ADDR); + } + /* 驱动的影子是文件级静态,跨用例不会自己清 —— 每条用例都从「刚上电」开始。 */ + pca9555_shadow_reset(); + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); /* 顺带把通道全关 */ } -/* 挂好并 init 完,外部电平停在静息值(全高 = 无故障、全部良好)。 */ -static void mount_ready(void) +void setUp(void) { mount(); } +void tearDown(void) {} + +/* 选到第 idx 片。驱动的契约就是「调用方先选好」,这里代的是 `slot.c` 的班。 */ +static void sel(int idx) { TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, idx)); } + +static void init_all(void) { - mount(); - TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_init()); + for (int i = 0; i < PCA9555_SLOT_COUNT; i++) { + sel(i); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_init(i)); + } } -/* ---- 验收主项一:接地脚永远是输入 ---------------------------------------- */ - -/* init 之后两个方向寄存器都必须是 0xFF —— 不是「大致全输入」,是逐位。 */ -static void test_init_configures_every_pin_as_input(void) +/* 绕开驱动往当前通道上那片写一个寄存器。用来演示「假器件真的会照出问题」。 */ +static hal_err_t raw_write(uint8_t reg, uint8_t val) { - mount_ready(); - TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_config(0)); - TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_config(1)); + const uint8_t wr[2] = { reg, val }; + return hal_i2c_write(PCA9555_ADDR, wr, sizeof(wr)); +} + +/* ---- ⭐① 初始化顺序 ------------------------------------------------------ */ + +/* 五片全部 init 完,**一次都不许出现「锁存器还是 0xFF 就把 Port0 配成输出」**。 + 写错的现象:五台手机一起被按住电源键 + VBUS 全部上电,持续到下一次写锁存器 + (几十 µs~几 ms),足够让手机认到一次长按。I²C 侧一切正常,日志里什么都没有。 + 把 `pca9555_init()` 里的 CONFIG0 那两句挪到 OUTPUT0 之前,这条立刻红。 */ +static void test_init_never_enables_outputs_while_the_latch_is_still_ff(void) +{ + init_all(); + + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_unsafe_enable()); TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_shorted()); + + for (int i = 0; i < PCA9555_SLOT_COUNT; i++) { + TEST_ASSERT_EQUAL_HEX8(PCA9555_CONFIG0_INIT, sim_pca9555_config(i, 0)); + TEST_ASSERT_EQUAL_HEX8(PCA9555_CONFIG1_INIT, sim_pca9555_config(i, 1)); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(i, 0)); /* 复位值 0xFF 已被清掉 */ + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(i, 1)); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(i, 0)); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(i, 1)); + } } -/* dir_safe 的判据穷举:**当且仅当**接地位全为 1(输入)才算安全。 - 写成「dir != 0」「dir & 0x80」这类近似判据的,会在这 512 个组合里露馅。 */ -static void test_dir_safe_is_exactly_the_grounded_pins(void) +/* 上一条的加强版:init 完**每一个输出脚在栅极上的实际电平**都得是 0。 + 三个键松开、EDL 不导通、VBUS 断。接地脚必须是高阻(-1),不是 0 —— 这两者 + 在寄存器上长得一样,在栅极上差着一条对地通路。 */ +static void test_after_init_every_output_pin_is_released(void) +{ + init_all(); + + for (int i = 0; i < PCA9555_SLOT_COUNT; i++) { + for (int bit = 0; bit <= PCA9555_BIT_VBUS_EN; bit++) { + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(i, 0, bit)); + } + for (int bit = 5; bit < 8; bit++) { + TEST_ASSERT_EQUAL_INT(-1, sim_pca9555_pin(i, 0, bit)); /* 接地脚 = 输入 = 高阻 */ + } + for (int bit = 0; bit < 8; bit++) { + TEST_ASSERT_EQUAL_INT(-1, sim_pca9555_pin(i, 1, bit)); /* Port1 一位都不输出 */ + } + } +} + +/* ⛔ 这条证明上面那道拦网**不是句空话**。手工按「有人把顺序调过来了」写一次 + CONFIG0:此刻锁存器还是复位值 0xFF ⇒ 五个输出脚一起输出高。 + 注意 `gnd_driven` 仍然是 0 —— 方向字本身是对的,错的只有**时机**。 + 两个计数分开,就是为了让「顺序错」和「方向错」不互相掩护。 */ +static void test_sim_catches_the_reversed_init_order(void) +{ + sel(0); + TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_output(0, 0)); /* 复位值,这才是要命的地方 */ + + TEST_ASSERT_EQUAL_INT(HAL_OK, raw_write(PCA9555_REG_CONFIG0, PCA9555_CONFIG0_INIT)); + + TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_unsafe_enable()); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); + /* 这一刻线上真实发生的事:三个键被按住 + EDL 短接 + VBUS 上电。 */ + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_PWR)); + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_EDL_EN)); + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_VBUS_EN)); +} + +/* 极性反转是「看门狗复位后残留」的重灾区:ESP32 重启了,刀片上的 PCA9555 没掉电, + 上一条命写进去的值原样留着。init 必须**无条件清零**,不是「复位值本来就是 0」。 + 写成非 0 的现象是「故障全都看不见」,而 I²C 一切正常。 */ +static void test_init_clears_a_stale_polarity_inversion(void) +{ + sel(2); + sim_i2c_set_reg_ch(2, PCA9555_ADDR, PCA9555_REG_POLINV0, 0xFF); /* 上一条命的残留 */ + sim_i2c_set_reg_ch(2, PCA9555_ADDR, PCA9555_REG_POLINV1, 0x0F); + + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_init(2)); + + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(2, 0)); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(2, 1)); +} + +/* 半路失败必须立刻停 —— 尤其不许「输出锁存器没写成,方向照样改」: + 那正好凑成了顺序反过来的那一幕。这里让头两次事务 NAK(写 OUTPUT0 + 回读), + 之后总线恢复正常,看驱动会不会接着把方向写下去。 */ +static void test_init_stops_at_the_first_failure_and_leaves_the_pins_as_inputs(void) +{ + sel(1); + sim_i2c_inject_nak_ch(1, PCA9555_ADDR, 1); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_init(1)); + + TEST_ASSERT_EQUAL_HEX8(PCA9555_DIR_ALL_INPUT, sim_pca9555_config(1, 0)); /* 方向没被碰 */ + TEST_ASSERT_EQUAL_HEX8(PCA9555_DIR_ALL_INPUT, sim_pca9555_config(1, 1)); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_unsafe_enable()); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); +} + +/* 回读校验:写下去了、读回来不是那个值 = 这地址上蹲着的不是我们以为的那片。 + 必须失败而不是接着往下配方向。用「写成功但回读时 NAK」造这条错路。 */ +static void test_init_fails_when_the_readback_does_not_come_back(void) +{ + sel(3); + sim_i2c_inject_nak_ch(3, PCA9555_ADDR, 2); /* 第 1 次写过、第 2 次(回读)起 NAK */ + + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_init(3)); + TEST_ASSERT_EQUAL_HEX8(PCA9555_DIR_ALL_INPUT, sim_pca9555_config(3, 0)); +} + +static void test_init_rejects_a_bad_slot(void) +{ + sel(0); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_init(-1)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_init(PCA9555_SLOT_COUNT)); + TEST_ASSERT_EQUAL_HEX8(PCA9555_DIR_ALL_INPUT, sim_pca9555_config(0, 0)); /* 什么都没发生 */ +} + +/* ---- ⭐② 方向白名单 ------------------------------------------------------ */ + +/* Port0 的判据穷举 256 个方向字:**当且仅当 bit5~7 全是 1(输入)才安全**。 + 写成「dir != 0」「dir & 0x80」这类近似判据的,会在这 256 个组合里露馅。 + ⚠️ bit0~4 随意 —— 它们本来就是要当输出的,多配少配是功能问题不是安全问题。 */ +static void test_dir_safe_port0_only_guards_the_grounded_pins(void) { for (int dir = 0; dir <= 0xFF; dir++) { - const bool p0_want = ((uint8_t)dir & PCA9555_P0_GND_MASK) == PCA9555_P0_GND_MASK; - const bool p1_want = ((uint8_t)dir & PCA9555_P1_GND_MASK) == PCA9555_P1_GND_MASK; - TEST_ASSERT_EQUAL_INT(p0_want, pca9555_dir_safe(0, (uint8_t)dir)); - TEST_ASSERT_EQUAL_INT(p1_want, pca9555_dir_safe(1, (uint8_t)dir)); + const bool want = ((uint8_t)dir & PCA9555_P0_GND_MASK) == PCA9555_P0_GND_MASK; + TEST_ASSERT_EQUAL_INT(want, pca9555_dir_safe(0, (uint8_t)dir)); } - /* 几个会被真写出来的错值 */ - TEST_ASSERT_FALSE(pca9555_dir_safe(0, 0x7F)); /* 只把 P0_7 配成输出:最像笔误的一个 */ - TEST_ASSERT_FALSE(pca9555_dir_safe(1, 0x1F)); /* 「低五位是 PG,高三位不用」 */ - TEST_ASSERT_FALSE(pca9555_dir_safe(1, 0x00)); /* 全输出 */ - TEST_ASSERT_TRUE(pca9555_dir_safe(0, PCA9555_DIR_ALL_INPUT)); - TEST_ASSERT_TRUE(pca9555_dir_safe(1, PCA9555_DIR_ALL_INPUT)); - TEST_ASSERT_FALSE(pca9555_dir_safe(2, PCA9555_DIR_ALL_INPUT)); /* 端口号越界 */ + TEST_ASSERT_TRUE(pca9555_dir_safe(0, PCA9555_CONFIG0_INIT)); /* 定稿值 0xE0 */ + TEST_ASSERT_TRUE(pca9555_dir_safe(0, PCA9555_DIR_ALL_INPUT)); /* 全输入永远安全 */ + TEST_ASSERT_FALSE(pca9555_dir_safe(0, 0x60)); /* 顺手把 bit7 也配成输出:最像笔误的一个 */ + TEST_ASSERT_FALSE(pca9555_dir_safe(0, 0x00)); /* 「全配成输出反正用不着」 */ + TEST_ASSERT_FALSE(pca9555_dir_safe(0, 0xE0u & ~0x20u)); /* 只错一位也是对地短路 */ } -/* 这条证明的是**假器件真的会照出短路**,否则上面那条「shorted == 0」是句空话。 - 手工绕过驱动,按「有人把 P0_7 配成输出」写一次方向寄存器: - PCA9555 的 Output Port 复位值是 0xFF,所以这一刻它就在对地灌电流。 */ +/* Port1 **一位都不许输出**,所以只有 0xFF 一个合法值。 + bit0~3 接的是 eFuse `FLT#` / Buck `PWRGD` / INA `ALERT` 的开漏输出:配成输出 + = 我们的推挽和对方的开漏对着灌,现象是「故障永远读不到」+ 那颗芯片过热; + bit4~7 焊死在地上,配成输出直接对地短路。两种后果都不 NAK、不报错。 */ +static void test_dir_safe_port1_allows_nothing_to_be_an_output(void) +{ + for (int dir = 0; dir <= 0xFF; dir++) { + TEST_ASSERT_EQUAL_INT(dir == PCA9555_DIR_ALL_INPUT, pca9555_dir_safe(1, (uint8_t)dir)); + } + TEST_ASSERT_FALSE(pca9555_dir_safe(1, 0xF0)); /* 「低四位是信号,配成输出读得快」 */ + TEST_ASSERT_FALSE(pca9555_dir_safe(1, 0x0F)); /* 「高四位不用,随便配」 */ + TEST_ASSERT_TRUE(pca9555_dir_safe(1, PCA9555_CONFIG1_INIT)); +} + +/* 端口号越界一律 false(不是「当成 0 号口」)。 */ +static void test_dir_safe_rejects_a_bad_port(void) +{ + TEST_ASSERT_FALSE(pca9555_dir_safe(2, PCA9555_DIR_ALL_INPUT)); + TEST_ASSERT_FALSE(pca9555_dir_safe(-1, PCA9555_DIR_ALL_INPUT)); +} + +/* 整篇跑下来一次都不许碰接地脚:init 五片 + 各种输出写 + 读输入。 */ +static void test_grounded_pins_are_never_driven_on_any_blade(void) +{ + init_all(); + for (int i = 0; i < PCA9555_SLOT_COUNT; i++) { + sel(i); + TEST_ASSERT_EQUAL_INT(HAL_OK, + pca9555_write_out0(i, PCA9555_P0_OUT_MASK, PCA9555_P0_OUT_MASK)); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(i, PCA9555_P0_OUT_MASK, 0x00)); + pca9555_in_t in; + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in)); + } + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_shorted()); + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); /* 通道从没开重过 */ +} + +/* 证明上面那两个计数不是空话:手工把 P0.7(接地)配成输出。 + 锁存器还是 0xFF ⇒ 立刻对地短路(`shorted` 也 +1)。 */ static void test_sim_catches_a_grounded_pin_driven_high(void) { - mount_ready(); - - TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_output(0)); /* 复位值就是全高,这才是要命的地方 */ - - const uint8_t wr[2] = { PCA9555_REG_CONFIG0, 0x7F }; - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(PCA9555_ADDR, wr, sizeof(wr))); + sel(4); + TEST_ASSERT_EQUAL_INT(HAL_OK, raw_write(PCA9555_REG_CONFIG0, 0x7F)); TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_gnd_driven()); - TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_shorted()); + TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_shorted()); /* 真板子上这一刻开始冒烟 */ } -/* 同上,但先把输出锁存器写成 0 再配成输出:不冒烟,可仍然是在驱动一个接地脚。 - 两个计数分开就是为了让这一格不被漏掉。 */ +/* 同上,但先把锁存器写成 0 再配成输出:不冒烟,可仍然是在驱动一个接地脚 + (下一次写 Port0 就会把它拉高)。两个计数分开就是为了不漏掉这一格。 */ static void test_sim_counts_driving_a_grounded_pin_even_when_low(void) { - mount_ready(); - - const uint8_t out0[2] = { PCA9555_REG_OUTPUT0, 0x00 }; - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(PCA9555_ADDR, out0, sizeof(out0))); - const uint8_t cfg0[2] = { PCA9555_REG_CONFIG0, 0x7F }; - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(PCA9555_ADDR, cfg0, sizeof(cfg0))); + sel(4); + TEST_ASSERT_EQUAL_INT(HAL_OK, raw_write(PCA9555_REG_OUTPUT0, 0x00)); + TEST_ASSERT_EQUAL_INT(HAL_OK, raw_write(PCA9555_REG_CONFIG0, 0x7F)); TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_gnd_driven()); TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_shorted()); } -/* 驱动全程一个字节都不许写 Output Port:没有输出脚,写它只可能是写错寄存器号。 */ -static void test_init_never_touches_the_output_registers(void) +/* Port1 被配成输出:`gnd_driven` 只看 bit4~7,所以这里同时钉住 + 「dir_safe 把 bit0~3 也管住了」这件事 —— 它是判据,不是假器件能照出来的。 */ +static void test_sim_catches_port1_configured_as_output(void) { - mount_ready(); - TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_output(0)); - TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_output(1)); + sel(0); + TEST_ASSERT_FALSE(pca9555_dir_safe(1, 0x0F)); + TEST_ASSERT_EQUAL_INT(HAL_OK, raw_write(PCA9555_REG_CONFIG1, 0x0F)); /* bit4~7 输出 */ + TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_gnd_driven()); } -/* ---- 极性反转寄存器 ------------------------------------------------------ */ +/* ---- 位分配与地址 -------------------------------------------------------- */ -/* 极性反转是「看门狗复位后残留」的重灾区:ESP32 重启了,PCA9555 没掉电。 - init 必须无条件清零,而不是「复位值本来就是 0」。 */ -static void test_init_clears_a_stale_polarity_inversion(void) +static void test_bit_map_is_self_consistent(void) { - mount(); - sim_i2c_set_reg(PCA9555_ADDR, PCA9555_REG_POLINV0, 0xFF); /* 上一条命的残留 */ - sim_i2c_set_reg(PCA9555_ADDR, PCA9555_REG_POLINV1, 0x1F); + TEST_ASSERT_TRUE(pca9555_map_ok()); - TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_init()); + /* 五个输出位正好铺满允许配成输出的那五位,一位不多一位不少。 */ + const uint8_t out_bits = (uint8_t)((1u << PCA9555_BIT_KEY_PWR) | (1u << PCA9555_BIT_KEY_VUP) | + (1u << PCA9555_BIT_KEY_VDN) | (1u << PCA9555_BIT_EDL_EN) | + (1u << PCA9555_BIT_VBUS_EN)); + TEST_ASSERT_EQUAL_HEX8(PCA9555_P0_OUT_MASK, out_bits); + TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)(out_bits & PCA9555_P0_GND_MASK)); /* 没落到接地脚上 */ - TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(0)); - TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(1)); + const uint8_t in_bits = (uint8_t)((1u << PCA9555_BIT_EFUSE_FLT) | (1u << PCA9555_BIT_BUCK_PG) | + (1u << PCA9555_BIT_INA_ALERT) | (1u << PCA9555_BIT_EFUSE_PG)); + TEST_ASSERT_EQUAL_HEX8(PCA9555_P1_IN_MASK, in_bits); + TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)(in_bits & PCA9555_P1_GND_MASK)); + + /* 方向字与位分配自洽:Port0 上「不是输入」的正好是那五个输出位。 */ + TEST_ASSERT_EQUAL_HEX8(PCA9555_P0_OUT_MASK, (uint8_t)~PCA9555_CONFIG0_INIT); + TEST_ASSERT_EQUAL_HEX8(PCA9555_DIR_ALL_INPUT, PCA9555_CONFIG1_INIT); + + /* 两个端口的掩码各自铺满 8 位 —— 漏一位就是「有个脚没人管」。 */ + TEST_ASSERT_EQUAL_HEX8(0xFF, (uint8_t)(PCA9555_P0_OUT_MASK | PCA9555_P0_GND_MASK)); + TEST_ASSERT_EQUAL_HEX8(0xFF, (uint8_t)(PCA9555_P1_IN_MASK | PCA9555_P1_GND_MASK)); } -/* ---- 验收主项二:三种极性 ------------------------------------------------ */ - -/* 静息态(两个端口都被上拉到 0xFF)= 五路无故障、五路电源良好、Buck 正常、无告警。 - 任何一处极性写反,这条立刻红。 */ -static void test_idle_bus_means_everything_is_fine(void) +/* 三个键的位号互不重复、都在输出白名单里。`pca9555_key_mask()` 是 + `t_actuator` 的「防御性松键」用的那个掩码,少一位就是「有个键松不掉」。 */ +static void test_key_bits_and_mask(void) { - mount_ready(); + TEST_ASSERT_EQUAL_INT(PCA9555_BIT_KEY_PWR, pca9555_key_bit(PCA9555_KEY_PWR)); + TEST_ASSERT_EQUAL_INT(PCA9555_BIT_KEY_VUP, pca9555_key_bit(PCA9555_KEY_VUP)); + TEST_ASSERT_EQUAL_INT(PCA9555_BIT_KEY_VDN, pca9555_key_bit(PCA9555_KEY_VDN)); + + const uint8_t m = pca9555_key_mask(); + TEST_ASSERT_EQUAL_HEX8(0x07, m); + TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)(m & PCA9555_P0_GND_MASK)); /* 松键不许碰接地脚 */ + TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)(m & (1u << PCA9555_BIT_VBUS_EN))); /* 也不许碰 VBUS */ + TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)(m & (1u << PCA9555_BIT_EDL_EN))); +} + +/* 地址撞车 = 两片一起应答,读回来是线与(数值正常、还很稳),一层都不报错。 + ⚠️ 刀片支路上的 INA226 是 **0x40**,与 0x20 本来就不是同一个地址,所以它同在 + 一条支路上没问题 —— 真正要防的是「有人把 PCA9555 的 A0 焊成 1 变 0x21」, + 或者哪天往主干上加器件时选了 0x20。 */ +static void test_address_does_not_collide(void) +{ + TEST_ASSERT_TRUE(pca9555_addr_ok()); + TEST_ASSERT_EQUAL_HEX8(0x20, PCA9555_ADDR); /* A0=A1=A2=GND(U4 的 pin21/2/3) */ + + TEST_ASSERT_NOT_EQUAL(TCA9548A_ADDR, PCA9555_ADDR); /* 0x70:撞了 = 选通道变成写 IO */ + TEST_ASSERT_NOT_EQUAL(0x43, PCA9555_ADDR); /* 输入侧 INA226 */ + TEST_ASSERT_NOT_EQUAL(0x4A, PCA9555_ADDR); /* DAC63204 ×2 */ + TEST_ASSERT_NOT_EQUAL(0x4B, PCA9555_ADDR); + TEST_ASSERT_NOT_EQUAL(0x40, PCA9555_ADDR); /* 同支路的刀片 INA226 —— 也不撞 */ + + /* 五片同址是**设计**不是笔误:假总线允许它,靠通道区分(sim_i2c_add_ch 的 assert + 只管同一条支路)。这一条把「哪天有人想给五片分配不同地址」钉死。 */ + for (int i = 0; i < PCA9555_SLOT_COUNT; i++) { + sel(i); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_probe()); /* 五个通道上都是同一个地址 */ + } + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); +} + +/* ---- ⭐③ 输出写:读改写 + 五片互不串 ------------------------------------ */ + +/* 读改写语义:只动 mask 里的位,其余位保持影子里的值。 + 组合键(VUP+PWR)必须落在**同一次写**里 —— 分两次写会先单按一个键, + 小米 8 在那几毫秒里可能真的响应了单键(§10.6)。 */ +static void test_write_out0_only_touches_the_masked_bits(void) +{ + init_all(); + sel(0); + + /* 先按住 VBUS(它在后面每一步里都必须纹丝不动)。 */ + const uint8_t vbus = (uint8_t)(1u << PCA9555_BIT_VBUS_EN); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(0, vbus, vbus)); + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_VBUS_EN)); + + /* 组合键:两位一次写下去。 */ + const uint8_t combo = (uint8_t)((1u << PCA9555_BIT_KEY_PWR) | (1u << PCA9555_BIT_KEY_VUP)); + const sim_i2c_dev_t *d = sim_i2c_dev_ch(0, PCA9555_ADDR); + const uint32_t w0 = d->n_write; + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(0, combo, combo)); + TEST_ASSERT_EQUAL_UINT32(1, d->n_write - w0); /* 一次事务,不是两次 */ + + TEST_ASSERT_EQUAL_HEX8((uint8_t)(combo | vbus), sim_pca9555_output(0, 0)); + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_VBUS_EN)); /* 没被顺手清掉 */ + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_VDN)); + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(0, 0, PCA9555_BIT_EDL_EN)); + + /* 松键:只清这两位,VBUS 还在。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(0, pca9555_key_mask(), 0x00)); + TEST_ASSERT_EQUAL_HEX8(vbus, sim_pca9555_output(0, 0)); +} + +/* mask 碰到输出白名单以外的位 → `HAL_ERR_ARG`,而且**总线上什么都没发生**。 + 悄悄把它写下去 = 把接地脚拉高(如果方向也错了)或者白写一次; + 这里返回错误码而不是 abort,是因为那多半是上层算错了掩码。 */ +static void test_write_out0_rejects_a_mask_that_touches_a_grounded_pin(void) +{ + init_all(); + sel(2); + const sim_i2c_dev_t *d = sim_i2c_dev_ch(2, PCA9555_ADDR); + const uint32_t w0 = d->n_write; + + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_write_out0(2, 0x20, 0x20)); /* bit5 接地 */ + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_write_out0(2, 0xFF, 0x00)); /* 「全清一遍」 */ + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_write_out0(2, 0x80, 0x00)); + + TEST_ASSERT_EQUAL_UINT32(0, d->n_write - w0); /* 一个字节都没发出去 */ + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(2, 0)); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); +} + +static void test_write_out0_rejects_a_bad_slot(void) +{ + init_all(); + sel(0); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_write_out0(-1, 0x01, 0x01)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_write_out0(PCA9555_SLOT_COUNT, 0x01, 0x01)); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(0, 0)); +} + +/* ⭐ **写失败时影子不更新。** 更新了的后果不是「这一次没生效」,而是**下一次** + 写会带着一个从没落到线上的位:比如这次「按 PWR」失败了却记了影子,下一次 + 「按 VUP」就会把 PWR 一起按下去 —— 而那一次是成功的,日志里干干净净。 + 所以判据打在**下一次写真正落到线上的字节**上,不是打在影子上。 */ +static void test_write_out0_keeps_the_shadow_when_the_write_fails(void) +{ + init_all(); + sel(1); + const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR); + const uint8_t vup = (uint8_t)(1u << PCA9555_BIT_KEY_VUP); + + sim_i2c_inject_nak_ch(1, PCA9555_ADDR, 1); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_write_out0(1, pwr, pwr)); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(1, 0)); /* 线上确实没变 */ + + /* 下一次写(成功)只许出现 VUP 一位。带上 PWR = 影子被污染了。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(1, vup, vup)); + TEST_ASSERT_EQUAL_HEX8(vup, sim_pca9555_output(1, 0)); + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(1, 0, PCA9555_BIT_KEY_PWR)); +} + +/* ⭐ R2 新增的失败形态:**五片的影子串了**。 + R1 只有一颗芯片,这类错根本不存在;R2 里影子是个五格数组,写成 + `s_out0[0]` 或者少减一个 1,现象是「改 CH1 的键把 CH4 的键也改了」, + 而五路电压电流全部正常、I²C 也没报错。 */ +static void test_the_five_shadows_do_not_bleed_into_each_other(void) +{ + init_all(); + + /* 每一片按一个不同的键,通道来回切,模拟 t_actuator 交错操作五路。 */ + const uint8_t want[PCA9555_SLOT_COUNT] = { + (uint8_t)(1u << PCA9555_BIT_KEY_PWR), + (uint8_t)(1u << PCA9555_BIT_KEY_VUP), + (uint8_t)(1u << PCA9555_BIT_KEY_VDN), + (uint8_t)(1u << PCA9555_BIT_EDL_EN), + (uint8_t)(1u << PCA9555_BIT_VBUS_EN), + }; + for (int i = 0; i < PCA9555_SLOT_COUNT; i++) { + sel(i); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(i, PCA9555_P0_OUT_MASK, want[i])); + } + for (int i = 0; i < PCA9555_SLOT_COUNT; i++) { + TEST_ASSERT_EQUAL_HEX8(want[i], sim_pca9555_output(i, 0)); + } + + /* 再回到第 0 片改一位:另外四片必须一位都不动。 */ + sel(0); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(0, pca9555_key_mask(), 0x00)); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(0, 0)); + for (int i = 1; i < PCA9555_SLOT_COUNT; i++) { + TEST_ASSERT_EQUAL_HEX8(want[i], sim_pca9555_output(i, 0)); + } + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); +} + +/* `pca9555_shadow_reset()` 只动影子、**不碰总线**(A15 的降级路径靠它说 + 「现在生效的是上电默认值」)。碰了总线就是在一条已经失联的总线上瞎写。 */ +static void test_shadow_reset_does_not_touch_the_bus(void) +{ + init_all(); + sel(3); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(3, PCA9555_P0_OUT_MASK, 0x1F)); + const sim_i2c_dev_t *d = sim_i2c_dev_ch(3, PCA9555_ADDR); + const uint32_t w0 = d->n_write; + + pca9555_shadow_reset(); + + TEST_ASSERT_EQUAL_UINT32(0, d->n_write - w0); + TEST_ASSERT_EQUAL_HEX8(0x1F, sim_pca9555_output(3, 0)); /* 器件上的值没变 */ + TEST_ASSERT_EQUAL_HEX8(0x00, pca9555_shadow(3)); +} + +/* ---- 输入与四个语义判读 -------------------------------------------------- */ + +/* 静息态:Port1 四根开漏线全被上拉 ⇒ 无故障、eFuse 良好、Buck 正常、无告警。 + 任何一处极性写反,这条立刻红。 + ⚠️ Port0 读回来必须是 0x00:bit0~4 是输出(锁存器 0),bit5~7 接地。 */ +static void test_idle_inputs_mean_everything_is_fine(void) +{ + init_all(); + sel(0); + pca9555_in_t in; TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in)); + TEST_ASSERT_EQUAL_HEX8(0x00, in.port0); + TEST_ASSERT_EQUAL_HEX8(PCA9555_P1_IN_MASK, in.port1); /* bit4~7 接地,恒 0 */ - TEST_ASSERT_EQUAL_HEX8(0x7F, in.port0); /* P0_7 接地,读回来必是 0 */ - TEST_ASSERT_EQUAL_HEX8(0x1F, in.port1); /* P1_5~7 接地 */ - - for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) { - TEST_ASSERT_FALSE(pca9555_rail_fault(&in, rail)); - TEST_ASSERT_TRUE(pca9555_rail_pg(&in, rail)); - } + TEST_ASSERT_FALSE(pca9555_efuse_fault(&in)); + TEST_ASSERT_TRUE(pca9555_efuse_pg(&in)); TEST_ASSERT_TRUE(pca9555_buck_pg(&in)); TEST_ASSERT_FALSE(pca9555_ina_alert(&in)); } -/* 全 0(比如整条总线被拉低、或者上拉没焊)= 五路全故障、五路全不良、Buck 不良、告警。 - 注意这**不是**「安全默认值」:它和「五路真的全炸了」长得一模一样, +/* 四个信号**两种极性**,一位一位单独拉低,四个判读函数各测一次。 + 极性写反的现象是「有故障被读成电源良好」—— 方向正好相反,是最坏的那一种: + 系统不但不保护,还会更自信。所以每一格都要:拉低这一位时它变了, + **而另外三个判读一动不动**(端口内串位的现象和极性写反长得一模一样)。 */ +static void test_each_polarity_exactly_once(void) +{ + init_all(); + sel(2); + pca9555_in_t in; + + /* ① EFUSE_FLT# 低有效 ⇒ 拉低 = 有故障 */ + sim_pca9555_drive(2, 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_EFUSE_FLT))); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in)); + TEST_ASSERT_TRUE(pca9555_efuse_fault(&in)); + TEST_ASSERT_TRUE(pca9555_efuse_pg(&in)); /* 另外三个不许跟着动 */ + TEST_ASSERT_TRUE(pca9555_buck_pg(&in)); + TEST_ASSERT_FALSE(pca9555_ina_alert(&in)); + + /* ② EFUSE_PG 高 = 良好 ⇒ 拉低 = 不良 */ + sim_pca9555_drive(2, 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_EFUSE_PG))); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in)); + TEST_ASSERT_FALSE(pca9555_efuse_pg(&in)); + TEST_ASSERT_FALSE(pca9555_efuse_fault(&in)); + TEST_ASSERT_TRUE(pca9555_buck_pg(&in)); + TEST_ASSERT_FALSE(pca9555_ina_alert(&in)); + + /* ③ BUCK_PG 高 = 正常 ⇒ 拉低 = 本路 Buck 不良(R2 里只代表本路!) */ + sim_pca9555_drive(2, 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_BUCK_PG))); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in)); + TEST_ASSERT_FALSE(pca9555_buck_pg(&in)); + TEST_ASSERT_FALSE(pca9555_efuse_fault(&in)); + TEST_ASSERT_TRUE(pca9555_efuse_pg(&in)); + TEST_ASSERT_FALSE(pca9555_ina_alert(&in)); + + /* ④ INA_ALERT 低有效 ⇒ 拉低 = 在告警 */ + sim_pca9555_drive(2, 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_INA_ALERT))); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in)); + TEST_ASSERT_TRUE(pca9555_ina_alert(&in)); + TEST_ASSERT_FALSE(pca9555_efuse_fault(&in)); + TEST_ASSERT_TRUE(pca9555_efuse_pg(&in)); + TEST_ASSERT_TRUE(pca9555_buck_pg(&in)); +} + +/* 全 0(整条支路被拉低、或者上拉没焊)= 全故障 + 全不良 + 告警。 + 这**不是**安全默认值:它和「这一路真的全炸了」长得一模一样, §10.10 因此规定这片不应答时改用 INA226 的电压判据兜底,而不是信这一帧。 */ static void test_all_low_means_everything_is_bad(void) { - mount_ready(); - sim_pca9555_drive(0, 0x00); - sim_pca9555_drive(1, 0x00); + init_all(); + sel(1); + sim_pca9555_drive(1, 1, 0x00); pca9555_in_t in; TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in)); - for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) { - TEST_ASSERT_TRUE(pca9555_rail_fault(&in, rail)); - TEST_ASSERT_FALSE(pca9555_rail_pg(&in, rail)); - } + TEST_ASSERT_TRUE(pca9555_efuse_fault(&in)); + TEST_ASSERT_FALSE(pca9555_efuse_pg(&in)); TEST_ASSERT_FALSE(pca9555_buck_pg(&in)); TEST_ASSERT_TRUE(pca9555_ina_alert(&in)); } -/* 逐路点名:只让一路报故障,其余四路必须纹丝不动。 - FLT 和 PG 两张表在两个端口上长得一模一样(都是 bit0~4),把端口搞反之后 - 「有故障」会被读成「电源良好」,所以这里一路一路地把两个端口错开来验。 */ -static void test_one_rail_at_a_time(void) +/* 读的是**当前通道上那一片**。五片同址,读错通道 = 读到另一台手机的故障状态, + 而量纲、范围、变化趋势全部正常。这条把「同址靠通道区分」真的跑一遍。 */ +static void test_read_inputs_reads_the_blade_on_the_current_channel(void) { - mount_ready(); - - for (int hurt = 1; hurt <= PCA9555_RAIL_COUNT; hurt++) { - /* FLT 只拉低 hurt 那一路;PG 只拉低**另一路**(hurt 的下一路,绕回来)。 */ - const int other = (hurt % PCA9555_RAIL_COUNT) + 1; - sim_pca9555_drive(0, (uint8_t)(0xFFu & ~(1u << pca9555_flt_bit(hurt)))); - sim_pca9555_drive(1, (uint8_t)(0xFFu & ~(1u << pca9555_pg_bit(other)))); + init_all(); + /* 只让第 3 片报 eFuse 故障。 */ + sim_pca9555_drive(3, 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_EFUSE_FLT))); + for (int i = 0; i < PCA9555_SLOT_COUNT; i++) { + sel(i); pca9555_in_t in; TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in)); - - for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) { - TEST_ASSERT_EQUAL_INT(rail == hurt, pca9555_rail_fault(&in, rail)); - TEST_ASSERT_EQUAL_INT(rail != other, pca9555_rail_pg(&in, rail)); - } - /* 两根线或信号不受任何一路 FLT 影响(它们在 P0_5 / P0_6 上)。 */ - TEST_ASSERT_TRUE(pca9555_buck_pg(&in)); - TEST_ASSERT_FALSE(pca9555_ina_alert(&in)); + TEST_ASSERT_EQUAL_INT(i == 3, pca9555_efuse_fault(&in)); } + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); } -/* 两根线或信号自己点名:拉低 BUCK_PG 不许把任何一路读成故障,反之亦然。 */ -static void test_wire_or_signals_do_not_alias_a_rail(void) -{ - mount_ready(); - pca9555_in_t in; - - sim_pca9555_drive(0, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_BUCK_PG))); - TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in)); - TEST_ASSERT_FALSE(pca9555_buck_pg(&in)); - TEST_ASSERT_FALSE(pca9555_ina_alert(&in)); - for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) { - TEST_ASSERT_FALSE(pca9555_rail_fault(&in, rail)); - } - - sim_pca9555_drive(0, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_INA_ALERT))); - TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in)); - TEST_ASSERT_TRUE(pca9555_buck_pg(&in)); - TEST_ASSERT_TRUE(pca9555_ina_alert(&in)); - for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) { - TEST_ASSERT_FALSE(pca9555_rail_fault(&in, rail)); - } -} - -/* ---- 位表与地址 ---------------------------------------------------------- */ - -static void test_bit_map_is_consistent(void) -{ - TEST_ASSERT_TRUE(pca9555_map_ok()); - TEST_ASSERT_TRUE(pca9555_addr_ok()); - TEST_ASSERT_EQUAL_HEX8(0x20, PCA9555_ADDR); /* A0=A1=A2=GND,U23 的 pin21/2/3 */ - - for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) { - TEST_ASSERT_EQUAL_INT(rail - 1, pca9555_flt_bit(rail)); - TEST_ASSERT_EQUAL_INT(rail - 1, pca9555_pg_bit(rail)); - /* 一路都不许落到接地脚或两根线或信号上 */ - TEST_ASSERT_TRUE(pca9555_flt_bit(rail) < PCA9555_BIT_BUCK_PG); - TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)((1u << pca9555_flt_bit(rail)) & PCA9555_P0_GND_MASK)); - TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)((1u << pca9555_pg_bit(rail)) & PCA9555_P1_GND_MASK)); - } - TEST_ASSERT_EQUAL_INT(5, PCA9555_BIT_BUCK_PG); - TEST_ASSERT_EQUAL_INT(6, PCA9555_BIT_INA_ALERT); -} - -/* ---- 总线行为 ------------------------------------------------------------ */ - -/* §10.3 的 0.12 ms 预算是按**一次事务**算的(写命令字节 + 重复起始读 2 字节)。 - 拆成两次事务照样能跑,但 t_safety 的 I²C 占用会对不上 A8 要断言的那个数。 */ +/* §10.3 的总线预算是按**一次事务**算的(写命令字节 + 重复起始读 2 字节)。 + 拆成两次照样能跑,但 t_safety 一圈的 I²C 占用会对不上 §5.5 那张表。 */ static void test_read_inputs_uses_one_transaction(void) { - mount_ready(); - const sim_i2c_dev_t *d = sim_i2c_dev(PCA9555_ADDR); + init_all(); + sel(0); + const sim_i2c_dev_t *d = sim_i2c_dev_ch(0, PCA9555_ADDR); const uint32_t w0 = d->n_write, r0 = d->n_read; pca9555_in_t in; @@ -234,107 +582,104 @@ static void test_read_inputs_uses_one_transaction(void) TEST_ASSERT_EQUAL_UINT32(1, d->n_read - r0); } -static void test_probe_accepts_a_fresh_device(void) +/* §10.10:这片不应答只是失去可见性,驱动如实返回错误码、什么动作都不做, + **也不许改写调用方的缓冲** —— 半新半旧的一帧比明着失败危险得多。 */ +static void test_read_inputs_propagates_nak_without_touching_the_buffer(void) { - mount(); - TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_probe()); -} - -/* 这个地址上蹲着别的东西(应答了,但方向寄存器不是复位值)。 */ -static void test_probe_rejects_a_stranger(void) -{ - mount(); - sim_i2c_set_reg(PCA9555_ADDR, PCA9555_REG_CONFIG1, 0x00); - TEST_ASSERT_EQUAL_INT(HAL_ERR_BUS, pca9555_probe()); -} - -static void test_probe_absent_device_naks(void) -{ - mount(); - sim_i2c_set_present(PCA9555_ADDR, false); - TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_probe()); -} - -/* §10.10:这片不应答只是失去可见性,驱动如实返回 NAK,什么动作都不做。 */ -static void test_read_propagates_nak(void) -{ - mount_ready(); - sim_i2c_inject_nak(PCA9555_ADDR, -1); + init_all(); + sel(4); + sim_i2c_inject_nak_ch(4, PCA9555_ADDR, -1); pca9555_in_t in = { 0xAA, 0x55 }; TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_read_inputs(&in)); - TEST_ASSERT_EQUAL_HEX8(0xAA, in.port0); /* 失败时不许改写调用方的缓冲 */ + TEST_ASSERT_EQUAL_HEX8(0xAA, in.port0); TEST_ASSERT_EQUAL_HEX8(0x55, in.port1); -} -static void test_init_propagates_nak(void) -{ - mount(); - sim_i2c_inject_nak(PCA9555_ADDR, -1); - TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_init()); -} - -/* 回读对不上 = 总线上有东西但不是我们以为的那片,init 必须失败而不是接着往下跑。 - 这里用「写第二个方向寄存器时 NAK」来造回读失败之外的另一条错路。 */ -static void test_init_stops_at_the_first_failure(void) -{ - mount(); - sim_i2c_inject_nak(PCA9555_ADDR, 3); /* 头三次事务 NAK,之后恢复 */ - TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_init()); - /* 失败了就别把剩下的寄存器也写一遍 —— 极性反转还停在没被碰过的状态。 */ - TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(1)); -} - -static void test_null_output_rejected(void) -{ - mount_ready(); TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_read_inputs(NULL)); } -/* ---- INT#(A8/A10 会用,这里只钉住它的行为) ----------------------------- */ +/* ---- probe ---------------------------------------------------------------- */ -/* 中断是加速路径不是主路径:**读走输入端口才释放**。 - 漏读一次就再也等不到下一个沿,所以 t_safety 必须无条件轮询。 */ -static void test_int_is_only_cleared_by_reading_the_input_ports(void) +/* probe 的语义是「这是一片**刚上电**的 PCA9555 吗」:两个方向寄存器都得是 0xFF。 */ +static void test_probe_accepts_a_fresh_blade(void) { - mount_ready(); - TEST_ASSERT_FALSE(sim_pca9555_int_asserted()); + for (int i = 0; i < PCA9555_SLOT_COUNT; i++) { + sel(i); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_probe()); + } +} - sim_pca9555_drive(0, 0xFE); /* CH1 报故障 */ - TEST_ASSERT_TRUE(sim_pca9555_int_asserted()); - TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_int_asserts()); +/* ⚠️ 已经被 init 过的片子返回 HAL_ERR_BUS —— 这是对的,不是 bug。 + 所以重连之后不能拿 probe 当「它还在不在」的判据(那是 read_inputs 的活)。 */ +static void test_probe_rejects_an_already_initialised_blade(void) +{ + sel(0); + TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_init(0)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_BUS, pca9555_probe()); +} - /* 读别的寄存器不清中断 */ - TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_probe()); - TEST_ASSERT_TRUE(sim_pca9555_int_asserted()); +/* 这地址上蹲着别的东西(应答了,方向寄存器却不是复位值)。 */ +static void test_probe_rejects_a_stranger(void) +{ + sel(1); + sim_i2c_set_reg_ch(1, PCA9555_ADDR, PCA9555_REG_CONFIG1, 0x00); + TEST_ASSERT_EQUAL_INT(HAL_ERR_BUS, pca9555_probe()); +} - pca9555_in_t in; - TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in)); - TEST_ASSERT_FALSE(sim_pca9555_int_asserted()); +/* 刀片拔了 / 那条支路没选通 → NAK。R2 特有:**通道没开**和**器件没了**在总线上 + 长得一模一样,两者都只能得到 NAK —— 区分它们是 `t_safety` 的事(PRSNT# + mux + 失联判据),不是驱动的事。 */ +static void test_probe_naks_when_the_blade_is_not_reachable(void) +{ + sel(2); + sim_i2c_set_present_ch(2, PCA9555_ADDR, false); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_probe()); + + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, TCA9548A_CH_NONE)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_probe()); /* 通道全关 = 谁也够不着 */ } int main(void) { UNITY_BEGIN(); - RUN_TEST(test_init_configures_every_pin_as_input); - RUN_TEST(test_dir_safe_is_exactly_the_grounded_pins); + /* ① 初始化顺序 */ + RUN_TEST(test_init_never_enables_outputs_while_the_latch_is_still_ff); + RUN_TEST(test_after_init_every_output_pin_is_released); + RUN_TEST(test_sim_catches_the_reversed_init_order); + RUN_TEST(test_init_clears_a_stale_polarity_inversion); + RUN_TEST(test_init_stops_at_the_first_failure_and_leaves_the_pins_as_inputs); + RUN_TEST(test_init_fails_when_the_readback_does_not_come_back); + RUN_TEST(test_init_rejects_a_bad_slot); + /* ② 方向白名单 */ + RUN_TEST(test_dir_safe_port0_only_guards_the_grounded_pins); + RUN_TEST(test_dir_safe_port1_allows_nothing_to_be_an_output); + RUN_TEST(test_dir_safe_rejects_a_bad_port); + RUN_TEST(test_grounded_pins_are_never_driven_on_any_blade); RUN_TEST(test_sim_catches_a_grounded_pin_driven_high); RUN_TEST(test_sim_counts_driving_a_grounded_pin_even_when_low); - RUN_TEST(test_init_never_touches_the_output_registers); - RUN_TEST(test_init_clears_a_stale_polarity_inversion); - RUN_TEST(test_idle_bus_means_everything_is_fine); + RUN_TEST(test_sim_catches_port1_configured_as_output); + /* 位分配与地址 */ + RUN_TEST(test_bit_map_is_self_consistent); + RUN_TEST(test_key_bits_and_mask); + RUN_TEST(test_address_does_not_collide); + /* ③ 输出写 */ + RUN_TEST(test_write_out0_only_touches_the_masked_bits); + RUN_TEST(test_write_out0_rejects_a_mask_that_touches_a_grounded_pin); + RUN_TEST(test_write_out0_rejects_a_bad_slot); + RUN_TEST(test_write_out0_keeps_the_shadow_when_the_write_fails); + RUN_TEST(test_the_five_shadows_do_not_bleed_into_each_other); + RUN_TEST(test_shadow_reset_does_not_touch_the_bus); + /* 输入与极性 */ + RUN_TEST(test_idle_inputs_mean_everything_is_fine); + RUN_TEST(test_each_polarity_exactly_once); RUN_TEST(test_all_low_means_everything_is_bad); - RUN_TEST(test_one_rail_at_a_time); - RUN_TEST(test_wire_or_signals_do_not_alias_a_rail); - RUN_TEST(test_bit_map_is_consistent); + RUN_TEST(test_read_inputs_reads_the_blade_on_the_current_channel); RUN_TEST(test_read_inputs_uses_one_transaction); - RUN_TEST(test_probe_accepts_a_fresh_device); + RUN_TEST(test_read_inputs_propagates_nak_without_touching_the_buffer); + /* probe */ + RUN_TEST(test_probe_accepts_a_fresh_blade); + RUN_TEST(test_probe_rejects_an_already_initialised_blade); RUN_TEST(test_probe_rejects_a_stranger); - RUN_TEST(test_probe_absent_device_naks); - RUN_TEST(test_read_propagates_nak); - RUN_TEST(test_init_propagates_nak); - RUN_TEST(test_init_stops_at_the_first_failure); - RUN_TEST(test_null_output_rejected); - RUN_TEST(test_int_is_only_cleared_by_reading_the_input_ports); + RUN_TEST(test_probe_naks_when_the_blade_is_not_reachable); return UNITY_END(); } diff --git a/firmware/test/test_rail_cfg.c b/firmware/test/test_rail_cfg.c index 169c237..cc71355 100644 --- a/firmware/test/test_rail_cfg.c +++ b/firmware/test/test_rail_cfg.c @@ -1,23 +1,45 @@ -/* A7:五路配置表 + 表驱动遍历 + 启动自检(需求 §10.4)。 +/* A7(**R2 版**):五路配置表 + 启动自检 + `EFUSE_EN`(需求 §10.4、计划 §5.7)。 + + 验收主项一:**启动自检 —— 五路的地址 / 通道 / 引脚无重复、无越界**, + 而且**自检逻辑本身查得出坏表**(真表是 const 的,只拿它跑一遍证明不了这件事)。 + 验收主项二:那张五路表和两个驱动自己的表**没有分家**(逐格比,不是抽查)。 + 验收主项三:⭐ **顺排的那几列也不许改成算式。** + R2 里 `mux_ch` = rail−1、`EFUSE_EN` = IO(8+rail) 这两列**确实是顺排的**, + 但 `PRSNT#` 那一列到第五路就断了(IO15、16、17、18、**IO7**)—— + 它就是现成的反例:**顺排是当下的事实,不是不变量**。 + + ---- R1 → R2 这份测试换掉了什么 ----------------------------------------- + · 595 那条链(按键位 / EDL 位)**整个没有了** —— 位号搬进 `pca9555.h`, + 五路逐格相同,不再是「每路一个」,因此也不再进这张表; + · `vbus_en` 那一列**没有了** —— 挪到刀片 PCA9555 的 P0.4(计划 §5.4), + 于是 `rail_all_off()` 的前半段从 GPIO 变成了 I²C,**可能失败**; + · 新增 `mux_ch` 与 `PRSNT#` 两列,`RAIL_SELFTEST_SR595` 换成 `RAIL_SELFTEST_MUX`; + · INA226 五路同址 0x40 —— R1 那条「六片六地址」的用例整个作废。 */ +#include - 验收主项:**启动自检 —— 五路的地址 / 位号 / 引脚无重复、无越界。** - 第二主项:那张五路表和四个驱动自己的表**没有分家**(逐格比,不是抽查)。 - 第三主项:**没有一列能用通道号算出来** —— 这一片最容易写出来的错就是 - 哪天有人把表换回 `0x40 + rail`,那之后板子照样跑,只是命令发给了另一路。 */ #include "board_pins.h" #include "dac63204.h" #include "hal_gpio.h" +#include "hal_i2c.h" +#include "hal_lock.h" +#include "i2cbus.h" #include "ina226.h" #include "pca9555.h" #include "phonerail.h" #include "rail_cfg.h" #include "sim_hal.h" -#include "sr595.h" +#include "slot.h" +#include "tca9548a.h" #include "unity.h" #define N PHONERAIL_RAIL_COUNT -void setUp(void) { sim_hal_reset(); } +void setUp(void) +{ + sim_hal_reset(); + /* 驱动里的静态影子不归 sim 管,不清会串到下一条用例。 */ + pca9555_shadow_reset(); +} void tearDown(void) {} /* ---- 验收主项一:自检 ----------------------------------------------------- */ @@ -27,35 +49,41 @@ static void test_selftest_passes(void) TEST_ASSERT_EQUAL_HEX32(0, rail_selftest()); } -/* 自检里的六项每一项都要真的被算过。这条挨个调它们背后的谓词 —— - `rail_selftest()` 要是哪天被改成 `return 0;`,上一条测试照样绿。 */ -static void test_selftest_covers_all_four_driver_tables(void) +/* 自检背后的每一个谓词都要真的被算过。`rail_selftest()` 哪天被改成 `return 0;`, + 上一条照样绿 —— 这条不会。 */ +static void test_selftest_covers_every_driver_table(void) { TEST_ASSERT_TRUE(ina226_addr_table_ok()); TEST_ASSERT_TRUE(dac63204_map_ok()); - TEST_ASSERT_TRUE(sr595_map_ok()); TEST_ASSERT_TRUE(pca9555_map_ok()); TEST_ASSERT_TRUE(pca9555_addr_ok()); + TEST_ASSERT_TRUE(tca9548a_addr_ok()); /* R2 新增的那颗,漏了它自检就少一项 */ } +/* 每个失败位都得有人话名字:打日志时打出一个 "?" 等于没打, + 而这条日志是上电自检失败时**唯一**的线索(那时串口以外什么都还没起来)。 */ static void test_selftest_str_names_every_bit(void) { static const uint32_t bits[] = { - RAIL_SELFTEST_INA, RAIL_SELFTEST_DAC, RAIL_SELFTEST_SR595, + RAIL_SELFTEST_INA, RAIL_SELFTEST_DAC, RAIL_SELFTEST_MUX, RAIL_SELFTEST_PCA9555, RAIL_SELFTEST_TABLE, RAIL_SELFTEST_PINS, }; for (unsigned i = 0; i < sizeof(bits) / sizeof(bits[0]); i++) { TEST_ASSERT_TRUE(rail_selftest_str(bits[i])[0] != '?'); } + /* R2 新增的那一位不许沿用 595 的名字 —— 名字对不上现象,等于把人往沟里带。 */ + TEST_ASSERT_EQUAL_HEX32(1u << 2, (uint32_t)RAIL_SELFTEST_MUX); + TEST_ASSERT_NULL(strstr(rail_selftest_str(RAIL_SELFTEST_MUX), "595")); + TEST_ASSERT_EQUAL_STRING("?", rail_selftest_str(0)); TEST_ASSERT_EQUAL_STRING("?", rail_selftest_str(1u << 31)); /* 两位一起传进来也是 "?":调用方该逐位拆。 */ TEST_ASSERT_EQUAL_STRING("?", rail_selftest_str(RAIL_SELFTEST_INA | RAIL_SELFTEST_DAC)); } -/* 自检**逻辑本身**得被测:拿真表跑一遍只能证明「现在没事」。 - 真表是 const 的,改不动,所以这里复制一份出来逐样改坏,看它认不认。 - 这几条同时也是「哪天有人把某个判据删了」的拦网。 */ +/* ---- 自检逻辑本身:喂坏表进去,它得认出来 -------------------------------- + 拿真表跑一遍只能证明「现在没事」,证明不了「真有事的时候查得出来」—— + 而真表是 const 的,测试改不动它。所以复制一份出来逐样改坏。 */ static void copy_real_table(rail_cfg_t *dst) { @@ -71,17 +99,19 @@ static void test_a_faithful_copy_of_the_real_table_passes(void) TEST_ASSERT_EQUAL_HEX32(0, rail_selftest_table(t)); /* 真表本身走的也是这条路 —— rail_cfg(1) 就是那张表的首行。 */ TEST_ASSERT_EQUAL_HEX32(0, rail_selftest_table(rail_cfg(1))); + /* NULL 不许悄悄当成「过了」。 */ TEST_ASSERT_TRUE((rail_selftest_table(NULL) & RAIL_SELFTEST_TABLE) != 0); } -/* 表和驱动分家:只改一格,而且改成一个**合法**的地址(0x43 是输入侧那片)。 - 这一手光看「地址是不是合法、有没有重复」是查不出来的。 */ +/* 表和驱动分家:只改一格,而且改成一个**合法**的值。 + 这一手光看「地址是不是合法、有没有重复」是查不出来的 —— + 板子照样跑,只是「读 CH3 的电流」读的是输入侧那片。 */ static void test_a_cell_that_drifted_from_the_driver_is_caught(void) { rail_cfg_t t[N]; copy_real_table(t); - t[2].ina_addr = 0x43; + t[2].ina_addr = INA226_ADDR_INPUT; /* 0x43 合法、板上真有这片 */ TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_TABLE, rail_selftest_table(t)); copy_real_table(t); @@ -89,168 +119,129 @@ static void test_a_cell_that_drifted_from_the_driver_is_caught(void) TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_TABLE, rail_selftest_table(t)); copy_real_table(t); - t[3].key_bit[SR595_KEY_VUP] = t[3].key_bit[SR595_KEY_VDN]; /* 音量键上下写反的近亲 */ - TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_TABLE, rail_selftest_table(t)); - - copy_real_table(t); - t[1].pg_bit = 4; + t[2].dac_addr = DAC63204_ADDR_U6; /* 「CH3 当然在 U6」—— R1 就踩过的那一脚 */ TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_TABLE, rail_selftest_table(t)); } -/* 引脚:重复 / 撞上别人 / strapping / 不存在的号 / 越界,五种都要认出来。 */ +/* ⭐ mux 通道的三种坏法。R2 里选错通道 = 读到另一台手机, + 量纲、范围、变化趋势全都正常,没有任何一层会报错(计划 §4.3 的 C-d)。 */ +static void test_every_kind_of_bad_mux_channel_is_caught(void) +{ + rail_cfg_t t[N]; + + /* ① 两路指向同一片刀片:CH4 的命令发给 CH1,CH4 读到的是 CH1 的电压电流。 + 五台手机全在跑,五路数字全都「正常」—— 只是有两路是同一份。 */ + copy_real_table(t); + t[3].mux_ch = t[0].mux_ch; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_MUX, rail_selftest_table(t)); + + /* ② 越界:TCA9548A 只有 8 路,控制字是掩码,写 8 进去移位就溢出了。 */ + copy_real_table(t); + t[4].mux_ch = TCA9548A_CH_COUNT; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_MUX, rail_selftest_table(t)); + copy_real_table(t); + t[0].mux_ch = 200; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_MUX, rail_selftest_table(t)); + + /* ③ ⭐ 通道 5 —— 那是 CH224A(PD 受电芯片)那一路,不是刀片。 + 选到它再去读 0x40 读不到任何东西,现象是「这一路的电压电流突然全是 0」, + 看上去像刀片被拔了,于是人会去拔插刀片、换刀片,而板子和刀片都没毛病。 */ + copy_real_table(t); + t[2].mux_ch = TCA9548A_CH_PD; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_MUX, rail_selftest_table(t)); + TEST_ASSERT_EQUAL_INT(5, TCA9548A_CH_PD); /* 把「5 是哪一路」也钉住 */ +} + +/* 引脚:重复、撞上别的信号、strapping、PSRAM、不存在的号、UART0、USB、越界。 + 每一种在真板上的后果都不一样,但共同点是**都不报错**。 */ static void test_every_kind_of_bad_pin_is_caught(void) { rail_cfg_t t[N]; + /* 同一路的两根脚撞在一起:`PRSNT#` 是输入、`EFUSE_EN` 是输出, + 配成输出之后 `slot_present()` 读回来的是固件自己写下去的值 —— 恒「在位」。 */ copy_real_table(t); - t[1].vbus_en = t[0].vbus_en; /* 两路共用一根 VBUS_EN */ + t[1].prsnt_n = t[1].efuse_en; TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + /* 两路共用一根 `EFUSE_EN`:关 CH1 把 CH2 也关了。 */ copy_real_table(t); - t[2].efuse_en = BOARD_PIN_I2C_SDA; /* 撞上 SDA:I²C 直接哑掉 */ + t[1].efuse_en = t[0].efuse_en; TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + /* 撞上 SDA / SCL:整条 I²C 哑掉,五路的电压电流、五片 PCA9555 一起失联。 */ copy_real_table(t); - t[0].efuse_en = 45; /* strapping */ + t[2].efuse_en = BOARD_PIN_I2C_SDA; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + copy_real_table(t); + t[2].prsnt_n = BOARD_PIN_I2C_SCL; TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + /* strapping(0/3/45/46):挂负载会改上电模式,现象是**有时候起不来**。 */ copy_real_table(t); - t[4].vbus_en = 24; /* S3 上根本没有 IO24:gpio_config() 不报错,什么也不做 */ + t[0].efuse_en = 45; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + copy_real_table(t); + t[0].prsnt_n = 0; /* IO0 在 R2 上还接着 J33 应急口的下载脚 */ TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + /* PSRAM(35/36/37,N16R8 的八线 PSRAM 占着):现象是跑着跑着内存错乱。 */ copy_real_table(t); - t[4].vbus_en = 36; /* R8 版八线 PSRAM 占着 */ + t[4].efuse_en = 36; TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + /* 不存在的脚。IO22~25 在 S3 上**根本没有**,IO26~32 是模组内部 flash。 + `gpio_config()` 对它们不报错,只是无声无息什么也不做 —— + 于是「这一路的 eFuse 既开不了也**关不掉**」,而固件一路 HAL_OK。 */ + copy_real_table(t); + t[4].efuse_en = 24; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + copy_real_table(t); + t[4].efuse_en = 30; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + + /* ⭐ UART0(43/44)= J33 应急口。Hub 挂掉(或者 `HUB_RST` 被误写)之后, + 它是**唯一**还能进得来的路;占掉它 = 出事那天连日志都读不出来。 */ + copy_real_table(t); + t[3].efuse_en = 43; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + copy_real_table(t); + t[3].prsnt_n = 44; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + + /* 原生 USB 的 D±(19/20)是硬连线,谁占了谁就断了下载与上位机 CDC。 */ + copy_real_table(t); + t[4].prsnt_n = BOARD_PIN_USB_DM; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + copy_real_table(t); + t[4].prsnt_n = BOARD_PIN_USB_DP; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); + + /* 纯越界:上界是 48(IO48 存在,`FAN_EN` 就在上面),负数同理。 */ copy_real_table(t); t[3].efuse_en = BOARD_PIN_NUM_MAX + 1; TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); - copy_real_table(t); t[3].efuse_en = -1; TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); - - /* 撞上 595 那四根线也算 —— 它们不在 board_pins.h 里,是从驱动的宏取的。 */ - copy_real_table(t); - t[0].vbus_en = SR595_PIN_RCLK; - TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_PINS, rail_selftest_table(t)); } -/* 两样一起坏,两位一起报 —— 别在第一个错上就 return。 */ -static void test_two_kinds_of_breakage_report_two_bits(void) +/* 三样一起坏,三位一起报 —— 别在第一个错上就 return。 + 自检只报第一项的话,修完一项还得重跑一次上电才能看见下一项。 */ +static void test_three_kinds_of_breakage_report_three_bits(void) { rail_cfg_t t[N]; copy_real_table(t); - t[2].ina_addr = 0x43; + t[2].ina_addr = INA226_ADDR_INPUT; t[0].efuse_en = 45; - TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_TABLE | RAIL_SELFTEST_PINS, rail_selftest_table(t)); + t[1].mux_ch = TCA9548A_CH_PD; + TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_TABLE | RAIL_SELFTEST_PINS | RAIL_SELFTEST_MUX, + rail_selftest_table(t)); } -/* ---- 验收主项二:无重复 --------------------------------------------------- */ +/* ---- 验收主项三:顺排也得查表 --------------------------------------------- */ -/* 六组编号,每一组五路两两不同。撞了的现象都是「两路共用一个东西」: - 共用 INA 地址 = 两路读到同一个电流;共用 (DAC 片, 通道) = 调这路那路也动; - 共用按键位 = 按 CH1 的电源键 CH4 也被按。 */ -static void test_no_two_rails_share_anything(void) -{ - for (int a = 1; a <= N; a++) { - const rail_cfg_t *x = rail_cfg(a); - for (int b = a + 1; b <= N; b++) { - const rail_cfg_t *y = rail_cfg(b); - - TEST_ASSERT_NOT_EQUAL(x->ina_addr, y->ina_addr); - TEST_ASSERT_FALSE(x->dac_addr == y->dac_addr && x->dac_ch == y->dac_ch); - TEST_ASSERT_NOT_EQUAL(x->edl_bit, y->edl_bit); - TEST_ASSERT_NOT_EQUAL(x->flt_bit, y->flt_bit); - TEST_ASSERT_NOT_EQUAL(x->pg_bit, y->pg_bit); - TEST_ASSERT_NOT_EQUAL(x->efuse_en, y->efuse_en); - TEST_ASSERT_NOT_EQUAL(x->vbus_en, y->vbus_en); - - for (int i = 0; i < SR595_KEY_COUNT; i++) { - TEST_ASSERT_NOT_EQUAL(x->key_bit[i], y->edl_bit); - TEST_ASSERT_NOT_EQUAL(y->key_bit[i], x->edl_bit); - for (int j = 0; j < SR595_KEY_COUNT; j++) { - TEST_ASSERT_NOT_EQUAL(x->key_bit[i], y->key_bit[j]); - } - } - } - /* 同一路自己的三个键也不许撞,而且都不许撞自己的 EDL 位。 */ - for (int i = 0; i < SR595_KEY_COUNT; i++) { - TEST_ASSERT_NOT_EQUAL(x->key_bit[i], x->edl_bit); - for (int j = i + 1; j < SR595_KEY_COUNT; j++) { - TEST_ASSERT_NOT_EQUAL(x->key_bit[i], x->key_bit[j]); - } - } - /* 一路自己的两根 EN 更不能是同一个脚。 */ - TEST_ASSERT_NOT_EQUAL(x->efuse_en, x->vbus_en); - } -} - -/* 24 位链上还蹲着 `HUB_RST` 和三个空脚 —— 五路的 20 个位一个都不许踩上去。 */ -static void test_shift_chain_bits_avoid_hub_reset_and_the_unused_pins(void) -{ - uint32_t seen = 0; - for (int rail = 1; rail <= N; rail++) { - const rail_cfg_t *c = rail_cfg(rail); - for (int i = 0; i < SR595_KEY_COUNT; i++) { - seen |= (uint32_t)1u << c->key_bit[i]; - } - seen |= (uint32_t)1u << c->edl_bit; - } - TEST_ASSERT_EQUAL_HEX32(0, seen & SR595_MASK_UNUSED); - TEST_ASSERT_EQUAL_HEX32(0, seen & ((uint32_t)1u << SR595_BIT_HUB_RST)); - TEST_ASSERT_EQUAL_HEX32(seen, seen & SR595_MASK_VALID); - /* 5×3 键 + 5 EDL = 20 位,一位不多一位不少。 */ - int n = 0; - for (int b = 0; b < SR595_BITS; b++) { - if ((seen & ((uint32_t)1u << b)) != 0) { - n++; - } - } - TEST_ASSERT_EQUAL_INT(20, n); -} - -/* ---- 验收主项二续:无越界 ------------------------------------------------- */ - -static void test_every_field_is_in_range(void) -{ - for (int rail = 1; rail <= N; rail++) { - const rail_cfg_t *c = rail_cfg(rail); - - TEST_ASSERT_TRUE(c->ina_addr <= 0x7f); - TEST_ASSERT_TRUE(c->dac_addr == DAC63204_ADDR_U6 || c->dac_addr == DAC63204_ADDR_U7); - TEST_ASSERT_TRUE(c->dac_ch <= 3); - /* 该通道必须在那片的「真的接了线」位图里 —— 没接线的通道 FBx 悬空, - 上电就是开环(datasheet 表 5-1)。 */ - TEST_ASSERT_TRUE((dac63204_used_mask(c->dac_addr) & (1u << c->dac_ch)) != 0); - - for (int i = 0; i < SR595_KEY_COUNT; i++) { - TEST_ASSERT_TRUE(c->key_bit[i] < SR595_BITS); - } - TEST_ASSERT_TRUE(c->edl_bit < SR595_BITS); - TEST_ASSERT_TRUE(c->flt_bit < PCA9555_RAIL_COUNT); /* Port0 bit0~4 */ - TEST_ASSERT_TRUE(c->pg_bit < PCA9555_RAIL_COUNT); /* Port1 bit0~4 */ - } -} - -/* FLT / PG 分属两个端口,所以两者位号相同是**对的**,不是撞车。 - 这条把「顺手给 PG 表加个偏移让它和 FLT 不一样」这种好心办坏事拦下来。 */ -static void test_flt_and_pg_live_on_different_ports_so_equal_bits_are_fine(void) -{ - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT(pca9555_flt_bit(rail), rail_cfg(rail)->flt_bit); - TEST_ASSERT_EQUAL_INT(pca9555_pg_bit(rail), rail_cfg(rail)->pg_bit); - } -} - -/* ---- 验收主项三:一列都不许是算式 ----------------------------------------- */ - -/* 六组编号里但凡有一组能被 `rail` 算出来,就会有人把它算出来。 - - 这条**不去猜别人会写哪个算式**(猜错了就成了一句空话),而是直接断言那几列 - 「不是等差数列」—— 任何 `起点 + 步长 × ch` 形式的算式都被它一次拦掉。 - ⚠️ 逐个格子去撞算式是不管用的:CH4 的按键位正好是 9/10/11,CH1 的 EDL 位 - 正好是 16,单看一路都「像顺排」。看整列才看得出来。 */ +/* 「起点 + 步长 × ch」形式的算式一次拦掉,不去猜别人会写哪一个。 */ static bool is_arithmetic(const int *v, int n) { const int step = v[1] - v[0]; @@ -262,47 +253,56 @@ static bool is_arithmetic(const int *v, int n) return true; } -static void test_no_column_is_an_arithmetic_progression(void) +/* ⭐⭐ 这条是本文件的核心。 + + R2 的 `mux_ch`(0 1 2 3 4)与 `EFUSE_EN`(IO9~IO13)**现在确实是顺排的**, + 这条用例把这个事实写下来 —— 但写下来的是「事实」,不是「不变量」: + 下面紧跟着的 `PRSNT#` 那一列,四路顺排、第五路是 IO7,就是现成的反例。 + + ⚠️ 把 `PRSNT#` 写成 IO(14+rail) 会怎样:CH5 去读 **IO19**,那是原生 USB 的 D−。 + 读回来的电平和「刀片在不在位」毫无关系,于是 CH5 空槽照样允许上电 —— + 而空槽上电意味着金手指裸露带 12V(计划 §5.8)。 + ⚠️ 为什么功能测试看不出来:五片刀片插满的时候,IO19 读成什么都不影响运行; + 只有「CH5 槽空着、又下了上电命令」这一种组合才会现形,而那正是没人会去测的组合。 */ +static void test_sequential_columns_are_facts_not_invariants(void) { - int ina[N], pwr[N], vup[N], vdn[N], edl[N], efuse[N], vbus[N]; + int mux[N], efuse[N], prsnt[N]; for (int rail = 1; rail <= N; rail++) { const rail_cfg_t *c = rail_cfg(rail); - ina[rail - 1] = c->ina_addr; - pwr[rail - 1] = c->key_bit[SR595_KEY_PWR]; - vup[rail - 1] = c->key_bit[SR595_KEY_VUP]; - vdn[rail - 1] = c->key_bit[SR595_KEY_VDN]; - edl[rail - 1] = c->edl_bit; + mux[rail - 1] = (int)c->mux_ch; efuse[rail - 1] = c->efuse_en; - vbus[rail - 1] = c->vbus_en; + prsnt[rail - 1] = c->prsnt_n; } - TEST_ASSERT_FALSE_MESSAGE(is_arithmetic(ina, N), "INA226 地址不是顺排(0x40+ch 是错的)"); - TEST_ASSERT_FALSE_MESSAGE(is_arithmetic(pwr, N), "PWR 键位不是顺排"); - TEST_ASSERT_FALSE_MESSAGE(is_arithmetic(vup, N), "VUP 键位不是顺排"); - TEST_ASSERT_FALSE_MESSAGE(is_arithmetic(vdn, N), "VDN 键位不是顺排"); - TEST_ASSERT_FALSE_MESSAGE(is_arithmetic(edl, N), "EDL 位不是顺排"); - TEST_ASSERT_FALSE_MESSAGE(is_arithmetic(efuse, N), "EFUSE_EN 引脚不是顺排"); - TEST_ASSERT_FALSE_MESSAGE(is_arithmetic(vbus, N), "VBUS_EN 引脚不是顺排"); -} + /* 两列「现在是顺排」的事实。改了先来改这条,别默默改表。 */ + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT_MESSAGE(rail - 1, mux[rail - 1], "mux_ch 当下就是 rail−1"); + TEST_ASSERT_EQUAL_INT_MESSAGE(8 + rail, efuse[rail - 1], "EFUSE_EN 当下就是 IO(8+rail)"); + } + TEST_ASSERT_TRUE(is_arithmetic(mux, N)); + TEST_ASSERT_TRUE(is_arithmetic(efuse, N)); -/* 需求 §10.4 点名的那个算式:`0x40 + ch`。**只有 CH3/CH4/CH5 是错的** —— - 前两路对,所以「拿 CH1 试一下没问题」是这条错误最常见的验证方式。 */ -static void test_ina_addr_formula_is_wrong_for_exactly_three_rails(void) -{ + /* ⭐ 反例:`PRSNT#` 不是等差数列,任何 `起点 + 步长 × rail` 都描述不了它。 */ + TEST_ASSERT_FALSE_MESSAGE(is_arithmetic(prsnt, N), "PRSNT# 到第五路就断了,不许算"); + + /* 而且那个算式**只有一路错** —— 「拿 CH1 试一下没问题」正是它会被放过的原因。 */ int wrong = 0; for (int rail = 1; rail <= N; rail++) { - if (rail_cfg(rail)->ina_addr != (uint8_t)(0x40 + rail - 1)) { + if (prsnt[rail - 1] != 14 + rail) { wrong++; } } - TEST_ASSERT_EQUAL_INT(3, wrong); - TEST_ASSERT_EQUAL_HEX8(0x40, rail_cfg(1)->ina_addr); /* 算式在这两路上是对的 */ - TEST_ASSERT_EQUAL_HEX8(0x41, rail_cfg(2)->ina_addr); + TEST_ASSERT_EQUAL_INT_MESSAGE(1, wrong, "IO(14+rail) 只在 CH5 上是错的"); + TEST_ASSERT_EQUAL_INT_MESSAGE(7, prsnt[4], "PRSNT5# 是 IO7(方案 B 重排)"); + + /* 算错之后落到哪儿:IO19 = 原生 USB 的 D−。把这件事写进断言, + 免得哪天有人觉得「不过是错了一根脚」。 */ + TEST_ASSERT_EQUAL_INT_MESSAGE(BOARD_PIN_USB_DM, 14 + 5, "算错的 PRSNT5# 正好落在 USB D− 上"); } -/* DAC 那两列不是等差数列能描述的(片 + 通道两个量),单独钉死: - 「前三路在 U6、通道号 = ch−1」这句话五路里有四路不成立。 */ +/* DAC 那两列 **R2 一个字没改**,坑原样还在:「前三路在 U6、通道号 = rail−1」 + 这句话五路里有四路不成立。接错了板子照样正常工作,只是调 CH1 的命令调了 CH2。 */ static void test_dac_mapping_is_not_the_obvious_one(void) { int wrong = 0; @@ -314,30 +314,40 @@ static void test_dac_mapping_is_not_the_obvious_one(void) } } TEST_ASSERT_EQUAL_INT_MESSAGE(4, wrong, "只有 CH5 正好撞上那个算式"); + TEST_ASSERT_EQUAL_HEX8_MESSAGE(DAC63204_ADDR_U7, rail_cfg(3)->dac_addr, "CH3 的 DAC 在 U7"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(2, rail_cfg(1)->dac_ch, "CH1 挂在 U6.OUT2,不是 OUT0"); } -/* FLT / PG 这两列**确实是顺排的**(§10.4 特意说明)。写在这里是为了让 - 「哪些列可以是算式」有个明面上的清单:以后有人给 PG 加个偏移,这条会红。 */ -static void test_flt_and_pg_really_are_sequential(void) +/* ⭐ 五路的 INA226 **全是 0x40**,这不是 bug(五片刀片完全同构,D8)。 + ⚠️ 于是「拿到地址就直接读」在 R2 上读的是**上一次选中的那一路** —— + 真正区分是哪一路的是 `mux_ch`。这条把两件事一起钉住。 */ +static void test_all_five_ina226_share_one_address_and_mux_is_what_separates_them(void) { for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_UINT8(rail - 1, rail_cfg(rail)->flt_bit); - TEST_ASSERT_EQUAL_UINT8(rail - 1, rail_cfg(rail)->pg_bit); + TEST_ASSERT_EQUAL_HEX8_MESSAGE(INA226_ADDR_RAIL, rail_cfg(rail)->ina_addr, + "五路同址是设计,不是笔误"); + } + /* 输入侧那片必须不同址:它挂主干,和刀片那片**同时可见**。 */ + TEST_ASSERT_NOT_EQUAL(INA226_ADDR_RAIL, INA226_ADDR_INPUT); + /* 区分五路的是通道,而通道两两不同 —— 这一句才是「同址无害」的前提。 */ + for (int a = 1; a <= N; a++) { + for (int b = a + 1; b <= N; b++) { + TEST_ASSERT_NOT_EQUAL(rail_cfg(a)->mux_ch, rail_cfg(b)->mux_ch); + } } } -/* 抽三个最容易被「顺手改顺」的格子,直接钉死实测值(出处:§8.2 / §26.18 C / §8.1, - 都从 `.claude/pcb1-parts.json` 的真实焊盘网络核过)。 */ -static void test_the_three_most_surprising_cells(void) +/* 抽几个最容易被「顺手改顺」的格子,直接钉死实测值 + (出处:`.claude/r2/_base_pcb_nets.json` 的 `U35`/`U20` 与 §5.7,2026-09-16 逐格核过)。 */ +static void test_the_most_surprising_cells(void) { - TEST_ASSERT_EQUAL_HEX8_MESSAGE(0x44, rail_cfg(3)->ina_addr, "CH3 的 INA226 是 0x44 不是 0x42"); - TEST_ASSERT_EQUAL_HEX8_MESSAGE(DAC63204_ADDR_U7, rail_cfg(3)->dac_addr, "CH3 的 DAC 在 U7"); - TEST_ASSERT_EQUAL_INT_MESSAGE(0, rail_cfg(4)->edl_bit, "EDL_EN4 在 U21.QA,也就是 bit0"); - TEST_ASSERT_EQUAL_UINT8_MESSAGE(2, rail_cfg(1)->dac_ch, "CH1 挂在 U6.OUT2,不是 OUT0"); - TEST_ASSERT_EQUAL_INT_MESSAGE(48, rail_cfg(2)->efuse_en, "EFUSE_EN2 在 IO48"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(4, rail_cfg(5)->mux_ch, "CH5 在通道 4,通道 5 是 CH224A"); + TEST_ASSERT_EQUAL_INT_MESSAGE(7, rail_cfg(5)->prsnt_n, "PRSNT5# 是 IO7 不是 IO19"); + TEST_ASSERT_EQUAL_INT_MESSAGE(9, rail_cfg(1)->efuse_en, "EFUSE_EN1 是 IO9(R1 是 IO39)"); + TEST_ASSERT_EQUAL_HEX8_MESSAGE(DAC63204_ADDR_U7, rail_cfg(5)->dac_addr, "CH5 的 DAC 也在 U7"); } -/* ---- 表与四个驱动没有分家 ------------------------------------------------- */ +/* ---- 表与驱动没有分家(外面独立比一遍) ----------------------------------- */ /* `rail_selftest()` 内部也比一遍,但那是**被测代码自己**在比。 这一条是外面独立比的:内部那段哪天被删了,这里照样红。 */ @@ -349,20 +359,59 @@ static void test_table_matches_every_driver_table(void) TEST_ASSERT_EQUAL_HEX8(ina226_rail_addr(rail), c->ina_addr); TEST_ASSERT_EQUAL_HEX8(dac63204_rail_addr(rail), c->dac_addr); TEST_ASSERT_EQUAL_UINT8(dac63204_rail_ch(rail), c->dac_ch); - TEST_ASSERT_EQUAL_INT(sr595_key_bit(rail, SR595_KEY_PWR), c->key_bit[SR595_KEY_PWR]); - TEST_ASSERT_EQUAL_INT(sr595_key_bit(rail, SR595_KEY_VUP), c->key_bit[SR595_KEY_VUP]); - TEST_ASSERT_EQUAL_INT(sr595_key_bit(rail, SR595_KEY_VDN), c->key_bit[SR595_KEY_VDN]); - TEST_ASSERT_EQUAL_INT(sr595_edl_bit(rail), c->edl_bit); - TEST_ASSERT_EQUAL_INT(pca9555_flt_bit(rail), c->flt_bit); - TEST_ASSERT_EQUAL_INT(pca9555_pg_bit(rail), c->pg_bit); + /* 路号 → 通道的唯一换算点在 `slot_mux_ch()`,它必须查的是同一张表。 */ + TEST_ASSERT_EQUAL_INT(slot_mux_ch(rail), (int)c->mux_ch); } } -/* ---- 引脚 ----------------------------------------------------------------- */ - -static bool in_list(const int *v, int n, int pin) +/* 两路共用一个东西的现象:共用 (DAC 片, 通道) = 调这路那路也动; + 共用 mux 通道 = 两路读到同一台手机;共用 GPIO = 关这路那路也关。 */ +static void test_no_two_rails_share_anything(void) { - for (int i = 0; i < n; i++) { + for (int a = 1; a <= N; a++) { + const rail_cfg_t *x = rail_cfg(a); + for (int b = a + 1; b <= N; b++) { + const rail_cfg_t *y = rail_cfg(b); + + TEST_ASSERT_FALSE(x->dac_addr == y->dac_addr && x->dac_ch == y->dac_ch); + TEST_ASSERT_NOT_EQUAL(x->mux_ch, y->mux_ch); + TEST_ASSERT_NOT_EQUAL(x->efuse_en, y->efuse_en); + TEST_ASSERT_NOT_EQUAL(x->prsnt_n, y->prsnt_n); + TEST_ASSERT_NOT_EQUAL(x->efuse_en, y->prsnt_n); + TEST_ASSERT_NOT_EQUAL(x->prsnt_n, y->efuse_en); + } + /* 一路自己的输出脚与输入脚更不能是同一个。 */ + TEST_ASSERT_NOT_EQUAL(x->efuse_en, x->prsnt_n); + } +} + +static void test_every_field_is_in_range(void) +{ + for (int rail = 1; rail <= N; rail++) { + const rail_cfg_t *c = rail_cfg(rail); + + TEST_ASSERT_TRUE(c->ina_addr <= 0x7f); + TEST_ASSERT_TRUE(c->dac_addr == DAC63204_ADDR_U6 || c->dac_addr == DAC63204_ADDR_U7); + TEST_ASSERT_TRUE(c->dac_ch <= 3); + /* 该通道必须在那片「真的接了线」的位图里 —— 没接线的通道 FBx 悬空, + 上电就是开环(datasheet 表 5-1)。 */ + TEST_ASSERT_TRUE((dac63204_used_mask(c->dac_addr) & (1u << c->dac_ch)) != 0); + + TEST_ASSERT_TRUE(c->mux_ch < TCA9548A_CH_COUNT); + TEST_ASSERT_NOT_EQUAL(TCA9548A_CH_PD, c->mux_ch); + } +} + +/* ---- ⭐ 全板引脚不重复 ---------------------------------------------------- + + 查的是**全板**,不只是五路那十个脚:重复的两个脚一定分属两个模块, + 只查自己那十个的话,「`PRSNT3#` 写成了 IO41」(撞上 SDA)这种错查不出来。 + R2 的宏名与值和 R1 **一个都不一样**,所以这张清单是照着 `board_pins.h` + 重列的,不是从 R1 那版改出来的。 */ + +static bool in_list(const int *v, size_t n, int pin) +{ + for (size_t i = 0; i < n; i++) { if (v[i] == pin) { return true; } @@ -370,99 +419,117 @@ static bool in_list(const int *v, int n, int pin) return false; } -/* 十根 EN 不许踩到 strapping / 不存在的号 / R8 版 PSRAM 占的号, - 也不许撞上全板任何一个已经有主的脚。 */ -static void test_en_pins_do_not_collide_with_anything_else(void) +static void test_no_pin_is_used_twice_on_the_whole_board(void) { + static const int fixed[] = { + BOARD_PIN_I2C_SDA, BOARD_PIN_I2C_SCL, BOARD_PIN_MUX_RST_N, + BOARD_PIN_IOEXP_INT_N, BOARD_PIN_INA_ALERT_N, BOARD_PIN_HUB_RST_N, + BOARD_PIN_UP_VBUS_DET, BOARD_PIN_NTC1, BOARD_PIN_NTC2, + BOARD_PIN_FAN_PWM, BOARD_PIN_FAN_EN, BOARD_PIN_FAN_TACH_IN, + BOARD_PIN_FAN_TACH_OUT, BOARD_PIN_USB_DM, BOARD_PIN_USB_DP, + }; static const int absent[] = BOARD_PINS_ABSENT; static const int strapping[] = BOARD_PINS_STRAPPING; static const int reserved[] = BOARD_PINS_RESERVED; - static const int others[] = { - BOARD_PIN_I2C_SDA, BOARD_PIN_I2C_SCL, BOARD_PIN_FAN_TACH, BOARD_PIN_FAN_PWM, - BOARD_PIN_NTC1, BOARD_PIN_NTC2, BOARD_PIN_USB_DM, BOARD_PIN_USB_DP, - BOARD_PIN_UP_VBUS_DET, SR595_PIN_SER, SR595_PIN_SRCLK, SR595_PIN_RCLK, - SR595_PIN_OE_N, PCA9555_PIN_INT_N, - }; + static const int uart0[] = BOARD_PINS_UART0; + int all[sizeof(fixed) / sizeof(fixed[0]) + 2 * N]; + size_t n = 0; + for (size_t i = 0; i < sizeof(fixed) / sizeof(fixed[0]); i++) { + all[n++] = fixed[i]; + } for (int rail = 1; rail <= N; rail++) { - const int pins[2] = { rail_cfg(rail)->efuse_en, rail_cfg(rail)->vbus_en }; - for (int i = 0; i < 2; i++) { - TEST_ASSERT_TRUE(pins[i] >= 0 && pins[i] <= BOARD_PIN_NUM_MAX); - TEST_ASSERT_FALSE(in_list(absent, (int)(sizeof(absent) / sizeof(int)), pins[i])); - TEST_ASSERT_FALSE(in_list(strapping, (int)(sizeof(strapping) / sizeof(int)), pins[i])); - TEST_ASSERT_FALSE(in_list(reserved, (int)(sizeof(reserved) / sizeof(int)), pins[i])); - TEST_ASSERT_FALSE(in_list(others, (int)(sizeof(others) / sizeof(int)), pins[i])); + all[n++] = rail_cfg(rail)->efuse_en; + all[n++] = rail_cfg(rail)->prsnt_n; + } + TEST_ASSERT_EQUAL_UINT32(sizeof(all) / sizeof(all[0]), (uint32_t)n); + + for (size_t i = 0; i < n; i++) { + TEST_ASSERT_TRUE(all[i] >= 0 && all[i] <= BOARD_PIN_NUM_MAX); + TEST_ASSERT_FALSE(in_list(absent, sizeof(absent) / sizeof(absent[0]), all[i])); + TEST_ASSERT_FALSE(in_list(strapping, sizeof(strapping) / sizeof(strapping[0]), all[i])); + TEST_ASSERT_FALSE(in_list(reserved, sizeof(reserved) / sizeof(reserved[0]), all[i])); + /* USB 的 D± 本来就在 `fixed` 里,所以 UART0 那条单独查五路那十个 —— + `fixed` 里没有 43/44,它们是**留给应急口的空位**,不是已分配的脚。 */ + TEST_ASSERT_FALSE(in_list(uart0, sizeof(uart0) / sizeof(uart0[0]), all[i])); + for (size_t j = i + 1; j < n; j++) { + TEST_ASSERT_NOT_EQUAL(all[i], all[j]); } } } -/* ⚠️ 两列 GPIO 是**全固件唯一一处**出处 —— 没有第二张表能和它对账 - (INA/DAC/595/PCA 那四列都有驱动那边兜着,这两列没有)。 - 所以这里把十个值连同模组脚号一起钉死;出处是 `.claude/pcb1-parts.json` 里 - U20 的真实焊盘网络(2026-09-07 逐脚核过),改这条之前先回去重核一遍。 - ⚠️ 光靠自检查不出「`EFUSE_EN1` 和 `VBUS_EN1` 写反了」:两个都是合法的空闲脚、 - 不重复、不越界。那种错只有这条测试和板上实测能拦。 */ -static void test_en_pins_match_the_pcb_netlist(void) +/* 十根脚是**全固件唯一一处**出处 —— 没有第二张表能和它对账 + (INA / DAC 两列有驱动那边兜着,这两列没有)。 + 出处是 `.claude/r2/_base_pcb_nets.json` 里 `U20`(WROOM-1)的真实焊盘网络 + (2026-09-16 逐脚核过),改这条之前先回去重核一遍。 + ⚠️ 光靠自检查不出「`EFUSE_EN1` 和 `PRSNT1#` 写反了」:两个都是合法的空闲脚、 + 不重复、不越界。那种错只有这条和板上实测能拦。 */ +static void test_rail_pins_match_the_pcb_netlist(void) { - /* EFUSE_EN VBUS_EN (模组脚号写在注释里) */ + /* EFUSE_EN PRSNT# (模组脚号写在注释里) */ static const int want[N][2] = { - { 39, 40 }, /* CH1: 脚32 / 脚33 */ - { 48, 10 }, /* CH2: 脚25 / 脚18 */ - { 6, 11 }, /* CH3: 脚6 / 脚19 */ - { 41, 42 }, /* CH4: 脚34 / 脚35 */ - { 8, 17 }, /* CH5: 脚12 / 脚10 */ + { 9, 15 }, /* CH1: 脚17 / 脚8 */ + { 10, 16 }, /* CH2: 脚18 / 脚9 */ + { 11, 17 }, /* CH3: 脚19 / 脚10 */ + { 12, 18 }, /* CH4: 脚20 / 脚11 */ + { 13, 7 }, /* CH5: 脚21 / 脚7 ← 不接着顺排 */ }; for (int rail = 1; rail <= N; rail++) { TEST_ASSERT_EQUAL_INT(want[rail - 1][0], rail_cfg(rail)->efuse_en); - TEST_ASSERT_EQUAL_INT(want[rail - 1][1], rail_cfg(rail)->vbus_en); + TEST_ASSERT_EQUAL_INT(want[rail - 1][1], rail_cfg(rail)->prsnt_n); } } +/* 两路 NTC 必须落在 ADC1(IO1~IO10):ADC2 在 WiFi 开着的时候读不了, + 而本板 WiFi 常开(§10.8 传输 A)。 + ⚠️ 挪到 ADC2 **不会报错**,只会在连上 WiFi 之后开始读失败 —— + 台架上不连 WiFi 一切正常,很难复现。 */ static void test_ntc_pins_are_on_adc1(void) { - /* ADC2 在 WiFi 开着的时候读不了,而本板 WiFi 常开。 */ - TEST_ASSERT_TRUE(BOARD_PIN_NTC1 >= 1 && BOARD_PIN_NTC1 <= 10); - TEST_ASSERT_TRUE(BOARD_PIN_NTC2 >= 1 && BOARD_PIN_NTC2 <= 10); + TEST_ASSERT_TRUE_MESSAGE(BOARD_PIN_NTC1 >= 1 && BOARD_PIN_NTC1 <= 10, "NTC1 必须在 ADC1"); + TEST_ASSERT_TRUE_MESSAGE(BOARD_PIN_NTC2 >= 1 && BOARD_PIN_NTC2 <= 10, "NTC2 必须在 ADC1"); + /* 自检也得认这件事 —— 上面两句只是在核对头文件,这句核对判据还在。 */ + TEST_ASSERT_EQUAL_HEX32(0, rail_selftest_table(rail_cfg(1)) & RAIL_SELFTEST_PINS); } -/* ⚠️ 回归拦网:`EFUSE_EN2` 在 **IO48** 上,而 ESP32-S3 的编号是 0~48。 - HAL 两边原来的边界都是 `pin < 48`,正好把它关在门外 —— - 现象是 CH2 的 eFuse 既开不了也**关不掉**。 */ +/* ⚠️ 回归拦网:ESP32-S3 的编号是 0~48,而 `FAN_EN` 就在 **IO48** 上。 + HAL 两边的边界原来写的是 `pin < 48`,正好把它关在门外 —— + 现象是风扇既开不了也关不掉,而固件一路 HAL_OK。 */ static void test_io48_is_a_usable_pin(void) { - TEST_ASSERT_EQUAL_INT(48, rail_cfg(2)->efuse_en); + TEST_ASSERT_EQUAL_INT(48, BOARD_PIN_FAN_EN); + TEST_ASSERT_EQUAL_INT(48, BOARD_PIN_NUM_MAX); TEST_ASSERT_EQUAL_INT(HAL_OK, hal_gpio_config(48, HAL_GPIO_OUTPUT)); TEST_ASSERT_EQUAL_INT(HAL_OK, hal_gpio_write(48, 1)); TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(48)); - /* 49 就真的越界了。 */ - TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, hal_gpio_config(49, HAL_GPIO_OUTPUT)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, hal_gpio_config(49, HAL_GPIO_OUTPUT)); /* 49 才是越界 */ } -/* ---- 表驱动遍历:两根 EN 线 ----------------------------------------------- */ +/* ---- `EFUSE_EN`:表驱动遍历 ----------------------------------------------- */ -static void assert_all_off(void) +static void assert_all_efuse_off(void) { for (int rail = 1; rail <= N; rail++) { TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(rail)->efuse_en)); - TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(rail)->vbus_en)); + TEST_ASSERT_FALSE(rail_efuse_is_on(rail)); } } -/* 上电第一件事:十个脚都配成输出、都是低。「五路全关」不许靠某个默认值。 */ -static void test_en_init_leaves_all_ten_pins_driven_low(void) +/* 上电第一件事:五个脚都配成输出、都是低。「五路全关」不许靠某个默认值 —— + DAC 上电那一刻输出就有电压了,eFuse 的 EN 这时必须已经确定是低的。 */ +static void test_en_init_leaves_all_five_pins_driven_low(void) { TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); - assert_all_off(); + assert_all_efuse_off(); for (int rail = 1; rail <= N; rail++) { /* sim_gpio_level() 对没配过的脚返回 −1,所以上面那句已经证明配过了; 这里再确认「真的写过一次 0」,而不只是配置时的默认值。 */ TEST_ASSERT_TRUE(sim_gpio_writes(rail_cfg(rail)->efuse_en) >= 1); - TEST_ASSERT_TRUE(sim_gpio_writes(rail_cfg(rail)->vbus_en) >= 1); } } -/* 开一路只动那一路的那一个脚。这条拦的是「rail−1 当下标」那类错位: - 错位之后打开的是别人,而两个脚都是好的,功能上「有一路开了」。 */ +/* 开一路只动那一路的那一个脚。拦的是「rail−1 当下标」那类错位: + 错位之后打开的是别人,而功能上「确实有一路开了」。 */ static void test_enabling_one_rail_touches_exactly_one_pin(void) { TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); @@ -472,23 +539,16 @@ static void test_enabling_one_rail_touches_exactly_one_pin(void) for (int rail = 1; rail <= N; rail++) { const int want = (rail == on) ? 1 : 0; TEST_ASSERT_EQUAL_INT(want, sim_gpio_level(rail_cfg(rail)->efuse_en)); - /* VBUS 一根都不许跟着动 —— 两张表混用是这里最像的错。 */ - TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(rail)->vbus_en)); + TEST_ASSERT_EQUAL_INT(want, rail_efuse_is_on(rail) ? 1 : 0); + /* `PRSNT#` 是输入,一根都不许被当成输出写 —— 写了就把上拉打死了。 */ + TEST_ASSERT_EQUAL_UINT32(0, sim_gpio_writes(rail_cfg(rail)->prsnt_n)); } TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(on, false)); } - - for (int on = 1; on <= N; on++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(on, true)); - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT((rail == on) ? 1 : 0, sim_gpio_level(rail_cfg(rail)->vbus_en)); - TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(rail)->efuse_en)); - } - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(on, false)); - } + assert_all_efuse_off(); } -/* 高有效:true = 导通。写反了板子上是「命令开电结果全关」,一眼看得出来, +/* 高有效:true = 导通。写反了「命令开电结果全关」一眼看得出来, 但**关电命令会变成开电**,那才是危险的一半。 */ static void test_enable_is_active_high(void) { @@ -499,111 +559,206 @@ static void test_enable_is_active_high(void) TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(1)->efuse_en)); } -/* 全关:十个脚都回到低,而且**五根 VBUS 全部早于任何一根 eFuse** —— - 反过来的话中间有一小段「手机只靠 USB 供着电」。 */ -static void test_all_off_cuts_vbus_before_efuse(void) +/* 影子**只在写成功时更新**。A10 的判据靠它决定「这一路现在该不该有电」: + 影子说 on、线上其实没写进去,判据就会武装在一条关着的路上, + 于是每一圈都得出「偏低 3900 mV」并报过压/欠压故障。 */ +static void test_shadow_only_moves_when_the_write_succeeded(void) { TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, true)); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(rail, true)); - } + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(3, true)); + TEST_ASSERT_TRUE(rail_efuse_is_on(3)); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_all_off()); - assert_all_off(); + /* 把那个脚改回输入 —— 之后写它必然失败(HAL 会拦)。 */ + const int pin = rail_cfg(3)->efuse_en; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_gpio_config(pin, HAL_GPIO_INPUT)); + TEST_ASSERT_NOT_EQUAL(HAL_OK, rail_efuse_enable(3, false)); + TEST_ASSERT_TRUE_MESSAGE(rail_efuse_is_on(3), "关失败 ⇒ 影子留在 on ⇒ 判据继续武装"); - uint32_t last_vbus = 0, first_efuse = 0xFFFFFFFFu; - for (int rail = 1; rail <= N; rail++) { - const uint32_t v = sim_gpio_write_seq(rail_cfg(rail)->vbus_en); - const uint32_t e = sim_gpio_write_seq(rail_cfg(rail)->efuse_en); - if (v > last_vbus) { - last_vbus = v; - } - if (e < first_efuse) { - first_efuse = e; - } - } - TEST_ASSERT_TRUE_MESSAGE(last_vbus < first_efuse, "最后一根 VBUS 必须早于第一根 eFuse"); -} - -/* 关电路径上「有一个脚失败就别管剩下九个了」是最坏的选择。 - 注错的办法是把 CH1 的 VBUS 脚偷偷改回输入 —— 之后写它必然 HAL_ERR_ARG, - 而剩下九个脚该关的还得关。 */ -static void test_all_off_keeps_going_after_one_pin_fails(void) -{ - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, true)); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(rail, true)); - } - - const int broken = rail_cfg(1)->vbus_en; - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_gpio_config(broken, HAL_GPIO_INPUT)); - const uint32_t before = sim_gpio_writes(broken); - - TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, rail_all_off()); /* 如实报第一个错误码 */ - TEST_ASSERT_EQUAL_UINT32(before, sim_gpio_writes(broken)); /* 那个脚确实没写成 */ - - /* 另外九个必须都已经关掉了。 */ - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(rail)->efuse_en)); - if (rail != 1) { - TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(rail)->vbus_en)); - } - } -} - -/* 没 rail_en_init() 过就调,十个脚全失败:返回错误码,不崩、不乱写。 */ -static void test_all_off_before_init_reports_an_error(void) -{ - TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, rail_all_off()); - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT(-1, sim_gpio_level(rail_cfg(rail)->efuse_en)); /* 没配过 */ - TEST_ASSERT_EQUAL_INT(-1, sim_gpio_level(rail_cfg(rail)->vbus_en)); - } -} - -static void test_rail_number_out_of_range_is_rejected(void) -{ - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); + /* 越界的路号既不许崩,也不许把别人的影子改掉。 */ TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, rail_efuse_enable(0, true)); TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, rail_efuse_enable(N + 1, true)); TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, rail_efuse_enable(-1, false)); - TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, rail_vbus_enable(0, true)); - TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, rail_vbus_enable(N + 1, true)); - assert_all_off(); /* 一个脚都没被越界的调用碰过 */ + TEST_ASSERT_FALSE(rail_efuse_is_on(0)); + TEST_ASSERT_FALSE(rail_efuse_is_on(N + 1)); + for (int rail = 1; rail <= N; rail++) { + if (rail != 3) { + TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(rail)->efuse_en)); + } + } +} + +/* ---- `rail_all_off()`:先 I²C 后 GPIO ------------------------------------- */ + +/* 一块「底板 + 五片刀片」的假板子:假 TCA9548A 挂主干,五片假 PCA9555 + 各在自己那条支路上(五片同址 0x20,正是 R2 的设计)。 */ +static void mount_board(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); + sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N); + for (int rail = 1; rail <= N; rail++) { + sim_pca9555_attach_ch(rail - 1, slot_mux_ch(rail), PCA9555_ADDR); + } + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail)); + } +} + +/* 五路全开:eFuse(GPIO)+ VBUS(I²C)。 */ +static void power_everything_up(void) +{ + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, true)); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(rail, true)); + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(rail - 1, 0, PCA9555_BIT_VBUS_EN)); + } +} + +/* 「先断 VBUS 再关 eFuse」在 sim 上**没有现成的观测面**:VBUS 走 I²C、 + eFuse 走 GPIO,两个计数器不在同一个序列里(`sim_gpio_write_seq()` 只数 GPIO)。 + 所以这里在假 PCA9555 的写钩子上**串一层**:每当有人写那片 PCA9555 + (断 VBUS 就是这么断的),顺手抄一眼五根 `EFUSE_EN` 的电平。 + 进 `rail_all_off()` 之前五根都是高,所以只要抄到一个 0, + 就说明 eFuse 已经被提前关掉了 —— 那正是要拦的顺序反转。 */ +static sim_i2c_hook_t s_orig_hook; +static uint32_t s_efuse_low_seen; + +static void order_spy(sim_i2c_dev_t *d, sim_i2c_ev_t ev) +{ + if (ev == SIM_I2C_EV_POST_WRITE) { + for (int rail = 1; rail <= N; rail++) { + if (sim_gpio_level(rail_cfg(rail)->efuse_en) == 0) { + s_efuse_low_seen++; + } + } + } + if (s_orig_hook != NULL) { + s_orig_hook(d, ev); /* 假 PCA9555 自己那一层不能被顶掉 */ + } +} + +static void install_order_spy(void) +{ + s_efuse_low_seen = 0; + s_orig_hook = NULL; + for (int rail = 1; rail <= N; rail++) { + sim_i2c_dev_t *d = sim_i2c_dev_ch(slot_mux_ch(rail), PCA9555_ADDR); + TEST_ASSERT_NOT_NULL(d); + if (s_orig_hook == NULL) { + s_orig_hook = d->hook; /* 五片挂的是同一个函数 */ + } + TEST_ASSERT_EQUAL_PTR(s_orig_hook, d->hook); + d->hook = order_spy; + } +} + +/* 全关:十次操作全做完,而且**五路 VBUS 全部早于任何一根 eFuse**。 + 反过来的话中间有一小段「手机只靠 USB 供着电」,那既不是开也不是关 —— + 手机会在那一瞬间掉到 USB 限流上,日志里看不出任何异常。 */ +static void test_all_off_cuts_vbus_before_efuse(void) +{ + mount_board(); + power_everything_up(); + install_order_spy(); + + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_all_off()); + + assert_all_efuse_off(); + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(rail - 1, 0, PCA9555_BIT_VBUS_EN)); + TEST_ASSERT_FALSE(slot_vbus_is_on(rail)); + } + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s_efuse_low_seen, "断 VBUS 的时候 eFuse 还不许关"); +} + +/* ⭐ R2 的**已知降级**(计划 §5.4,是决定不是 bug): + 前半段断 VBUS 要走 I²C,mux / PCA9555 失联时**必然失败**; + 后半段关 eFuse 是 GPIO,永远成功。 + 所以最坏情况下 `rail_all_off()` 的语义是 + 「五路直供确实断了,但 VBUS 可能还通着」—— 手机仍靠 USB 的 5 V 吊着命。 + 这是 R2 用一根跨板针换来的代价。这条用例把它钉住: + 哪天有人「顺手」让 VBUS 失败就提前 return,五路直供就再也关不掉了。 */ +static void test_all_off_still_cuts_every_efuse_when_i2c_is_dead(void) +{ + mount_board(); + power_everything_up(); + + /* 五片 PCA9555 一起失联(真板上的对应情形:mux 挂了 / 刀片供电塌了)。 */ + for (int rail = 1; rail <= N; rail++) { + sim_i2c_inject_nak_ch(slot_mux_ch(rail), PCA9555_ADDR, -1); + } + + TEST_ASSERT_EQUAL_INT_MESSAGE(HAL_ERR_NAK, rail_all_off(), "如实报第一个错误码"); + + /* 直供全断 —— 这一半是保证的。 */ + assert_all_efuse_off(); + /* VBUS **还通着** —— 这一半不保证,正是那笔交易的代价。 + 写成断言是为了让「哪天它变了」必须先来改这条注释。 */ + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT_MESSAGE(1, sim_pca9555_pin(rail - 1, 0, PCA9555_BIT_VBUS_EN), + "I²C 死了 VBUS 就关不掉,这是 R2 的已知代价"); + } +} + +/* 「十次操作全部试一遍」:只让**第一路**的 PCA9555 失联。 + 在第一个错上就 return 的写法,会让另外四路的 VBUS 与全部五路的 eFuse 都留着。 */ +static void test_all_off_keeps_going_after_the_first_failure(void) +{ + mount_board(); + power_everything_up(); + sim_i2c_inject_nak_ch(slot_mux_ch(1), PCA9555_ADDR, -1); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, rail_all_off()); + + assert_all_efuse_off(); /* 五根 eFuse 一根都不许剩 */ + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_VBUS_EN)); /* 这一路关不掉 */ + for (int rail = 2; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT_MESSAGE(0, sim_pca9555_pin(rail - 1, 0, PCA9555_BIT_VBUS_EN), + "第一路失败不许连累后面四路"); + } +} + +/* 没 `rail_en_init()`(也没有总线)就调:返回错误码,不崩、不乱写。 + 这是复位路径上真会发生的情形 —— 早期自检失败就直接走收尾。 */ +static void test_all_off_before_init_reports_an_error(void) +{ + TEST_ASSERT_NOT_EQUAL(HAL_OK, rail_all_off()); + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(-1, sim_gpio_level(rail_cfg(rail)->efuse_en)); /* 没配过 */ + } } int main(void) { UNITY_BEGIN(); RUN_TEST(test_selftest_passes); - RUN_TEST(test_selftest_covers_all_four_driver_tables); + RUN_TEST(test_selftest_covers_every_driver_table); RUN_TEST(test_selftest_str_names_every_bit); RUN_TEST(test_a_faithful_copy_of_the_real_table_passes); RUN_TEST(test_a_cell_that_drifted_from_the_driver_is_caught); + RUN_TEST(test_every_kind_of_bad_mux_channel_is_caught); RUN_TEST(test_every_kind_of_bad_pin_is_caught); - RUN_TEST(test_two_kinds_of_breakage_report_two_bits); - RUN_TEST(test_no_two_rails_share_anything); - RUN_TEST(test_shift_chain_bits_avoid_hub_reset_and_the_unused_pins); - RUN_TEST(test_every_field_is_in_range); - RUN_TEST(test_flt_and_pg_live_on_different_ports_so_equal_bits_are_fine); - RUN_TEST(test_no_column_is_an_arithmetic_progression); - RUN_TEST(test_ina_addr_formula_is_wrong_for_exactly_three_rails); + RUN_TEST(test_three_kinds_of_breakage_report_three_bits); + RUN_TEST(test_sequential_columns_are_facts_not_invariants); RUN_TEST(test_dac_mapping_is_not_the_obvious_one); - RUN_TEST(test_flt_and_pg_really_are_sequential); - RUN_TEST(test_the_three_most_surprising_cells); + RUN_TEST(test_all_five_ina226_share_one_address_and_mux_is_what_separates_them); + RUN_TEST(test_the_most_surprising_cells); RUN_TEST(test_table_matches_every_driver_table); - RUN_TEST(test_en_pins_do_not_collide_with_anything_else); - RUN_TEST(test_en_pins_match_the_pcb_netlist); + RUN_TEST(test_no_two_rails_share_anything); + RUN_TEST(test_every_field_is_in_range); + RUN_TEST(test_no_pin_is_used_twice_on_the_whole_board); + RUN_TEST(test_rail_pins_match_the_pcb_netlist); RUN_TEST(test_ntc_pins_are_on_adc1); RUN_TEST(test_io48_is_a_usable_pin); - RUN_TEST(test_en_init_leaves_all_ten_pins_driven_low); + RUN_TEST(test_en_init_leaves_all_five_pins_driven_low); RUN_TEST(test_enabling_one_rail_touches_exactly_one_pin); RUN_TEST(test_enable_is_active_high); + RUN_TEST(test_shadow_only_moves_when_the_write_succeeded); RUN_TEST(test_all_off_cuts_vbus_before_efuse); - RUN_TEST(test_all_off_keeps_going_after_one_pin_fails); + RUN_TEST(test_all_off_still_cuts_every_efuse_when_i2c_is_dead); + RUN_TEST(test_all_off_keeps_going_after_the_first_failure); RUN_TEST(test_all_off_before_init_reports_an_error); - RUN_TEST(test_rail_number_out_of_range_is_rejected); return UNITY_END(); } diff --git a/firmware/test/test_slot.c b/firmware/test/test_slot.c new file mode 100644 index 0000000..cbd589b --- /dev/null +++ b/firmware/test/test_slot.c @@ -0,0 +1,464 @@ +/* `slot` —— 一片刀片的 I/O 门面(R2 新增)。 + + 这一层存在的全部理由是**一类不会报错的错**: + `pca9555_write_out0(slot, ...)` 的 `slot` 只用来记影子,驱动没法验证它和当前 + mux 通道是不是同一路。传错的后果是「按了另一台手机的电源键」—— + 而五路的电压、电流、温度全部正常,I²C 一次都不 NAK,日志里什么都没有。 + `slot.c` 把「选通道」和「记影子」绑在同一个函数里,让这件事**不可能**传错。 + + 所以本文件的断言几乎都长成同一个样子: + **动了第 N 路 ⇒ 假 mux 的当前通道 == `rail_cfg(N)->mux_ch`, + 而且只有第 N 片假 PCA9555 的输出锁存器变了。** + 光看返回值和终态是看不出通道错的 —— 五片同址 0x20,写哪一片都成功。 + + 另外三件会真出事的事: + · VBUS 写失败时影子往**哪一边**倒(关失败 ⇒ 留在 on,还会再关一次); + · `_locked` 版本在 `xact` 回调外被调(通道没选)会写到错的片子上; + · `PRSNT#` 读失败时返回**上一次成功的值**,而不是「不在位」。 */ +#include "slot.h" + +#include "board_pins.h" +#include "i2cbus.h" +#include "rail_cfg.h" +#include "sim_hal.h" +#include "tca9548a.h" +#include "unity.h" + +/* ---- 脚手架 -------------------------------------------------------------- */ + +/* 假器件 idx 与路号的对应:本文件一律用 `blade(rail)`,不写 `rail - 1` + (`rail_cfg.h` 的规矩:由路号算出来的编号只许在那张表里出现)。 */ +static int blade(int rail) { return rail - 1; } + +static void mount(void) +{ + sim_hal_reset(); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); + sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N); + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + /* ⚠️ 通道从**表**里取。写成 `rail - 1` 的话,哪天表改了(`PRSNT#` 那一列 + 就是现成的先例),测试会跟着一起错,而且仍然全绿。 */ + sim_pca9555_attach_ch(blade(rail), rail_cfg(rail)->mux_ch, PCA9555_ADDR); + } + pca9555_shadow_reset(); /* 驱动的影子是文件级静态,跨用例不会自己清 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); /* VBUS 受控之前 eFuse 必须先关 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_prsnt_init()); +} + +void setUp(void) { mount(); } +void tearDown(void) {} + +static void init_all(void) +{ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail)); + } +} + +/* 除了 `except` 那一片,其余四片的输出锁存器都必须是 `rest`。 */ +static void assert_only_blade_changed(int except_rail, uint8_t want, uint8_t rest) +{ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + const uint8_t got = sim_pca9555_output(blade(rail), 0); + TEST_ASSERT_EQUAL_HEX8(rail == except_rail ? want : rest, got); + } +} + +/* ---- 路号 → 通道(唯一换算点) ------------------------------------------ */ + +/* `slot_mux_ch()` 必须**查表**,不许算。现在表恰好是 0 1 2 3 4,所以任何 + 「`rail - 1`」的写法都跑得过 —— 这条用例挡不住那种写法,它挡的是 + 「哪天表改了而 `slot.c` 没跟着改」。`rail_cfg.h` 里 `PRSNT#` 那一列就是先例。 */ +static void test_mux_ch_comes_from_the_table(void) +{ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + TEST_ASSERT_EQUAL_INT(rail_cfg(rail)->mux_ch, slot_mux_ch(rail)); + } +} + +/* ---- ⭐ 通道选择:动了这一路 = 只动了这一片 ------------------------------ */ + +/* 核心用例。改 CH3 的电源键时: + ① 假 mux 的当前通道必须是 `rail_cfg(3)->mux_ch`; + ② **只有第 3 片**的输出锁存器变了; + ③ 从头到尾没有两片一起应答(`ambiguous == 0`)。 + 通道传错的现象是「按了另一台手机的电源键」,五路电压电流全部正常。 */ +static void test_out0_write_selects_this_rails_channel(void) +{ + init_all(); + const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR); + + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(3, pwr, pwr)); + + TEST_ASSERT_EQUAL_INT(rail_cfg(3)->mux_ch, sim_i2c_mux_ch()); + assert_only_blade_changed(3, pwr, 0x00); + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(blade(3), 0, PCA9555_BIT_KEY_PWR)); + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); +} + +/* 五路各点一次名。上一条只证明了 CH3 对,这条排掉「常数 2」这种碰巧对的写法。 */ +static void test_every_rail_touches_only_its_own_blade(void) +{ + init_all(); + const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR); + + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(rail, pwr, pwr)); + TEST_ASSERT_EQUAL_INT(rail_cfg(rail)->mux_ch, sim_i2c_mux_ch()); + assert_only_blade_changed(rail, pwr, 0x00); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(rail, pwr, 0x00)); /* 松开再走下一路 */ + } + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); +} + +/* 通道来回切之后,各路的影子还得是各路自己的 —— 影子串了的现象是 + 「按 CH5 的音量键顺手把 CH1 的电源键松开了」,而两次写都成功。 */ +static void test_shadows_survive_channel_switching(void) +{ + init_all(); + const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR); + const uint8_t vup = (uint8_t)(1u << PCA9555_BIT_KEY_VUP); + + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(1, pwr, pwr)); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(5, vup, vup)); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(1, vup, vup)); /* CH1 再加一个键 */ + + TEST_ASSERT_EQUAL_HEX8((uint8_t)(pwr | vup), sim_pca9555_output(blade(1), 0)); + TEST_ASSERT_EQUAL_HEX8(vup, sim_pca9555_output(blade(5), 0)); + TEST_ASSERT_EQUAL_HEX8((uint8_t)(pwr | vup), slot_shadow(1)); + TEST_ASSERT_EQUAL_HEX8(vup, slot_shadow(5)); +} + +/* `slot_init()` 也得选对通道,而且**不许把别的刀片初始化掉** —— + 只 init 一路时,其余四片必须还停在上电复位态(方向全输入)。 + 顺带再钉一次「初始化顺序」那道拦网:经 `slot` 走也一次都不许触发。 */ +static void test_slot_init_configures_only_this_blade(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(4)); + + TEST_ASSERT_EQUAL_INT(rail_cfg(4)->mux_ch, sim_i2c_mux_ch()); + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + const uint8_t want = (rail == 4) ? PCA9555_CONFIG0_INIT : PCA9555_DIR_ALL_INPUT; + TEST_ASSERT_EQUAL_HEX8(want, sim_pca9555_config(blade(rail), 0)); + } + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_unsafe_enable()); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); +} + +/* ---- VBUS ---------------------------------------------------------------- */ + +/* 极性:**高 = 通**(TPS22918 的 ON 脚 + R701 100 k 下拉)。写反的现象是 + 「上电就通、命令关它反而开」,而 I²C 全程正常。判据打在栅极电平上。 */ +static void test_vbus_enable_drives_the_right_pin_on_the_right_blade(void) +{ + init_all(); + + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(2, true)); + TEST_ASSERT_EQUAL_INT(rail_cfg(2)->mux_ch, sim_i2c_mux_ch()); + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(blade(2), 0, PCA9555_BIT_VBUS_EN)); + assert_only_blade_changed(2, (uint8_t)(1u << PCA9555_BIT_VBUS_EN), 0x00); + TEST_ASSERT_TRUE(slot_vbus_is_on(2)); + + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(2, false)); + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(blade(2), 0, PCA9555_BIT_VBUS_EN)); + TEST_ASSERT_FALSE(slot_vbus_is_on(2)); +} + +/* 影子只有一份(在驱动里)。`slot.c` 要是自己再存一份,两处迟早分家: + 现象是「`slot_vbus_is_on()` 说通着,下一次写键的读改写却把 VBUS 位清了」。 + 这里把「另存一份」钉死:绕过 `slot_vbus_enable()` 直接写驱动, + `slot_vbus_is_on()` 必须跟着变。 */ +static void test_vbus_shadow_is_the_drivers_one(void) +{ + init_all(); + TEST_ASSERT_FALSE(slot_vbus_is_on(4)); + + /* 走 `slot_out0_write()`(键的那条路)把 VBUS 位置上 —— 同一个字节、同一个影子。 */ + const uint8_t vbus = (uint8_t)(1u << PCA9555_BIT_VBUS_EN); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(4, vbus, vbus)); + TEST_ASSERT_TRUE(slot_vbus_is_on(4)); + TEST_ASSERT_EQUAL_HEX8(vbus, slot_shadow(4)); + + /* 反过来:按一个键不许把 VBUS 顺手带掉(读改写的那条语义)。 */ + const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(4, pwr, pwr)); + TEST_ASSERT_TRUE(slot_vbus_is_on(4)); + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(blade(4), 0, PCA9555_BIT_VBUS_EN)); +} + +/* ⭐ **关 VBUS 失败 ⇒ 影子留在 on。** + 方向是有讲究的:留在 on ⇒ 上层的反灌判据继续武装,下一圈还会再关一次。 + 要是写失败却把影子改成 off,判据会认为「已经关掉了」而解除武装 —— + 现象是「日志里写着 VBUS 已断,实际还通着」,而这一路的电压电流看上去正常。 */ +static void test_a_failed_vbus_off_leaves_the_shadow_on(void) +{ + init_all(); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(5, true)); + TEST_ASSERT_TRUE(slot_vbus_is_on(5)); + + sim_i2c_inject_nak_ch(rail_cfg(5)->mux_ch, PCA9555_ADDR, -1); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, slot_vbus_enable(5, false)); + + TEST_ASSERT_TRUE(slot_vbus_is_on(5)); /* 还武装着 */ + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(blade(5), 0, PCA9555_BIT_VBUS_EN)); /* 线上确实还通 */ + + /* 总线恢复之后再关一次就该真的关掉(判据没解除武装,所以会有这一次)。 */ + sim_i2c_inject_nak_ch(rail_cfg(5)->mux_ch, PCA9555_ADDR, 0); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(5, false)); + TEST_ASSERT_FALSE(slot_vbus_is_on(5)); + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(blade(5), 0, PCA9555_BIT_VBUS_EN)); +} + +/* ⭐ **开 VBUS 失败 ⇒ 影子留在 off。** + 同一条规则(写成功才更新影子)在另一个方向上的表现,两边都倒向安全的那一侧: + 这次影子要是变成 on,上层会以为 USB 已经供上了,从而不再重试, + 而手机那头其实一直没电 —— 而且下一次写键的读改写会把 VBUS 位"补"上去, + 变成一次没人下令的上电。 */ +static void test_a_failed_vbus_on_leaves_the_shadow_off(void) +{ + init_all(); + sim_i2c_inject_nak_ch(rail_cfg(1)->mux_ch, PCA9555_ADDR, -1); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, slot_vbus_enable(1, true)); + TEST_ASSERT_FALSE(slot_vbus_is_on(1)); + + /* 恢复后按一个键:VBUS 位不许被带上去。 */ + sim_i2c_inject_nak_ch(rail_cfg(1)->mux_ch, PCA9555_ADDR, 0); + const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(1, pwr, pwr)); + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(blade(1), 0, PCA9555_BIT_VBUS_EN)); +} + +/* ---- ⭐ `_locked` 版本:为什么必须在 `xact` 回调里 ---------------------- */ + +typedef struct { + int rail; + bool on; +} vbus_arg_t; + +static hal_err_t cb_vbus(void *arg) +{ + const vbus_arg_t *a = (const vbus_arg_t *)arg; + return slot_vbus_enable_locked(a->rail, a->on); +} + +/* 正路:`t_safety` 的用法 —— 拿 `slot_mux_ch(rail)` 开 `xact`,在回调里断 VBUS。 + SW-8 要求「同一圈、同一把锁」里完成(计划 §5.4)。 */ +static void test_vbus_locked_inside_a_xact_hits_the_right_blade(void) +{ + init_all(); + vbus_arg_t a = { 3, true }; + + TEST_ASSERT_EQUAL_INT(HAL_OK, + i2cbus_xact(slot_mux_ch(3), SLOT_LOCK_TIMEOUT_MS, cb_vbus, &a)); + + TEST_ASSERT_EQUAL_INT(rail_cfg(3)->mux_ch, sim_i2c_mux_ch()); + assert_only_blade_changed(3, (uint8_t)(1u << PCA9555_BIT_VBUS_EN), 0x00); + TEST_ASSERT_TRUE(slot_vbus_is_on(3)); +} + +/* ⭐⭐ 反路:在回调**外面**调 `_locked` 版本。通道还停在上一次事务选的那一路, + 于是这次写落在了**那一片**上,而影子记在了第 3 路的格子里。 + 返回值是 HAL_OK,五路电压电流全部正常,日志里什么都没有 —— + **这就是 `slot.c` 存在的理由**:不带 `_locked` 的版本自己开 `xact`, + 让「选通道」和「记影子」不可能分开。 + + ⚠️ 这条用例是在演示一个**错误用法的后果**,不是在钉一个正确行为。 + 哪天 `_locked` 版本自己也会选通道了,它会变红 —— 那时该改的是这条注释。 */ +static void test_vbus_locked_outside_a_xact_writes_to_the_wrong_blade(void) +{ + init_all(); + const uint8_t vbus = (uint8_t)(1u << PCA9555_BIT_KEY_PWR); + + /* 上一次事务把通道留在了 CH1 上。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(1, vbus, 0x00)); + TEST_ASSERT_EQUAL_INT(rail_cfg(1)->mux_ch, sim_i2c_mux_ch()); + + /* 现在「关 CH3 的 VBUS」——没人给它选通道。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable_locked(3, true)); /* 居然成功了 */ + + /* 线上真实发生的事:CH1 的 VBUS 被打开了,CH3 一动没动。 */ + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(blade(1), 0, PCA9555_BIT_VBUS_EN)); + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(blade(3), 0, PCA9555_BIT_VBUS_EN)); + /* 而固件这边一口咬定 CH3 通着、CH1 没通 —— 两份认知就此分家。 */ + TEST_ASSERT_TRUE(slot_vbus_is_on(3)); + TEST_ASSERT_FALSE(slot_vbus_is_on(1)); +} + +/* ⚠️ `i2cbus.h` 末尾那条警告:回调里再调不带 `_locked` 的版本 = 嵌套拿同一把锁。 + `i2cbus` 直接判 `HAL_ERR_STATE` 而不是死锁 —— 死锁在板子上的现象是 + 「巡检任务不再喂狗,整机每 8 s 重启一次」,而错误码至少能进事件日志。 */ +static hal_err_t cb_nested(void *arg) +{ + (void)arg; + return slot_out0_write(2, (uint8_t)(1u << PCA9555_BIT_KEY_PWR), 0x00); +} + +static void test_calling_the_unlocked_version_inside_a_xact_is_rejected(void) +{ + init_all(); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, + i2cbus_xact(slot_mux_ch(2), SLOT_LOCK_TIMEOUT_MS, cb_nested, NULL)); +} + +/* ---- 输入(只有 `_locked` 版本) ---------------------------------------- */ + +static pca9555_in_t s_in; + +static hal_err_t cb_read(void *arg) +{ + const int rail = *(const int *)arg; + return slot_read_inputs_locked(rail, &s_in); +} + +/* 读的是**这一路**的刀片。只让 CH4 报 eFuse 故障,五路挨个读一遍: + 通道错的现象是「CH4 的故障被记在了 CH2 头上」,于是断的是好的那一路。 */ +static void test_read_inputs_locked_reads_this_rails_blade(void) +{ + init_all(); + sim_pca9555_drive(blade(4), 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_EFUSE_FLT))); + + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + int r = rail; + s_in.port0 = 0xAA; + s_in.port1 = 0xAA; + TEST_ASSERT_EQUAL_INT(HAL_OK, + i2cbus_xact(slot_mux_ch(rail), SLOT_LOCK_TIMEOUT_MS, cb_read, &r)); + TEST_ASSERT_EQUAL_INT(rail == 4, pca9555_efuse_fault(&s_in)); + } + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); +} + +/* ---- 参数与掩码的守门 ---------------------------------------------------- */ + +/* 路号是 **1~5**(不是 0~4)。越界必须返回 `HAL_ERR_ARG` 而不是悄悄当成 CH1 —— + 悄悄当成 CH1 的现象是「命令发给了另一台手机」。 */ +static void test_a_bad_rail_is_rejected_everywhere(void) +{ + init_all(); + const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_out0_write(0, pwr, pwr)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_out0_write(PHONERAIL_RAIL_COUNT + 1, pwr, pwr)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_init(0)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_vbus_enable(0, true)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_vbus_enable_locked(PHONERAIL_RAIL_COUNT + 1, true)); + TEST_ASSERT_FALSE(slot_vbus_is_on(0)); + TEST_ASSERT_FALSE(slot_present(0)); + TEST_ASSERT_FALSE(slot_present(PHONERAIL_RAIL_COUNT + 1)); + + /* 一片都没被碰过。 */ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(blade(rail), 0)); + } +} + +/* 掩码碰到接地脚 → 驱动的 `HAL_ERR_ARG` 必须原样透出来(不是被 `xact` 吃掉 + 变成 HAL_OK)。透不出来的现象是「上层以为键按下了,其实一个字节都没发」。 */ +static void test_a_mask_touching_a_grounded_pin_is_rejected(void) +{ + init_all(); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_out0_write(3, 0x20, 0x20)); /* P0.5 接地 */ + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_out0_write(3, 0xFF, 0x00)); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(blade(3), 0)); +} + +static void test_read_inputs_locked_rejects_null(void) +{ + init_all(); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_read_inputs_locked(1, NULL)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_read_inputs_locked(0, &s_in)); +} + +/* ---- `PRSNT#`:纯 GPIO,低 = 在位 ---------------------------------------- */ + +/* 极性:刀片把金手指 B31 短到 A1 的 GND,底板 R606~R610 上拉 ⇒ **低 = 在位**。 + 写反的现象是「五个槽全空 ⇒ 固件拒绝上电」(还算安全),或者 + 「空槽被当成在位 ⇒ 给一排裸露的金手指送 12 V」(不安全)。 */ +static void test_present_is_low_true(void) +{ + /* `slot_prsnt_init()` 刚跑完:内部上拉、没人拉低 = 五个槽都空着。 */ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + TEST_ASSERT_FALSE(slot_present(rail)); + TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(rail_cfg(rail)->prsnt_n)); /* 上拉生效 */ + } + + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + sim_gpio_drive(rail_cfg(rail)->prsnt_n, 0); /* 插上一片 */ + } + slot_prsnt_poll(); + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + TEST_ASSERT_TRUE(slot_present(rail)); + } +} + +/* 五个槽各读各的脚。⚠️ `PRSNT5#` 是 **IO7**,不接着前四路顺排 —— + 写成 `15 + rail - 1` 的话第五路会去读 IO19(那上面是别的信号), + 现象是「CH5 的刀片时有时无」。 */ +static void test_each_slot_reads_its_own_pin(void) +{ + for (int on = 1; on <= PHONERAIL_RAIL_COUNT; on++) { + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + sim_gpio_drive(rail_cfg(rail)->prsnt_n, rail == on ? 0 : 1); + } + slot_prsnt_poll(); + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + TEST_ASSERT_EQUAL_INT(rail == on, slot_present(rail)); + } + } +} + +/* ⭐ **GPIO 读失败时返回上一次成功的值,不是「不在位」。** + 让一次读失败把五台正在跑的手机判成"拔掉了",代价比漏判大得多 + (上层会因此断电、记事件、甚至拒绝重新上电)。 + + 这里用「引脚整体回到没 config 的状态」造读失败(`hal_gpio_read()` 在两个目标上 + 也只可能因为这个失败)。断言用**混合现状**:两路不在位、三路在位。 + 写成「失败就当不在位」⇒ 那三路变红;写成「失败就当在位」⇒ 那两路变红。 */ +static void test_present_keeps_the_last_good_value_when_the_read_fails(void) +{ + const bool want[PHONERAIL_RAIL_COUNT] = { false, true, false, true, true }; + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + sim_gpio_drive(rail_cfg(rail)->prsnt_n, want[rail - 1] ? 0 : 1); + } + slot_prsnt_poll(); + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + TEST_ASSERT_EQUAL_INT(want[rail - 1], slot_present(rail)); + } + + /* 引脚全部回到「没 config」⇒ `hal_gpio_read()` 一律 HAL_ERR_ARG。 */ + sim_hal_reset(); + slot_prsnt_poll(); + + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + TEST_ASSERT_EQUAL_INT(want[rail - 1], slot_present(rail)); /* 一格没变 */ + } +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_mux_ch_comes_from_the_table); + RUN_TEST(test_out0_write_selects_this_rails_channel); + RUN_TEST(test_every_rail_touches_only_its_own_blade); + RUN_TEST(test_shadows_survive_channel_switching); + RUN_TEST(test_slot_init_configures_only_this_blade); + RUN_TEST(test_vbus_enable_drives_the_right_pin_on_the_right_blade); + RUN_TEST(test_vbus_shadow_is_the_drivers_one); + RUN_TEST(test_a_failed_vbus_off_leaves_the_shadow_on); + RUN_TEST(test_a_failed_vbus_on_leaves_the_shadow_off); + RUN_TEST(test_vbus_locked_inside_a_xact_hits_the_right_blade); + RUN_TEST(test_vbus_locked_outside_a_xact_writes_to_the_wrong_blade); + RUN_TEST(test_calling_the_unlocked_version_inside_a_xact_is_rejected); + RUN_TEST(test_read_inputs_locked_reads_this_rails_blade); + RUN_TEST(test_a_bad_rail_is_rejected_everywhere); + RUN_TEST(test_a_mask_touching_a_grounded_pin_is_rejected); + RUN_TEST(test_read_inputs_locked_rejects_null); + RUN_TEST(test_present_is_low_true); + RUN_TEST(test_each_slot_reads_its_own_pin); + RUN_TEST(test_present_keeps_the_last_good_value_when_the_read_fails); + return UNITY_END(); +} diff --git a/firmware/test/test_t_actuator.c b/firmware/test/test_t_actuator.c index f136c26..3ed1474 100644 --- a/firmware/test/test_t_actuator.c +++ b/firmware/test/test_t_actuator.c @@ -1,33 +1,59 @@ -/* A9:三个执行器状态机 + 互斥(需求 §10.6)。计划里这一包的验收原话是 +/* A9(**R2 版**):三个执行器状态机 + 互斥(需求 §10.6)。计划里这一包的验收原话是 「场景测试:硬重启走完 17 ms 上电时序;按键序列不重入;EDL 顺序固定」。 ⚠️ 这三条都**不是**「函数返回了 HAL_OK」能证明的东西,所以本文件里的断言 一律打在**线上真实发生的事**上: - · 24 位链看 `sim_sr595_*`(RCLK 沿的个数、栅极上的电平),不看驱动自己的影子 - —— 对着影子断言等于自己证明自己,「分两次写把组合键拆开了」影子看不出来; - · 两根 EN 线看 `sim_gpio_level()` 与**全局写序号** `sim_gpio_write_seq()` - —— 只看终值的话,「先插 USB 再短接」和「先短接再插 USB」长得一模一样。 + · 按键 / EDL / VBUS 看五片假 PCA9555 的**输出锁存器与栅极电平** + (`sim_pca9555_output/pin`)与**写事务计数**(`sim_i2c_dev_ch(...)->n_write`), + 不看驱动自己的影子 —— 对着影子断言等于自己证明自己, + 「组合键分两次写」「影子记到了另一片上」影子变量一律看不出来; + · `EFUSE_EN` 看 `sim_gpio_level()` 与全局写序号 `sim_gpio_write_seq()`。 + + ---- R1 → R2 这一层换了什么 ---------------------------------------------- + R1 里按键 / EDL 在 74HC595 的 24 位链上(纯 GPIO),`VBUS_EN` 是直连 GPIO, + 所以执行器**与 I²C 无交集**。R2 里这三样全在刀片那颗 PCA9555 的 Port0 上: + + bit0 KEY_PWR bit1 KEY_VUP bit2 KEY_VDN bit3 EDL_EN bit4 VBUS_EN + (位号五片逐格相同,区分五路的是 **mux 通道**) + + 于是本层的失败形态整个换了一批,测试也跟着换: + + | R1 的失败形态 | R2 的对应形态 | 本文件里哪一条抓 | + |---|---|---| + | 位号算错(链上算成另一路的位) | **mux 通道选错 / 影子下标传错** | `..._every_action_selects_this_rails_mux_channel`、`..._pressing_ch1_does_not_touch_ch4` | + | 组合键分两帧 RCLK | 组合键**分两次 `pca9555_write_out0`** | `..._combo_keys_land_in_exactly_one_write` | + | 收尾时 595 锁被别人占着 | 收尾时 **I²C 锁**被别人占着 | `..._cleanup_retries_until_the_key_is_really_released` | + | 两把锁不许嵌套 | **只剩一把锁**,改成与巡检**争用** | `..._and_safety_share_one_lock_without_starving` | 虚拟时钟下这些场景是零耗时的:`run_ms()` 干的就是任务体干的事 - —— 调一次 `t_actuator_step()`,按它返回的时间往前拨表。 */ + —— 调一次 `t_actuator_step()`,按它返回的时间往前拨表。 + ⚠️ 假总线上的事务**会花掉虚拟时钟**(`sim_i2c_set_bus_time` 默认开), + 这里**故意不关**:执行器与巡检争用同一把锁这件事,靠的就是总线时间是真的。 */ #include #include +#include "board_pins.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 "phonerail.h" #include "rail_cfg.h" #include "sim_hal.h" -#include "sr595.h" +#include "slot.h" #include "t_actuator.h" +#include "t_safety.h" +#include "tca9548a.h" #include "unity.h" #define N PHONERAIL_RAIL_COUNT /* ---- 假 PG --------------------------------------------------------------- - 真板上这个值来自 `t_safety` 每 20 ms 读回来的 PCA9555 快照(app_main.c 里接的)。 - `readable = false` 模拟「PCA9555 失联 / 巡检还没跑过一圈」—— 那是**读不到**, + 真板上这个值来自 `t_safety` 每 20 ms 读回来的那一路 PCA9555 快照(app_main.c 里接的)。 + `readable = false` 模拟「这一片 PCA9555 失联 / 巡检还没跑过一圈」—— 那是**读不到**, 和「读到了,是低」(电源还没好)是两回事,两条路径下面都要测到。 */ static bool s_pg_readable; static bool s_pg_val[N]; @@ -42,20 +68,121 @@ static bool fake_pg(void *ctx, int rail, bool *pg) return true; } +/* ---- 五片刀片的观测口 ---------------------------------------------------- + `idx`(假器件下标)与 `rail - 1` 对应,但通道一律查 `rail_cfg()`,**不算** + —— 「mux_ch = rail − 1」是当下的事实,不是不变量(rail_cfg.h 那条禁令)。 */ + +static sim_i2c_dev_t *blade(int rail) +{ + sim_i2c_dev_t *d = sim_i2c_dev_ch(rail_cfg(rail)->mux_ch, PCA9555_ADDR); + TEST_ASSERT_NOT_NULL(d); + return d; +} + +static uint8_t latch(int rail) { return sim_pca9555_output(rail - 1, 0); } +static int pin(int rail, int bit) { return sim_pca9555_pin(rail - 1, 0, bit); } +static int key_pin(int rail, pca9555_key_t k) { return pin(rail, pca9555_key_bit(k)); } + +static void snap_latches(uint8_t out[N]) +{ + for (int r = 1; r <= N; r++) { + out[r - 1] = latch(r); + } +} + +static void snap_writes(uint32_t out[N]) +{ + for (int r = 1; r <= N; r++) { + out[r - 1] = blade(r)->n_write; + } +} + +/* 「除了 `except` 那一路,别的四片一个字节都没动」。 + ⭐ R2 里「按了另一台手机的电源键」就长这个样子:本路的位号完全正确, + 只是通道选错 / 影子下标传错,事务落到了另一片上。五路电压电流全部正常。 */ +static void assert_only_this_blade_moved(int except, const uint8_t l0[N], + const uint32_t w0[N]) +{ + for (int r = 1; r <= N; r++) { + if (r == except) { + continue; + } + TEST_ASSERT_EQUAL_HEX8(l0[r - 1], latch(r)); + TEST_ASSERT_EQUAL_UINT32(w0[r - 1], blade(r)->n_write); + } + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); /* 同时开两路 = 两片一起应答 */ +} + +/* ---- 窃听钩子:把「一次 I²C 写」和「一次 GPIO 写」排进同一条时间轴 -------- + R1 靠比较 `RCLK` 与 `EFUSE_EN` 两根脚的全局写序号证明「先松键、再断电」。 + R2 里松键是一次 I²C 写、断电仍是一次 GPIO 写,两边的计数器不通用, + 所以在假器件上挂一个钩子:每次写落地时记下「`EFUSE_EN` 到此刻被写过几次」。 + 钩子**链到**假 PCA9555 自己的那一个,不是把它顶掉(顶掉就没人查短路了)。 */ +static sim_i2c_hook_t s_inner_hook; +static int s_spy_pin = -1; +static uint32_t s_spy_writes; +static uint32_t s_spy_gpio_writes_at_first; /* 第一次写发生时,那根脚的写次数 */ + +static void spy_hook(sim_i2c_dev_t *d, sim_i2c_ev_t ev) +{ + if (ev == SIM_I2C_EV_POST_WRITE) { + if (s_spy_writes == 0 && s_spy_pin >= 0) { + s_spy_gpio_writes_at_first = sim_gpio_writes(s_spy_pin); + } + s_spy_writes++; + } + if (s_inner_hook != NULL) { + s_inner_hook(d, ev); + } +} + +static void spy_on(int rail, int gpio_pin) +{ + sim_i2c_dev_t *d = blade(rail); + s_inner_hook = d->hook; + d->hook = spy_hook; + s_spy_pin = gpio_pin; + s_spy_writes = 0; + s_spy_gpio_writes_at_first = 0; +} + +/* ---- 假板子 -------------------------------------------------------------- + 底板一颗 TCA9548A,五个通道后面各挂一片同地址(0x20)的 PCA9555 —— + **五片同址**正是 R2 D8 的全部意义,不是笔误。 */ static void board_up(void) { + const hal_i2c_cfg_t cfg = { .sda_gpio = BOARD_PIN_I2C_SDA, + .scl_gpio = BOARD_PIN_I2C_SCL, + .freq_hz = 400000 }; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg)); TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); /* 十根 EN 线,全部拉低 */ - sim_sr595_attach(SR595_PIN_SER, SR595_PIN_SRCLK, SR595_PIN_RCLK, SR595_PIN_OE_N); - TEST_ASSERT_EQUAL_INT(HAL_OK, sr595_init()); /* 安全影子 + 使能输出(A5) */ + sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N); + for (int rail = 1; rail <= N; rail++) { + sim_pca9555_attach_ch(rail - 1, rail_cfg(rail)->mux_ch, PCA9555_ADDR); + } - /* 强制加锁纪律 —— 但要在 `sr595_init()` **之后**打开:初始化是启动期 - 独占跑的,本来就不该被要求先拿锁。打开之后,动那四根线却没持锁就记违规。 */ - sim_lock_require(HAL_LOCK_SR595, true); + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); /* 五根 EN 线,全部拉低 */ + for (int rail = 1; rail <= N; rail++) { + /* 输出锁存器先清零、方向最后改(pca9555.h 那个框)。之后影子与锁存器都是 0。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail)); + TEST_ASSERT_EQUAL_HEX8(0x00, latch(rail)); + } + /* 初始化一步都不许把接地脚配成输出,也不许在锁存器还是 0xFF 时就开方向。 */ + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_unsafe_enable()); + + /* 强制加锁纪律 —— 但要在初始化**之后**打开:启动期是独占跑的, + 本来就不该被要求先拿锁。打开之后,任何一次 I²C 事务没持锁就记一次违规。 */ + sim_lock_require(HAL_LOCK_I2C, true); s_pg_readable = true; memset(s_pg_val, 0, sizeof(s_pg_val)); + s_inner_hook = NULL; + s_spy_pin = -1; + s_spy_writes = 0; + s_spy_gpio_writes_at_first = 0; t_actuator_init(fake_pg, NULL); } @@ -69,7 +196,7 @@ void tearDown(void) {} /* 把虚拟时钟往前推 ms,期间按 `t_actuator_step()` 自己要的节奏叫它 —— 这正是 app_main.c 里那个任务体在干的事。 ⚠️ 用**绝对目标时刻**而不是「累减 left」:等锁超时会在 step 内部把虚拟时钟 - 花掉(hal_lock_sim.c),累减的写法会因此推过头。 */ + 花掉 20 ms(hal_lock_sim.c),I²C 事务本身也花时钟,累减的写法会因此推过头。 */ static void run_ms(uint32_t ms) { const uint32_t target = hal_time_now_ms() + ms; @@ -87,12 +214,25 @@ static void run_ms(uint32_t ms) } } -static uint32_t keys_on_chain(void) { return sim_sr595_outputs() & sr595_all_keys_mask(); } +/* 扮演第二个任务,把 I²C 锁占住 / 放开(单线程 sim 里「别人正持着锁」就这么造)。 */ +static void other_task_takes_the_bus(void) +{ + sim_lock_set_task(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + sim_lock_set_task(0); +} + +static void other_task_frees_the_bus(void) +{ + sim_lock_set_task(1); + hal_lock_give(HAL_LOCK_I2C); + sim_lock_set_task(0); +} /* ==== 验收主项一:硬重启走完 17 ms 上电时序 =============================== */ -/* 用 CH3 —— 它的 `EFUSE_EN` 是 IO6、`VBUS_EN` 是 IO11、DAC 在**另一片**上, - 全是「按通道号算就一定算错」的那一路。 */ +/* 用 CH3 —— 它的 `EFUSE_EN` 是 IO11、mux 通道 2、DAC 在**另一片**上, + 全是「按路号算就一定算错」的那一路。 */ static void test_hard_reset_walks_the_full_power_up_sequence(void) { const int rail = 3; @@ -103,8 +243,12 @@ static void test_hard_reset_walks_the_full_power_up_sequence(void) TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(en)); s_pg_val[rail - 1] = true; /* PG 一直是好的 */ - const uint32_t latches0 = sim_sr595_latches(); + const uint32_t takes0 = sim_lock_takes(HAL_LOCK_I2C); const uint32_t t_req = hal_time_now_ms(); + uint8_t l0[N]; + uint32_t w0[N]; + snap_latches(l0); + snap_writes(w0); TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(en)); /* 请求那一刻就断电,不等下一拍 */ @@ -139,22 +283,21 @@ static void test_hard_reset_walks_the_full_power_up_sequence(void) TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(en)); TEST_ASSERT_EQUAL_UINT32(1, t_actuator_done(rail)); - /* 硬重启在 24 位链上什么都不该发(键本来就没按着 ⇒ 影子不变 ⇒ 不脏 ⇒ 不发帧)。 */ - TEST_ASSERT_EQUAL_UINT32(latches0, sim_sr595_latches()); - /* 也不该碰 I²C 那把锁 —— 两把锁因此永远不会嵌套。 */ - TEST_ASSERT_EQUAL_UINT32(0, sim_lock_takes(HAL_LOCK_I2C)); - TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_SR595)); + /* ⭐ 硬重启在总线上什么都不该发:键本来就没按着 ⇒ 影子不脏 ⇒ 一个事务都不发。 + R2 里这条比 R1 更要紧 —— 每一次多余的事务都是从巡检嘴里抢锁。 */ + TEST_ASSERT_EQUAL_UINT32(takes0, sim_lock_takes(HAL_LOCK_I2C)); + assert_only_this_blade_moved(0, l0, w0); /* 0 = 五片一个都不许动 */ + TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); } -/* 别的四路的两根 EN 线,从头到尾一次都不该被写。 */ -static void test_hard_reset_touches_only_its_own_two_pins(void) +/* 别的四路的 `EFUSE_EN`,从头到尾一次都不该被写。 */ +static void test_hard_reset_touches_only_its_own_en_pin(void) { const int rail = 2; - uint32_t before[N][2]; + uint32_t before[N]; for (int r = 1; r <= N; r++) { - before[r - 1][0] = sim_gpio_writes(rail_cfg(r)->efuse_en); - before[r - 1][1] = sim_gpio_writes(rail_cfg(r)->vbus_en); + before[r - 1] = sim_gpio_writes(rail_cfg(r)->efuse_en); } s_pg_val[rail - 1] = true; TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); @@ -165,14 +308,16 @@ static void test_hard_reset_touches_only_its_own_two_pins(void) if (r == rail) { continue; } - TEST_ASSERT_EQUAL_UINT32(before[r - 1][0], sim_gpio_writes(rail_cfg(r)->efuse_en)); - TEST_ASSERT_EQUAL_UINT32(before[r - 1][1], sim_gpio_writes(rail_cfg(r)->vbus_en)); + TEST_ASSERT_EQUAL_UINT32(before[r - 1], sim_gpio_writes(rail_cfg(r)->efuse_en)); + } + /* VBUS 是 EDL 的事,硬重启不碰 —— R2 里它在 PCA9555 的 P0.4 上。 */ + for (int r = 1; r <= N; r++) { + TEST_ASSERT_EQUAL_INT(0, pin(r, PCA9555_BIT_VBUS_EN)); + TEST_ASSERT_FALSE(slot_vbus_is_on(r)); } - /* VBUS 是 EDL 的事,硬重启不碰。 */ - TEST_ASSERT_EQUAL_UINT32(before[rail - 1][1], sim_gpio_writes(rail_cfg(rail)->vbus_en)); } -/* PG 等不到(PCA9555 失联)⇒ 记 NO_PG,但**电继续开着**。 +/* PG 等不到(这一片 PCA9555 失联)⇒ 记 NO_PG,但**电继续开着**。 §10.10:这片不应答 = 失去 PG 可见性,**不关输出**。把「看不见」当「坏了」去断电, 等于一片 I/O 扩展器失联就让五台手机一起停机。 */ static void test_no_pg_leaves_the_rail_powered(void) @@ -223,7 +368,8 @@ static void test_late_pg_is_still_accepted(void) 所以测不出「step 返回的睡眠时长本身对不对」。真板上的任务体是**完全听 step 的**: 它说睡多久就睡多久。这个测试照那样跑,PG 在斜坡走完之后才置位, 序列必须在一个轮询间隔之内收工 —— 「等 PG 时一觉睡满 500 ms 超时」的写法在这里变红 - (那种写法把「等到 PG 就结束」悄悄变成了「一律等满 500 ms」,功能测试看不出来)。 */ + (那种写法把「等到 PG 就结束」悄悄变成了「一律等满 500 ms」, + 而测试与遥测都看不出区别:结果照样是 OK,只是每次硬重启都慢半秒)。 */ static void test_the_task_loop_only_sleeps_what_step_asks_for(void) { const int rail = 2; @@ -256,75 +402,114 @@ static void test_the_task_loop_only_sleeps_what_step_asks_for(void) } /* 硬重启之前先把这一路的三个键松开:断电重启时有键按着,手机会开进 - Recovery / Fastboot 而不是正常起来。这里手工造一个「上一次收尾没落地」的脏位。 */ + Recovery / Fastboot 而不是正常起来。这里手工造一个「上一次收尾没落地」的脏位。 + + ⭐ 顺序也要对:**先松键、再断电**。R2 里这两件事一件在 I²C 上、一件在 GPIO 上, + 两边的计数器不通用,所以靠钩子记下「松键那一刻 EN 被写过几次」。 + 反过来写(先断电再松键)的现象是:手机在断电那一刻键还按着 —— 而重启之后 + 它进的是 Recovery,遥测上电压电流一切正常。 */ static void test_hard_reset_releases_a_stuck_key_before_cutting_power(void) { - const int rail = 1; - const int bit = sr595_key_bit(rail, SR595_KEY_PWR); - const int other = sr595_key_bit(4, SR595_KEY_VUP); + const int rail = 1; + const int other = 4; + const int en = rail_cfg(rail)->efuse_en; + const uint8_t km = pca9555_key_mask(); - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_SR595, 10)); - TEST_ASSERT_EQUAL_INT(HAL_OK, - sr595_write(((uint32_t)1u << bit) | ((uint32_t)1u << other), - ((uint32_t)1u << bit) | ((uint32_t)1u << other))); - hal_lock_give(HAL_LOCK_SR595); - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(bit)); + /* 造脏位:这一路三个键按着,另一路按着音量上(它一动都不许动)。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(rail, km, km)); + TEST_ASSERT_EQUAL_INT( + HAL_OK, slot_out0_write(other, (uint8_t)(1u << pca9555_key_bit(PCA9555_KEY_VUP)), + (uint8_t)(1u << pca9555_key_bit(PCA9555_KEY_VUP)))); + TEST_ASSERT_EQUAL_INT(1, key_pin(rail, PCA9555_KEY_PWR)); + TEST_ASSERT_EQUAL_INT(1, key_pin(other, PCA9555_KEY_VUP)); + + spy_on(rail, en); + const uint32_t en_writes0 = sim_gpio_writes(en); TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); - TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit)); /* 松开了 */ - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(other)); /* 别路的键一动没动 */ - /* 顺序也要对:先松键,再断电。 */ - TEST_ASSERT_TRUE(sim_gpio_write_seq(SR595_PIN_RCLK) < - sim_gpio_write_seq(rail_cfg(rail)->efuse_en)); - TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(rail)->efuse_en)); + TEST_ASSERT_EQUAL_HEX8(0x00, (uint8_t)(latch(rail) & km)); /* 三个键一起松开 */ + TEST_ASSERT_EQUAL_INT(1, key_pin(other, PCA9555_KEY_VUP)); /* 别路的键一动没动 */ + + /* 松键**只发一帧**(三位在同一次写里),而且发的时候 EN 还没被碰过。 */ + TEST_ASSERT_EQUAL_UINT32(1, s_spy_writes); + TEST_ASSERT_EQUAL_UINT32(en_writes0, s_spy_gpio_writes_at_first); + TEST_ASSERT_EQUAL_UINT32(en_writes0 + 1, sim_gpio_writes(en)); + TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(en)); +} + +/* 影子是干净的时候(正常情况)**一个事务都不发** —— 每一次多余的写都是从 + 每 20 ms 一圈的巡检嘴里抢同一把锁。 */ +static void test_the_defensive_release_sends_nothing_when_the_shadow_is_clean(void) +{ + const uint32_t takes0 = sim_lock_takes(HAL_LOCK_I2C); + uint8_t l0[N]; + uint32_t w0[N]; + snap_latches(l0); + snap_writes(w0); + + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + } + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_UINT32(w0[rail - 1], blade(rail)->n_write); + TEST_ASSERT_EQUAL_HEX8(l0[rail - 1], latch(rail)); + } + TEST_ASSERT_EQUAL_UINT32(takes0, sim_lock_takes(HAL_LOCK_I2C)); } /* ==== 验收主项二:按键序列不重入 ========================================= */ -/* 短按 100 ms(SW-5 的第一档)。位号一律查 `sr595_key_bit()`:CH5 的电源键 - 在 U22.QH(bit15),按通道号算怎么算都算不出来。 */ +/* 短按 100 ms(SW-5 的第一档)。位号一律查 `pca9555_key_bit()`:R2 里它确实 + 与路号无关,但「与路号无关」是当下的事实,不是写死 `1 << 0` 的理由。 */ static void test_key_tap_presses_the_table_bit_and_releases_it(void) { const int rail = 5; - const int bit = sr595_key_bit(rail, SR595_KEY_PWR); - const uint32_t latches0 = sim_sr595_latches(); + const int bit = pca9555_key_bit(PCA9555_KEY_PWR); + const uint32_t w0 = blade(rail)->n_write; TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_TAP)); - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(bit)); /* 栅极真的被拉起来了 */ - TEST_ASSERT_EQUAL_UINT32(latches0 + 1, sim_sr595_latches()); - TEST_ASSERT_EQUAL_UINT32((uint32_t)1u << bit, keys_on_chain()); + TEST_ASSERT_EQUAL_INT(1, pin(rail, bit)); /* 栅极真的被拉起来了 */ + TEST_ASSERT_EQUAL_HEX8((uint8_t)(1u << bit), latch(rail)); + TEST_ASSERT_EQUAL_UINT32(w0 + 1, blade(rail)->n_write); + /* ⭐ 这一次写落在**这一路的**通道上。选错通道 = 按了另一台手机的电源键。 */ + TEST_ASSERT_EQUAL_INT(rail_cfg(rail)->mux_ch, sim_i2c_mux_ch()); run_ms(ACT_KEY_MS_TAP - 1); - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(bit)); + TEST_ASSERT_EQUAL_INT(1, pin(rail, bit)); TEST_ASSERT_TRUE(t_actuator_busy(rail)); run_ms(1); - TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit)); - TEST_ASSERT_EQUAL_UINT32(latches0 + 2, sim_sr595_latches()); /* 按下一帧、松开一帧 */ - TEST_ASSERT_EQUAL_UINT32(0, keys_on_chain()); + TEST_ASSERT_EQUAL_INT(0, pin(rail, bit)); + TEST_ASSERT_EQUAL_HEX8(0x00, latch(rail)); + TEST_ASSERT_EQUAL_UINT32(w0 + 2, blade(rail)->n_write); /* 按下一次、松开一次 */ TEST_ASSERT_FALSE(t_actuator_busy(rail)); TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail)); - TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_SR595)); + TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); } -/* ⭐ §10.6 的硬约束:组合键必须**同一帧**写进去。 - 分两次写的实现在这里 `latches` 会 +2,而且中间那一帧只按下了一个键 - ——「先单按了一下 VOL−」在有的机型上就是调音量。 */ -static void test_combo_keys_land_in_exactly_one_frame(void) +/* ⭐ §10.6 的硬约束:组合键必须**一次写**进去。 + R1 靠「同一帧 RCLK」证明,R2 靠「一次 `pca9555_write_out0`」—— + 假器件的写事务计数是唯一能证明这件事的东西:分两次写的实现,影子最终值、 + 栅极最终电平、函数返回码**全部一模一样**,只有 `n_write` 会是 2。 + 而分两次写的真实后果是「先单按了一下 VOL−」,在有的机型上就是调音量。 */ +static void test_combo_keys_land_in_exactly_one_write(void) { const int rail = 2; - const uint32_t want = ((uint32_t)1u << sr595_key_bit(rail, SR595_KEY_VDN)) | - ((uint32_t)1u << sr595_key_bit(rail, SR595_KEY_PWR)); - const uint32_t latches0 = sim_sr595_latches(); + const uint8_t want = (uint8_t)((1u << pca9555_key_bit(PCA9555_KEY_VDN)) | + (1u << pca9555_key_bit(PCA9555_KEY_PWR))); + const uint32_t w0 = blade(rail)->n_write; TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_FASTBOOT, ACT_KEY_MS_ON)); - TEST_ASSERT_EQUAL_UINT32(latches0 + 1, sim_sr595_latches()); - TEST_ASSERT_EQUAL_UINT32(want, keys_on_chain()); + TEST_ASSERT_EQUAL_UINT32(w0 + 1, blade(rail)->n_write); /* ⭐ 正好一次 */ + TEST_ASSERT_EQUAL_HEX8(want, latch(rail)); + TEST_ASSERT_EQUAL_INT(1, key_pin(rail, PCA9555_KEY_VDN)); + TEST_ASSERT_EQUAL_INT(1, key_pin(rail, PCA9555_KEY_PWR)); + TEST_ASSERT_EQUAL_INT(0, key_pin(rail, PCA9555_KEY_VUP)); /* 第三个键没被顺手带上 */ run_ms(ACT_KEY_MS_ON); - TEST_ASSERT_EQUAL_UINT32(0, keys_on_chain()); - TEST_ASSERT_EQUAL_UINT32(latches0 + 2, sim_sr595_latches()); + TEST_ASSERT_EQUAL_HEX8(0x00, latch(rail)); + TEST_ASSERT_EQUAL_UINT32(w0 + 2, blade(rail)->n_write); /* 松开也是一次 */ } /* Recovery 是 VOL+ + PWR,和 Fastboot 差在哪一个音量键上 —— 两者不能相等。 */ @@ -333,16 +518,41 @@ static void test_recovery_and_fastboot_are_different_key_sets(void) const int rail = 3; TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_RECOVERY, ACT_KEY_MS_ON)); - const uint32_t recovery = keys_on_chain(); + const uint8_t recovery = latch(rail); run_ms(ACT_KEY_MS_ON); TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_FASTBOOT, ACT_KEY_MS_ON)); - const uint32_t fastboot = keys_on_chain(); + const uint8_t fastboot = latch(rail); run_ms(ACT_KEY_MS_ON); - TEST_ASSERT_NOT_EQUAL(recovery, fastboot); - TEST_ASSERT_EQUAL_UINT32((uint32_t)1u << sr595_key_bit(rail, SR595_KEY_PWR), - recovery & fastboot); /* 共有的只有电源键 */ + TEST_ASSERT_TRUE(recovery != fastboot); + TEST_ASSERT_EQUAL_HEX8((uint8_t)(1u << pca9555_key_bit(PCA9555_KEY_PWR)), + (uint8_t)(recovery & fastboot)); /* 共有的只有电源键 */ +} + +/* ⭐ R2 新增:**不许在持锁期间等时间。** 按键保持 100 ms / 2 s / 10 s 是状态机的事, + 不是持锁的事 —— 持着锁等 10 s,那 10 s 里巡检一圈都跑不了,SR-5 整条判据停摆, + 而按键功能本身完全正常(测试与遥测都看不出区别)。 + 证明方式:保持期的每一拍,这把锁都必须是**没人持着**的; + 整个序列上锁的次数正好 **2**(按下一次、松开一次)。 */ +static void test_the_key_hold_never_holds_the_i2c_lock(void) +{ + const int rail = 3; + const uint32_t takes0 = sim_lock_takes(HAL_LOCK_I2C); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_ON)); + TEST_ASSERT_EQUAL_INT(-1, sim_lock_owner(HAL_LOCK_I2C)); /* 写完当场就放掉 */ + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); + + for (int i = 0; i < 120; i++) { + (void)t_actuator_step(); + TEST_ASSERT_EQUAL_INT(-1, sim_lock_owner(HAL_LOCK_I2C)); + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); + sim_time_advance_ms(20); /* 巡检的节拍 */ + } + TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_HEX8(0x00, latch(rail)); + TEST_ASSERT_EQUAL_UINT32(takes0 + 2, sim_lock_takes(HAL_LOCK_I2C)); } /* ⭐ 不重入:一路上正在跑序列时,三个请求全部被拒,而且**一个字节都不发**。 */ @@ -352,16 +562,16 @@ static void test_a_running_sequence_rejects_every_new_request(void) const int en = rail_cfg(rail)->efuse_en; TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_FORCE)); - const uint32_t latches1 = sim_sr595_latches(); - const uint32_t shadow1 = sr595_shadow(); + const uint32_t writes1 = blade(rail)->n_write; + const uint8_t latch1 = latch(rail); const uint32_t en_writes1 = sim_gpio_writes(en); TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, t_actuator_key(rail, ACT_KEYS_VUP, ACT_KEY_MS_TAP)); TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, t_actuator_hard_reset(rail)); TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, t_actuator_edl(rail)); - TEST_ASSERT_EQUAL_UINT32(latches1, sim_sr595_latches()); - TEST_ASSERT_EQUAL_UINT32(shadow1, sr595_shadow()); + TEST_ASSERT_EQUAL_UINT32(writes1, blade(rail)->n_write); + TEST_ASSERT_EQUAL_HEX8(latch1, latch(rail)); TEST_ASSERT_EQUAL_UINT32(en_writes1, sim_gpio_writes(en)); /* 被拒的硬重启没断电 */ /* 被拒的请求也不许改掉正在跑的那一个:仍然按 10 s 松开,不是 100 ms。 */ @@ -372,155 +582,231 @@ static void test_a_running_sequence_rejects_every_new_request(void) TEST_ASSERT_EQUAL_UINT32(1, t_actuator_done(rail)); /* 只跑过一个序列 */ } -/* 互斥是**每路一个**:别的路照样能同时跑。这也是影子寄存器存在的理由 —— - §10.6 那句「另一台手机的按键被顺手清掉」说的就是这里。 */ -static void test_two_rails_run_at_the_same_time_without_clobbering(void) +/* ⭐ 改 CH1 的键不许动 CH4 的。 + R1 里这是「同一条链上的不同位」,位号算错就串路;R2 里这是**两片不同的器件**, + 串路的形态变成了「mux 通道选错」或「影子下标传错」—— + 后果一样是「按了另一台手机的电源键」,而五路电压电流全部正常。 + 所以断言打在两片假器件**各自的输出锁存器**上,外加「另外三片一个字节没动」。 */ +static void test_pressing_ch1_does_not_touch_ch4(void) { - const int b1 = sr595_key_bit(1, SR595_KEY_PWR); - const int b4 = sr595_key_bit(4, SR595_KEY_VUP); + const int pwr = pca9555_key_bit(PCA9555_KEY_PWR); + const int vup = pca9555_key_bit(PCA9555_KEY_VUP); + uint8_t l0[N]; + uint32_t w0[N]; + snap_latches(l0); + snap_writes(w0); TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON)); /* 2 s */ + TEST_ASSERT_EQUAL_INT(rail_cfg(1)->mux_ch, sim_i2c_mux_ch()); + assert_only_this_blade_moved(1, l0, w0); + run_ms(50); + snap_latches(l0); + snap_writes(w0); TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(4, ACT_KEYS_VUP, ACT_KEY_MS_TAP)); /* 100 ms */ - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(b1)); - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(b4)); + TEST_ASSERT_EQUAL_INT(rail_cfg(4)->mux_ch, sim_i2c_mux_ch()); + assert_only_this_blade_moved(4, l0, w0); /* CH1 的电源键仍按着,没被顺手改掉 */ + + TEST_ASSERT_EQUAL_INT(1, pin(1, pwr)); + TEST_ASSERT_EQUAL_INT(1, pin(4, vup)); run_ms(ACT_KEY_MS_TAP); /* CH4 到点 */ - TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(b4)); - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(b1)); /* ⭐ CH1 没被顺手清掉 */ + TEST_ASSERT_EQUAL_INT(0, pin(4, vup)); + TEST_ASSERT_EQUAL_INT(1, pin(1, pwr)); /* ⭐ CH1 没被顺手清掉 */ TEST_ASSERT_TRUE(t_actuator_busy(1)); TEST_ASSERT_FALSE(t_actuator_busy(4)); run_ms(ACT_KEY_MS_ON); /* CH1 到点 */ - TEST_ASSERT_EQUAL_UINT32(0, keys_on_chain()); + for (int r = 1; r <= N; r++) { + TEST_ASSERT_EQUAL_HEX8(0x00, latch(r)); + } TEST_ASSERT_FALSE(t_actuator_busy(1)); } +/* ⭐ R2 新增:**每一次动作发生时,mux 通道选的是这一路。** + 五路逐个跑一遍完整按键序列,每一次写都要满足两件事: + ① 写完之后当前通道就是 `rail_cfg(rail)->mux_ch`(查表,不算); + ② 只有这一片的锁存器和写计数变了,别的四片一个字节没动。 + ⚠️ 「mux_ch = rail − 1」现在确实成立,所以把它写死也能过 ① —— + ② 才是真正拦得住「影子下标传错」的那一条(那时通道是对的,影子记到了别片上, + 下一次写会把那一片的键顺手改掉)。 */ +static void test_every_action_selects_this_rails_mux_channel(void) +{ + for (int rail = 1; rail <= N; rail++) { + uint8_t l0[N]; + uint32_t w0[N]; + snap_latches(l0); + snap_writes(w0); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_RECOVERY, ACT_KEY_MS_TAP)); + TEST_ASSERT_EQUAL_INT(rail_cfg(rail)->mux_ch, sim_i2c_mux_ch()); + TEST_ASSERT_EQUAL_UINT32(w0[rail - 1] + 1, blade(rail)->n_write); + assert_only_this_blade_moved(rail, l0, w0); + + snap_latches(l0); + snap_writes(w0); + run_ms(ACT_KEY_MS_TAP); + TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_INT(rail_cfg(rail)->mux_ch, sim_i2c_mux_ch()); + TEST_ASSERT_EQUAL_HEX8(0x00, latch(rail)); + assert_only_this_blade_moved(rail, l0, w0); + } +} + /* ==== 验收主项三:EDL 顺序固定 =========================================== */ -/* SW-6:`EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0`。**先短接再插 USB**。 - 只看终值是分不出顺序的(终值都是「EDL 松开、VBUS 开着」),所以这里比的是 - 全局写序号。 */ +/* SW-6:`EDL_EN=1` → 等 10 ms → `VBUS_EN=1` → 等枚举 3 s → `EDL_EN=0`。 + **先短接再插 USB** 是硬要求。R1 里这两样一个在 595 上、一个是 GPIO,只能比写序号; + R2 里它们是**同一个字节的两位**,所以改成在三个时刻各看一眼 —— + 顺序写反的实现,中间那一眼会看到「USB 已经插上、短接还没建立」。 */ static void test_edl_order_is_short_then_vbus_then_release(void) { - const int rail = 3; - const int edl = sr595_edl_bit(rail); - const int vb = rail_cfg(rail)->vbus_en; + const int rail = 3; + const uint32_t w0 = blade(rail)->n_write; - TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(vb)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_edl(rail)); - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(edl)); - TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(vb)); /* USB 还没插 */ - const uint32_t seq_short = sim_gpio_write_seq(SR595_PIN_RCLK); + TEST_ASSERT_EQUAL_INT(1, pin(rail, PCA9555_BIT_EDL_EN)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); /* USB 还没插 */ + TEST_ASSERT_EQUAL_UINT32(w0 + 1, blade(rail)->n_write); run_ms(ACT_EDL_SHORT_MS - 1); - TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(vb)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); run_ms(1); - TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(vb)); - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(edl)); /* 还短接着 */ - const uint32_t seq_vbus = sim_gpio_write_seq(vb); + TEST_ASSERT_EQUAL_INT(1, pin(rail, PCA9555_BIT_VBUS_EN)); + TEST_ASSERT_EQUAL_INT(1, pin(rail, PCA9555_BIT_EDL_EN)); /* 还短接着 */ + TEST_ASSERT_TRUE(slot_vbus_is_on(rail)); + TEST_ASSERT_EQUAL_UINT32(w0 + 2, blade(rail)->n_write); run_ms(ACT_EDL_ENUM_MS - 1); - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(edl)); + TEST_ASSERT_EQUAL_INT(1, pin(rail, PCA9555_BIT_EDL_EN)); run_ms(1); - TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(edl)); /* 枚举完才松短接 */ - const uint32_t seq_release = sim_gpio_write_seq(SR595_PIN_RCLK); - - TEST_ASSERT_TRUE(seq_short < seq_vbus); - TEST_ASSERT_TRUE(seq_vbus < seq_release); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_EDL_EN)); /* 枚举完才松短接 */ + TEST_ASSERT_EQUAL_UINT32(w0 + 3, blade(rail)->n_write); /* VBUS **留着** —— 手机现在正挂在 9008 上,把它断了等于白跑一趟。 */ - TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(vb)); + TEST_ASSERT_EQUAL_INT(1, pin(rail, PCA9555_BIT_VBUS_EN)); + TEST_ASSERT_TRUE(slot_vbus_is_on(rail)); TEST_ASSERT_FALSE(t_actuator_busy(rail)); TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail)); + /* 一个键都没按过:EDL 只该动 bit3 和 bit4。 */ + TEST_ASSERT_EQUAL_HEX8(0x00, (uint8_t)(latch(rail) & pca9555_key_mask())); } -/* EDL 用的是 `sr595_edl_bit()` 那张表,一路都不能串。CH1 在 U33.QA(16), - CH2~5 在 U21 上而且不是顺序的 —— 算出来的位号必错。 */ -static void test_edl_uses_the_lookup_table_not_arithmetic(void) +/* 五路逐个跑 EDL:位号五片相同(`PCA9555_BIT_EDL_EN`),但**器件是五片不同的**, + 所以每一路都要落在自己那一片上,别的四片不许出现 `EDL_EN`。 */ +static void test_edl_lands_on_its_own_blade_for_every_rail(void) { for (int rail = 1; rail <= N; rail++) { TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_edl(rail)); - TEST_ASSERT_EQUAL_UINT32((uint32_t)1u << sr595_edl_bit(rail), - sim_sr595_outputs() & ~sr595_all_keys_mask() & - ~((uint32_t)1u << SR595_BIT_HUB_RST)); + TEST_ASSERT_EQUAL_INT(rail_cfg(rail)->mux_ch, sim_i2c_mux_ch()); + for (int r = 1; r <= N; r++) { + TEST_ASSERT_EQUAL_INT(r == rail ? 1 : 0, pin(r, PCA9555_BIT_EDL_EN)); + } run_ms(ACT_EDL_SHORT_MS + ACT_EDL_ENUM_MS); TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_EDL_EN)); + TEST_ASSERT_EQUAL_INT(1, pin(rail, PCA9555_BIT_VBUS_EN)); + } + /* 跑完五路:五片的 VBUS 都开着、EDL 都松了,一个键都没按着。 */ + for (int r = 1; r <= N; r++) { + TEST_ASSERT_EQUAL_HEX8((uint8_t)(1u << PCA9555_BIT_VBUS_EN), latch(r)); } } /* ==== 收尾:按下去的东西一定要松开 ======================================= */ -/* 595 那把锁被别人占着时,松键落不下去。**不许放弃** —— - 放弃 = 电源键留在按下状态,而遥测上五路电压电流一切正常。 */ +/* ⭐ I²C 锁被别人占着时,松键落不下去。**不许放弃** —— + 放弃 = 电源键留在按下状态,手机再也起不来,而遥测上五路电压电流一切正常。 + R1 靠占住 595 那把锁制造写失败,R2 里只剩一把锁,占的就是巡检也在用的那一把, + 所以这条路径在真板上**每天都会走到**(不是异常路径)。 */ static void test_cleanup_retries_until_the_key_is_really_released(void) { const int rail = 1; - const int bit = sr595_key_bit(rail, SR595_KEY_PWR); + const int bit = pca9555_key_bit(PCA9555_KEY_PWR); TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_TAP)); - - /* 扮演第二个任务,把 595 那把锁占住(A15 的初始化、A10 的收尾都会拿它)。 */ - sim_lock_set_task(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_SR595, 10)); - sim_lock_set_task(0); + other_task_takes_the_bus(); run_ms(ACT_KEY_MS_TAP); TEST_ASSERT_EQUAL_INT(ACT_ST_CLEANUP, t_actuator_state(rail)); TEST_ASSERT_TRUE(t_actuator_busy(rail)); - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(bit)); /* 键还按着 */ + TEST_ASSERT_EQUAL_INT(1, pin(rail, bit)); /* 键还按着 */ for (int i = 0; i < 5; i++) { (void)t_actuator_step(); } TEST_ASSERT_TRUE(t_actuator_cleanup_retries(rail) > 0); + TEST_ASSERT_EQUAL_INT(1, pin(rail, bit)); /* 重试期间键仍然按着,没被当成已松开 */ /* 收尾期间这一路照样算 busy,新请求进不来。 */ TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_TAP)); - sim_lock_set_task(1); - hal_lock_give(HAL_LOCK_SR595); - sim_lock_set_task(0); + other_task_frees_the_bus(); (void)t_actuator_step(); - TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit)); /* 锁一放开就落地 */ + TEST_ASSERT_EQUAL_INT(0, pin(rail, bit)); /* 锁一放开就落地 */ + TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_HEX8(0x00, latch(rail)); +} + +/* 收尾重试**永远不会自己放弃**:把锁占住不放,跑上几百拍, + 这一路必须一直是 CLEANUP、一直 busy、键一直按着。 + 写成「重试 N 次就算了」的实现在这里会悄悄回到 IDLE —— 那正是最坏的失败。 */ +static void test_cleanup_never_gives_up_no_matter_how_long_the_bus_is_busy(void) +{ + const int rail = 2; + const int bit = pca9555_key_bit(PCA9555_KEY_PWR); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_TAP)); + other_task_takes_the_bus(); + run_ms(ACT_KEY_MS_TAP); + + for (int i = 0; i < 500; i++) { + (void)t_actuator_step(); + TEST_ASSERT_EQUAL_INT(ACT_ST_CLEANUP, t_actuator_state(rail)); + TEST_ASSERT_TRUE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_INT(1, pin(rail, bit)); + } + TEST_ASSERT_TRUE(t_actuator_cleanup_retries(rail) >= 500); + + other_task_frees_the_bus(); + (void)t_actuator_step(); + TEST_ASSERT_EQUAL_INT(0, pin(rail, bit)); TEST_ASSERT_FALSE(t_actuator_busy(rail)); } -/* 按下那一步就失败(595 那把锁被别人占着)。请求返回错误码,但**不能就这么算了** —— - 影子有可能已经被改成「按下」了(拿到锁而整帧没发出去的那种失败),直接回 IDLE - 等于把一个脏的「按下」留在那儿:谁下一次 flush 谁就替我们把键按下去, - 而没有任何人会来松开它。所以失败的按下照样要走收尾。 */ +/* 按下那一步就失败(I²C 锁被别人占着)。请求返回错误码,但**不能就这么算了** —— + 事务有可能发出去一半(驱动的影子不更新,可器件里那个字节是未知的), + 直接回 IDLE 等于把一个也许已经按下的键留在那儿,而没有任何人会来松开它。 + 所以失败的按下照样要走收尾。 */ static void test_a_failed_press_still_goes_through_cleanup(void) { const int rail = 4; - const int bit = sr595_key_bit(rail, SR595_KEY_PWR); + const int bit = pca9555_key_bit(PCA9555_KEY_PWR); - sim_lock_set_task(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_SR595, 10)); - sim_lock_set_task(0); + other_task_takes_the_bus(); TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_TAP)); TEST_ASSERT_EQUAL_INT(ACT_ST_CLEANUP, t_actuator_state(rail)); TEST_ASSERT_EQUAL_INT(ACT_RES_IO, t_actuator_last_result(rail)); - TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit)); /* 这一次键根本没按下去 */ + TEST_ASSERT_EQUAL_INT(0, pin(rail, bit)); /* 这一次键根本没按下去 */ - sim_lock_set_task(1); - hal_lock_give(HAL_LOCK_SR595); - sim_lock_set_task(0); + other_task_frees_the_bus(); (void)t_actuator_step(); TEST_ASSERT_FALSE(t_actuator_busy(rail)); - TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, bit)); + TEST_ASSERT_EQUAL_HEX8(0x00, latch(rail)); /* 收尾真的把那几位显式清了一遍 */ } -/* `t_actuator_abort()`:立刻收尾,键当场松开,但**不动两根 EN 线** —— +/* `t_actuator_abort()`:立刻收尾,键当场松开,但**不动 `EFUSE_EN` / `VBUS_EN`** —— 把电再关一次或再开一次都是自作主张(那是 A10 的判据和上位机命令的事)。 */ static void test_abort_releases_the_key_and_leaves_the_pins_alone(void) { const int rail = 2; - const int bit = sr595_key_bit(rail, SR595_KEY_PWR); + const int bit = pca9555_key_bit(PCA9555_KEY_PWR); const int en = rail_cfg(rail)->efuse_en; TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_FORCE)); @@ -528,7 +814,7 @@ static void test_abort_releases_the_key_and_leaves_the_pins_alone(void) const uint32_t en_writes = sim_gpio_writes(en); t_actuator_abort(rail); - TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, bit)); TEST_ASSERT_FALSE(t_actuator_busy(rail)); TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, t_actuator_last_result(rail)); TEST_ASSERT_EQUAL_UINT32(en_writes, sim_gpio_writes(en)); @@ -550,6 +836,103 @@ static void test_abort_during_hard_reset_does_not_restore_power(void) TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(en)); /* 还是关着的,等上层拿主意 */ } +/* EDL 跑到「已插 USB」那一段被打断:`EDL_EN` 立刻松(那是本序列按下去的), + **`VBUS_EN` 一动不动** —— R2 里它和按键在同一个字节里,一不小心就会被 + 收尾那次读改写顺手清掉,而现象是「手机莫名其妙从 9008 掉线」。 */ +static void test_abort_during_edl_releases_the_short_but_keeps_vbus(void) +{ + const int rail = 1; + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_edl(rail)); + run_ms(ACT_EDL_SHORT_MS + 100); + TEST_ASSERT_EQUAL_INT(ACT_ST_EDL_ENUM, t_actuator_state(rail)); + TEST_ASSERT_EQUAL_INT(1, pin(rail, PCA9555_BIT_VBUS_EN)); + + t_actuator_abort(rail); + TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, t_actuator_last_result(rail)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_EDL_EN)); + TEST_ASSERT_EQUAL_INT(1, pin(rail, PCA9555_BIT_VBUS_EN)); /* ⭐ USB 还插着 */ + TEST_ASSERT_TRUE(slot_vbus_is_on(rail)); +} + +/* ==== ⭐ R2 新增:执行器与巡检争用同一把锁 =============================== */ + +/* R1 里两把锁永远不会嵌套,所以这一层和巡检井水不犯河水。 + R2 只剩一把锁,按键 / EDL / VBUS 全在上面 ⇒ 两个任务开始抢。 + 抢锁写错了的现象**不是崩**,是慢性的:巡检每圈都跳过(SR-5 判据停摆), + 或者某一路的键永远松不开(手机再也起不来)—— 两样都是遥测上一切正常。 + + 这里交替叫 `t_safety_step()` 与 `t_actuator_step()` 跑一千多圈, + 并且每隔 13 圈让第三个任务把总线占住一拍(制造真实的争用),断言三件事: + ① 巡检 `skipped` **有界**(只在被占住的那些圈里跳,不会雪崩); + ② 键最终一定全部松开; + ③ 没有哪一路卡在 CLEANUP 出不来。 */ +static void test_actuator_and_safety_share_one_lock_without_starving(void) +{ + /* 巡检要读的六片 INA226:五路各一片(同址 0x40,靠通道区分)+ 输入侧在主干。 */ + for (int rail = 1; rail <= N; rail++) { + sim_i2c_dev_t *d = sim_i2c_add_ch(rail_cfg(rail)->mux_ch, rail_cfg(rail)->ina_addr, 2); + d->regs[INA226_REG_BUS_V] = 3120; /* 3.9 V ÷ 1.25 mV */ + d->regs[INA226_REG_CURRENT] = (uint16_t)(rail * 100); + } + sim_i2c_add(INA226_ADDR_INPUT, 2); + + safety_snap_t snap; + t_safety_init(&snap); + + const int rounds = 1300; + uint32_t busy_rounds = 0; + uint32_t issued = 0; + + for (int i = 0; i < rounds; i++) { + const bool bus_busy = (i % 13 == 0); + if (bus_busy) { + other_task_takes_the_bus(); /* 第三个任务(比如 A12 的命令处理器)占住总线 */ + busy_rounds++; + } + + (void)t_safety_step(&snap); + (void)t_actuator_step(); + + if (bus_busy) { + other_task_frees_the_bus(); + } + + /* 不断有新的按键请求进来 —— 五路轮着来,包括总线正忙的那些圈。 */ + if (i % 7 == 0) { + const int rail = (i / 7) % N + 1; + if (t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_TAP) == HAL_OK) { + issued++; + } + } + sim_time_advance_ms(20); + } + + /* 收场:不再下新请求,总线空着,所有序列必须走完。 */ + for (int i = 0; i < 300; i++) { + (void)t_safety_step(&snap); + (void)t_actuator_step(); + sim_time_advance_ms(20); + } + + TEST_ASSERT_TRUE(issued > 50); /* 争用之下照样接得下活,不是被拒到底 */ + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_FALSE(t_actuator_busy(rail)); /* ③ 没卡在 CLEANUP */ + TEST_ASSERT_EQUAL_INT(ACT_ST_IDLE, t_actuator_state(rail)); + TEST_ASSERT_EQUAL_HEX8(0x00, (uint8_t)(latch(rail) & pca9555_key_mask())); /* ② */ + } + /* ① `skipped` 有界:只可能落在被占住的那些圈里。真跳过了才算测到了争用。 */ + TEST_ASSERT_TRUE(snap.skipped > 0); + TEST_ASSERT_TRUE(snap.skipped <= busy_rounds); + /* 抢了一千多圈,加锁纪律一次都没破,也没出现「两片一起应答」。 */ + TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); + printf(" 争用 %d 圈:巡检跳过 %u 圈(总线被占 %u 圈),下发按键 %u 次\n", rounds, + (unsigned)snap.skipped, (unsigned)busy_rounds, (unsigned)issued); +} + /* ==== 参数与常量 ========================================================== */ static void test_bad_arguments_are_rejected(void) @@ -561,6 +944,7 @@ static void test_bad_arguments_are_rejected(void) TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_actuator_key(1, 0, 100)); /* 一个键都没选 */ TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_actuator_key(1, 1u << 5, 100)); /* 不存在的键 */ + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_actuator_key(1, 1u << PCA9555_BIT_EDL_EN, 100)); TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_actuator_key(1, ACT_KEYS_PWR, 0)); /* 按 0 ms */ /* 上限拦的是调用方的手误:传一个 10 分钟进来等于把电源键按死。 */ @@ -568,9 +952,12 @@ static void test_bad_arguments_are_rejected(void) t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MAX_MS + 1)); TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MAX_MS)); - /* 被拒的请求一律不留痕迹。 */ + /* 被拒的请求一律不留痕迹:别的四片一个字节都没写过。 */ TEST_ASSERT_FALSE(t_actuator_busy(2)); - TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_SR595)); + for (int r = 2; r <= N; r++) { + TEST_ASSERT_EQUAL_HEX8(0x00, latch(r)); + } + TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); } /* 时间常量与文档逐格对账。改了这里就是改了需求,得先去改文档。 */ @@ -580,39 +967,64 @@ static void test_timings_match_the_documents(void) TEST_ASSERT_EQUAL_UINT32(3000, ACT_HR_OFF_MIN_MS); /* §7.4 的 3~5 s 窗口 */ TEST_ASSERT_EQUAL_UINT32(5000, ACT_HR_OFF_MAX_MS); TEST_ASSERT_TRUE(ACT_HR_OFF_MS >= ACT_HR_OFF_MIN_MS && ACT_HR_OFF_MS <= ACT_HR_OFF_MAX_MS); + TEST_ASSERT_EQUAL_UINT32(500, ACT_HR_PG_TIMEOUT_MS); + /* 轮询间隔必须远小于超时,否则「等到 PG 就结束」退化成「一律等满 500 ms」。 */ + TEST_ASSERT_TRUE(ACT_HR_PG_POLL_MS > 0 && ACT_HR_PG_POLL_MS * 10 <= ACT_HR_PG_TIMEOUT_MS); TEST_ASSERT_EQUAL_UINT32(100, ACT_KEY_MS_TAP); /* SW-5 三档 */ TEST_ASSERT_EQUAL_UINT32(2000, ACT_KEY_MS_ON); TEST_ASSERT_EQUAL_UINT32(10000, ACT_KEY_MS_FORCE); TEST_ASSERT_TRUE(ACT_KEY_MS_FORCE <= ACT_KEY_MAX_MS); /* 最长那一档不能被自己的上限拦掉 */ - /* 逻辑键位就是 sr595 的那三个下标,组合键是它们的并。 */ + TEST_ASSERT_EQUAL_UINT32(10, ACT_EDL_SHORT_MS); /* SW-6:先短接再插 USB */ + TEST_ASSERT_EQUAL_UINT32(3000, ACT_EDL_ENUM_MS); + + /* ⭐ R2:等锁的超时就是 `slot` 那一个,不许在这里另配一个数 —— + 两个数分家之后,「执行器等 20 ms、巡检等 5 ms」这套优先级就悄悄失效了。 */ + TEST_ASSERT_EQUAL_UINT32(SLOT_LOCK_TIMEOUT_MS, ACT_LOCK_TIMEOUT_MS); + TEST_ASSERT_EQUAL_UINT32(20, ACT_LOCK_TIMEOUT_MS); + + /* 逻辑键位就是 PCA9555 那三个键的下标,组合键是它们的并。 */ + TEST_ASSERT_EQUAL_UINT32(1u << PCA9555_KEY_PWR, (uint32_t)ACT_KEYS_PWR); + TEST_ASSERT_EQUAL_UINT32(1u << PCA9555_KEY_VUP, (uint32_t)ACT_KEYS_VUP); + TEST_ASSERT_EQUAL_UINT32(1u << PCA9555_KEY_VDN, (uint32_t)ACT_KEYS_VDN); TEST_ASSERT_EQUAL_UINT32(ACT_KEYS_VDN | ACT_KEYS_PWR, (uint32_t)ACT_KEYS_FASTBOOT); TEST_ASSERT_EQUAL_UINT32(ACT_KEYS_VUP | ACT_KEYS_PWR, (uint32_t)ACT_KEYS_RECOVERY); + TEST_ASSERT_TRUE((uint32_t)ACT_KEYS_FASTBOOT != (uint32_t)ACT_KEYS_RECOVERY); + + /* 三个键的位号一律查表得到,而且必须落在 Port0 那五个输出位里。 */ + TEST_ASSERT_EQUAL_HEX8(0x07, pca9555_key_mask()); + TEST_ASSERT_EQUAL_HEX8(0x00, (uint8_t)(pca9555_key_mask() & ~PCA9555_P0_OUT_MASK)); } -/* 加锁纪律那条检查本身不能是句空话:不拿锁直接写一帧,违规必须 +1。 +/* 加锁纪律那条检查本身不能是句空话:不拿锁直接写一片 PCA9555,违规必须 +1。 (没有这条反向测试,上面那些「violations == 0」在检查失灵时会永远绿。) */ static void test_the_lock_discipline_check_actually_fires(void) { - TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_SR595)); - (void)sr595_write((uint32_t)1u << sr595_key_bit(1, SR595_KEY_PWR), 0xFFFFFFFFu & - SR595_MASK_VALID); - TEST_ASSERT_TRUE(sim_lock_violations(HAL_LOCK_SR595) > 0); + TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); + + const uint8_t wr[2] = { PCA9555_REG_OUTPUT0, 0x00 }; /* 值本身无害,越权的是「没拿锁」 */ + (void)hal_i2c_write(PCA9555_ADDR, wr, sizeof(wr)); + + TEST_ASSERT_TRUE(sim_lock_violations(HAL_LOCK_I2C) > 0); } -/* 空闲时 `t_actuator_step()` 什么也不做,也不该去动锁或引脚。 */ +/* 空闲时 `t_actuator_step()` 什么也不做,也不该去动锁或任何一片器件 —— + R2 里「顺手刷一次影子」的代价是每一拍都从巡检手里抢一次锁。 */ static void test_idle_step_is_free(void) { - const uint32_t takes = sim_lock_takes(HAL_LOCK_SR595); - const uint32_t latches = sim_sr595_latches(); + const uint32_t takes = sim_lock_takes(HAL_LOCK_I2C); + uint8_t l0[N]; + uint32_t w0[N]; + snap_latches(l0); + snap_writes(w0); for (int i = 0; i < 100; i++) { TEST_ASSERT_EQUAL_UINT32(ACT_IDLE_WAIT_MS, t_actuator_step()); sim_time_advance_ms(ACT_IDLE_WAIT_MS); } - TEST_ASSERT_EQUAL_UINT32(takes, sim_lock_takes(HAL_LOCK_SR595)); - TEST_ASSERT_EQUAL_UINT32(latches, sim_sr595_latches()); + TEST_ASSERT_EQUAL_UINT32(takes, sim_lock_takes(HAL_LOCK_I2C)); + assert_only_this_blade_moved(0, l0, w0); } static void test_format_prints_every_rail(void) @@ -624,7 +1036,7 @@ static void test_format_prints_every_rail(void) TEST_ASSERT_TRUE(n > 0 && n < (int)sizeof(buf)); TEST_ASSERT_NOT_NULL(strstr(buf, "ch2=key-hold")); TEST_ASSERT_NOT_NULL(strstr(buf, "ch5=idle")); - printf("%s\n", buf); + printf(" %s\n", buf); /* 缓冲太小也不许越界。 */ char small[12]; @@ -639,26 +1051,33 @@ int main(void) UNITY_BEGIN(); RUN_TEST(test_hard_reset_walks_the_full_power_up_sequence); - RUN_TEST(test_hard_reset_touches_only_its_own_two_pins); + RUN_TEST(test_hard_reset_touches_only_its_own_en_pin); RUN_TEST(test_no_pg_leaves_the_rail_powered); RUN_TEST(test_pg_stuck_low_also_leaves_the_rail_powered); RUN_TEST(test_late_pg_is_still_accepted); RUN_TEST(test_the_task_loop_only_sleeps_what_step_asks_for); RUN_TEST(test_hard_reset_releases_a_stuck_key_before_cutting_power); + RUN_TEST(test_the_defensive_release_sends_nothing_when_the_shadow_is_clean); RUN_TEST(test_key_tap_presses_the_table_bit_and_releases_it); - RUN_TEST(test_combo_keys_land_in_exactly_one_frame); + RUN_TEST(test_combo_keys_land_in_exactly_one_write); RUN_TEST(test_recovery_and_fastboot_are_different_key_sets); + RUN_TEST(test_the_key_hold_never_holds_the_i2c_lock); RUN_TEST(test_a_running_sequence_rejects_every_new_request); - RUN_TEST(test_two_rails_run_at_the_same_time_without_clobbering); + RUN_TEST(test_pressing_ch1_does_not_touch_ch4); + RUN_TEST(test_every_action_selects_this_rails_mux_channel); RUN_TEST(test_edl_order_is_short_then_vbus_then_release); - RUN_TEST(test_edl_uses_the_lookup_table_not_arithmetic); + RUN_TEST(test_edl_lands_on_its_own_blade_for_every_rail); RUN_TEST(test_cleanup_retries_until_the_key_is_really_released); + RUN_TEST(test_cleanup_never_gives_up_no_matter_how_long_the_bus_is_busy); RUN_TEST(test_a_failed_press_still_goes_through_cleanup); RUN_TEST(test_abort_releases_the_key_and_leaves_the_pins_alone); RUN_TEST(test_abort_during_hard_reset_does_not_restore_power); + RUN_TEST(test_abort_during_edl_releases_the_short_but_keeps_vbus); + + RUN_TEST(test_actuator_and_safety_share_one_lock_without_starving); RUN_TEST(test_bad_arguments_are_rejected); RUN_TEST(test_timings_match_the_documents); diff --git a/firmware/test/test_t_safety.c b/firmware/test/test_t_safety.c index 362322c..42fb2c2 100644 --- a/firmware/test/test_t_safety.c +++ b/firmware/test/test_t_safety.c @@ -1,46 +1,146 @@ -/* A8 之二:`t_safety` 的一个周期,以及需求 §10.3 的两条验收判据 —— - ① sim 时钟下周期是 20 ms ±1 ms; - ② I²C 占用打印出来,与 §10.3 手算的「约 1.5 ms / 约 8 %」对得上。 +/* A10-R2 之一:`t_safety` **一圈的形状与账**(需求 §10.3、计划 §5.5)。 + 判据本身在 `test_t_safety_rules.c` 里,这里只管「这一圈发了什么、发给了谁」。 - ⚠️ 这两条能成立,靠的是 sim 的假 I²C **真的把虚拟时钟花掉** - (`sim_i2c_set_bus_time`,默认开)。没有那一条,巡检在 sim 里耗时为零, - 「20 ms 周期」就退化成「测 delay 参数传对了没」—— 那是句空话: - 真正会出事的是「巡检自己占掉了 1.5 ms,节拍还准不准」。 */ + ---- R1 → R2,这个文件测的东西换了重心 ------------------------------------ + + R1:一条总线、六片不同地址的 INA226,一圈 13 次事务。那时唯一会出事的形态是 + 「地址算错」,而地址算错**会 NAK**,一眼就能看见。 + + R2:五片刀片完全同构(INA226 全是 0x40、PCA9555 全是 0x20),区分它们的 + 唯一手段是底板 TCA9548A 的通道。于是出现了一种 R1 根本没有的错误形态: + **通道选错 ⇒ 读到另一台手机的电压电流**,而量纲、范围、变化趋势全都正常, + 没有任何一层会报错、会 NAK、会计数。⭐ 本文件里带星的那两条就是它的拦网。 + + ⚠️ 两条能成立的前提: + · 假总线里有一颗**真的 mux**(`sim_i2c_add_mux`)—— 没有它,五片刀片会 + 塌成一片,「选错通道」在宿主机上一次也复现不出来; + · 假 I²C **真的把虚拟时钟花掉**(`sim_i2c_set_bus_time`,默认开)—— + 没有它,「20 ms 周期」就退化成「测 delay 参数传对了没」,而真正会出事的是 + 「巡检自己占掉了 2.34 ms,节拍还准不准」。 */ #include #include +#include "board_pins.h" +#include "hal_gpio.h" #include "hal_i2c.h" #include "hal_i2c_acct.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 "sched.h" #include "sim_hal.h" +#include "slot.h" +#include "t_actuator.h" #include "t_safety.h" #include "tasks.h" +#include "tca9548a.h" #include "unity.h" #define N PHONERAIL_RAIL_COUNT -/* 一圈发出去的事务数:6 片 INA226 各 2 次寄存器读 + PCA9555 一次。 */ -#define TXNS_PER_CYCLE (INA226_COUNT * 2 + 1) +/* 主干上那两片 DAC(`rail.h` 的 U6 / U7)。巡检一次也不该碰它们 —— + 设定值是影子里的(`rail_setpoint_mv()`),去读 DAC 就是每圈多五次事务。 */ +#define DAC_ADDR_A 0x4A +#define DAC_ADDR_B 0x4B -/* 把假板子搭起来:6 片 INA226 + 1 片 PCA9555,地址全部**查表**取。 */ +/* ⭐ 五路预置成**互不相同**的电压:CH1 3600 → CH5 4000 mV。 + 只有值各不相同,「读到了另一台手机的数据」才留得下痕迹。 */ +#define RAIL_MV(rail) (3600 + 100 * ((rail) - 1)) +/* 电流也各不相同:CH1 1 A → CH5 5 A。 */ +#define RAIL_UA(rail) ((rail) * 1000000) + +/* 五片刀片的 Port1(四个故障/PG 信号,开漏上拉 ⇒ 静息 0x0F)各驱动成不同的图案, + 每一片**恰好有一个信号在响**,而且响的不是同一个。 + 全驱成一样的话,「五路读的都是同一片」这条错就看不出来。 */ +static const uint8_t k_port1[N] = { + (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_EFUSE_FLT)), /* CH1:eFuse 报故障 */ + (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_BUCK_PG)), /* CH2:Buck PG 掉了 */ + (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_INA_ALERT)), /* CH3:INA226 在告警 */ + (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_EFUSE_PG)), /* CH4:eFuse PG 掉了 */ + 0x0Fu, /* CH5:什么事都没有 */ +}; + +static safety_snap_t g_snap; + +/* `board_up()` 跑完那一刻的基准值。启动本身会在总线和引脚上留下痕迹, + 拿绝对值去断言等于把启动的账算进第一圈(那正是 `t_safety_init()` 对齐游标要防的事)。 */ +static uint32_t g_resets0; /* `MUX_RST#` 复位次数 —— `i2cbus_init()` 必定留下一次,见下 */ + +static int ch_of(int rail) { return (int)rail_cfg(rail)->mux_ch; } + +static sim_i2c_dev_t *ina_of(int rail) +{ + return sim_i2c_dev_ch(ch_of(rail), rail_cfg(rail)->ina_addr); +} +static sim_i2c_dev_t *pca_of(int rail) { return sim_i2c_dev_ch(ch_of(rail), PCA9555_ADDR); } +static sim_i2c_dev_t *mux_dev(void) { return sim_i2c_dev(TCA9548A_ADDR); } + +/* 从**假器件的寄存器**注入,不去改快照 —— 改快照等于跳过驱动和 mux, + 那样连「读的是哪一片」都证明不了。 */ +static void put_vi(int rail, int mv, int ua) +{ + const int ch = ch_of(rail); + const uint8_t addr = rail_cfg(rail)->ina_addr; + TEST_ASSERT_EQUAL_INT(0, mv % 5); /* 母线 LSB 1.25 mV:不是 5 的倍数就注不准 */ + sim_i2c_set_reg_ch(ch, addr, INA226_REG_BUS_V, (uint16_t)(mv * 4 / 5)); + sim_i2c_set_reg_ch(ch, addr, INA226_REG_CURRENT, + (uint16_t)(int16_t)(ua / INA226_CURRENT_LSB_UA)); +} + +/* 假板子:主干(mux + 输入侧 INA226 + 两片 DAC)+ 五片刀片(各 INA226 + PCA9555)。 */ static void board_up(void) { - const hal_i2c_cfg_t cfg = { .sda_gpio = 14, .scl_gpio = 15, .freq_hz = 400000 }; + const hal_i2c_cfg_t cfg = { .sda_gpio = BOARD_PIN_I2C_SDA, + .scl_gpio = BOARD_PIN_I2C_SCL, + .freq_hz = 400000 }; TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg)); TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); + sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N); + for (int rail = 1; rail <= N; rail++) { - sim_i2c_dev_t *d = sim_i2c_add(rail_cfg(rail)->ina_addr, 2); - d->regs[INA226_REG_BUS_V] = 3120; /* 3.9 V ÷ 1.25 mV */ - d->regs[INA226_REG_CURRENT] = (uint16_t)(rail * 1000); /* 每路不同,好认 */ + const int ch = ch_of(rail); + sim_i2c_add_ch(ch, rail_cfg(rail)->ina_addr, 2); + put_vi(rail, RAIL_MV(rail), RAIL_UA(rail)); + sim_pca9555_attach_ch(rail - 1, ch, PCA9555_ADDR); + sim_pca9555_drive(rail - 1, 1, k_port1[rail - 1]); } + sim_i2c_add(INA226_ADDR_INPUT, 2); - sim_pca9555_attach(PCA9555_ADDR); + sim_i2c_set_reg(INA226_ADDR_INPUT, INA226_REG_BUS_V, (uint16_t)(12000 * 4 / 5)); + sim_i2c_set_reg(INA226_ADDR_INPUT, INA226_REG_CURRENT, (uint16_t)(2000000 / 500)); + sim_i2c_add(DAC_ADDR_A, 2); + sim_i2c_add(DAC_ADDR_B, 2); + + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_prsnt_init()); + + /* ⚠️ `PRSNT#` 配的是**上拉输入**,没人驱动就是高 = 刀片不在位。 + 不驱低的话每一圈都会触发「刀片不在位」判据,而现象是「所有用例都多出几条 + 事件、五路自己关掉」—— 看上去像判据写错了,其实是假板子插反了。 */ + for (int rail = 1; rail <= N; rail++) { + sim_gpio_drive(rail_cfg(rail)->prsnt_n, 0); + } + slot_prsnt_poll(); + + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail)); + } + t_actuator_init(NULL, NULL); + rail_setpoint_reset(); + t_safety_init(&g_snap); + + /* ⚠️ 这里一定是 1,不是 0:`i2cbus_init()` 先把 `MUX_RST#` 配成输出(两个 HAL + 都保证「配完是 0」)再写 1,中间那一瞬间对真器件就是一次复位脉冲,假 mux + 如实照做。这个窗口是已知且可接受的(那时还没人用总线),但**不能当它不存在** + —— 所以后面的用例一律和这个基准比差值。 */ + g_resets0 = sim_i2c_mux_resets(); + TEST_ASSERT_EQUAL_UINT32(1, g_resets0); } void setUp(void) @@ -48,142 +148,271 @@ void setUp(void) sim_hal_reset(); board_up(); } -void tearDown(void) {} -/* ---- 验收主项一:20 ms 的节拍 -------------------------------------------- */ +/* 每个用例收尾都查一次「有没有同时开两路通道」。真板上那是两片同地址的 INA226 + 一起应答、读回来是线与 —— 数值正常、还很稳,没有任何一层会报错。 + 所以这个数**必须恒为 0**,放在 tearDown 里等于给每一条用例都加上这条断言。 */ +void tearDown(void) +{ + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); +} -/* 500 圈。每一圈都要**正好**落在 20 ms 的格子上,而且真实唤醒时刻 - (µs 级)对理想时刻的偏差始终 < 1 ms —— 这就是「20 ms ±1 ms」。 - 会漂的实现(干完睡 20)在这里第一圈就出局:那样周期是 21.56 ms, - 500 圈之后差出 780 ms。 */ +/* ---- 一圈的事务账(计划 §5.5) ------------------------------------------- */ + +/* 一圈正好 `t_safety_cycle_txns()` 次事务,而且**每一片器件被读的次数是钉死的**。 + 顺手多读一个寄存器(比如 check 一下 Calibration)会当场变红, + 然后写的人才会想起来去把预算那一行也改了。 */ +static void test_one_cycle_is_exactly_the_budgeted_transaction_count(void) +{ + uint32_t ina_rd[N], pca_rd[N], pca_wr[N]; + for (int rail = 1; rail <= N; rail++) { + ina_rd[rail - 1] = ina_of(rail)->n_read; + pca_rd[rail - 1] = pca_of(rail)->n_read; + pca_wr[rail - 1] = pca_of(rail)->n_write; + } + const uint32_t in_rd = sim_i2c_dev(INA226_ADDR_INPUT)->n_read; + const uint32_t mux_wr = mux_dev()->n_write; + const uint32_t mux_rd = mux_dev()->n_read; + const uint32_t dac_rd = sim_i2c_dev(DAC_ADDR_A)->n_read + sim_i2c_dev(DAC_ADDR_B)->n_read; + + const uint32_t before = hal_i2c_acct_txns(); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), hal_i2c_acct_txns() - before); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); + + /* 逐片对账:五路各 2 次 INA226 寄存器读 + 1 次 PCA9555 输入读; + 主干那片 INA226 2 次;mux 被写 6 次(五路各一次 + 回主干一次)。 + ⚠️ 比的是**这一圈的增量**:`slot_init()` 自己就读了 PCA9555 六次(写一个回读一个), + 拿绝对值比等于把启动的账算进巡检。 */ + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_UINT32(2, ina_of(rail)->n_read - ina_rd[rail - 1]); + TEST_ASSERT_EQUAL_UINT32(1, pca_of(rail)->n_read - pca_rd[rail - 1]); + /* 「读输入」在线上是「写一个寄存器号 + 读两字节」,所以写计数也会 +1。 + 真正要拦的是**写输出锁存器**:平安无事的一圈里,最后落到这一片上的写 + 只能是那一个字节的寄存器指针(0x00),不能是 OUTPUT0(0x02)。 */ + TEST_ASSERT_EQUAL_UINT32(1, pca_of(rail)->n_write - pca_wr[rail - 1]); + TEST_ASSERT_EQUAL_UINT8(PCA9555_REG_INPUT0, pca_of(rail)->last_write[0]); + TEST_ASSERT_EQUAL_UINT8(1, pca_of(rail)->last_write_len); + TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(rail - 1, 0)); /* 键与 VBUS 全是 0 */ + } + TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_dev(INA226_ADDR_INPUT)->n_read - in_rd); + TEST_ASSERT_EQUAL_UINT32((uint32_t)N + 1u, mux_dev()->n_write - mux_wr); + TEST_ASSERT_EQUAL_UINT32(0, mux_dev()->n_read - mux_rd); /* 选完不回读,那是 probe 的事 */ + + /* 设定值从影子里取(`rail_setpoint_mv()`),判据一次也不该去读 DAC。 + 读一次就是每圈多五次事务,把 §10.3 的预算直接顶穿。 */ + TEST_ASSERT_EQUAL_UINT32( + 0, sim_i2c_dev(DAC_ADDR_A)->n_read + sim_i2c_dev(DAC_ADDR_B)->n_read - dac_rd); +} + +/* 静态预算与实测必须**一模一样**。不一样只有两种可能:巡检里多读/少读了东西, + 或者有别的任务在同一圈里插了事务进来 —— 两种都该被这条拦住。 + (这也是为什么预算函数不是一个写死的常数:它按同一个算式从事务形状来。) */ +static void test_measured_bus_time_equals_the_static_budget(void) +{ + for (int i = 0; i < 10; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); + } +} + +/* 计划 §5.5 那张表的可执行版本:23 次事务 ≈ 2.5 ms / 12.5 %。 + 这里按线上真实发生的位数算,得 6×20 + 17×48 = 936 位 = 2340 µs = 11.7 %。 + §5.5 手算的 2.48 ms 用的是每次事务的近似值,差的那 140 µs 是近似的账。 + ⚠️ 这一条同时钉住「R1 的 13 次 / 1.56 ms 已经不是这块板子的数了」—— + 把预算函数改回 R1 那版,这里第一行就红。 */ +static void test_the_bus_account_matches_plan_5_5(void) +{ + TEST_ASSERT_EQUAL_UINT32(23, t_safety_cycle_txns()); + TEST_ASSERT_EQUAL_UINT32(20, hal_i2c_txn_bits(1, 0)); /* mux 选通:裸写一个字节 */ + TEST_ASSERT_EQUAL_UINT32(48, hal_i2c_txn_bits(1, 2)); /* 寄存器读 */ + TEST_ASSERT_EQUAL_UINT32(2340, t_safety_budget_us()); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + + const uint32_t period = task_desc(TASK_SAFETY)->period_ms; + const uint32_t pm = t_safety_load_permille(g_snap.bus_us, period); + TEST_ASSERT_EQUAL_UINT32(117, pm); + /* 计划 §5.5 的「≈12.4 %」:11 %~13 % 都算对得上。超出就是巡检里多读/少读了东西。 */ + TEST_ASSERT_TRUE(pm >= 110 && pm <= 130); + /* 一圈占的总线时间必须远小于周期,否则巡检自己就把节拍吃掉了。 */ + TEST_ASSERT_TRUE(g_snap.bus_us < period * 1000u / 4u); + + char line[160]; + const int n = t_safety_format(&g_snap, line, sizeof(line)); + TEST_ASSERT_TRUE(n > 0 && n < (int)sizeof(line)); + TEST_ASSERT_NOT_NULL(strstr(line, "txns=23")); + TEST_ASSERT_NOT_NULL(strstr(line, "bus=2340 us/20 ms")); + TEST_ASSERT_NOT_NULL(strstr(line, "load=11.7%")); + printf(" %s(计划 §5.5:≈2.5 ms / 12.4 %%)\n", line); +} + +/* ---- ⭐ R2 最危险的错误形态:读到了另一台手机 ---------------------------- */ + +/* 五个通道上的假 INA226 预置成**互不相同**的电压与电流,跑一圈,逐路对上。 + 通道选错的后果是「读到另一台手机的数据」—— 量纲、范围、变化趋势全都正常, + 遥测曲线好看得很,五台机器的过压判据却全部盯错了对象。 + R1 里这条错误不存在(六片六个地址,错了就 NAK);R2 里它不报错、不 NAK、 + 不计数,**只有值本身能拆穿它**。 */ +static void test_each_rail_is_read_on_its_own_mux_channel(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]); + TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv); + TEST_ASSERT_EQUAL_INT32(RAIL_UA(rail), g_snap.rail[rail - 1].current_ua); + } + /* 主干那片(0x43)不在任何通道后面,它的值不许混进五路里。 */ + TEST_ASSERT_TRUE(g_snap.input_ok); + TEST_ASSERT_EQUAL_INT32(12000, g_snap.input.bus_mv); + + /* 换一组值再跑一圈:万一上面那组恰好被「按顺序发牌」蒙对,换一组就露馅。 */ + for (int rail = 1; rail <= N; rail++) { + put_vi(rail, 4000 - 100 * (rail - 1), -(rail * 100000)); /* 顺序反过来、电流转负 */ + } + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT32(4000 - 100 * (rail - 1), g_snap.rail[rail - 1].bus_mv); + TEST_ASSERT_EQUAL_INT32(-(rail * 100000), g_snap.rail[rail - 1].current_ua); + } +} + +/* 同一件事,换 PCA9555 那一路:五片的四个故障信号各响一个,逐片对上。 + INA226 那条对上了、这条没对上,说明「选通道」和「读哪一片」在两条路径上 + 各自算了一遍 —— 而 `slot.c` 存在的全部理由就是让这两件事只算一次。 */ +static void test_each_blade_reports_its_own_pca9555_inputs(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]); + TEST_ASSERT_EQUAL_UINT8(k_port1[rail - 1], g_snap.io[rail - 1].port1); + } + /* 换成语义判读再验一遍(极性写反在原始字节上是看不出来的)。 */ + TEST_ASSERT_TRUE(pca9555_efuse_fault(&g_snap.io[0])); /* CH1 FLT# 低 = 有故障 */ + TEST_ASSERT_FALSE(pca9555_efuse_fault(&g_snap.io[1])); + TEST_ASSERT_FALSE(pca9555_buck_pg(&g_snap.io[1])); /* CH2 Buck PG 低 = 不良 */ + TEST_ASSERT_TRUE(pca9555_buck_pg(&g_snap.io[0])); + TEST_ASSERT_TRUE(pca9555_ina_alert(&g_snap.io[2])); /* CH3 ALERT 低 = 在告警 */ + TEST_ASSERT_FALSE(pca9555_ina_alert(&g_snap.io[3])); + TEST_ASSERT_FALSE(pca9555_efuse_pg(&g_snap.io[3])); /* CH4 eFuse PG 低 */ + TEST_ASSERT_TRUE(pca9555_efuse_pg(&g_snap.io[4])); /* CH5 全好 */ +} + +/* 任何时刻都只许开一路通道,而且一圈走完停在**主干**上。 + 同时开两路在真板上是两片一起应答、读回线与(数值正常、还很稳)—— + `sim_i2c_ambiguous()` 是这件事在宿主机上唯一的痕迹。 */ +static void test_the_mux_never_has_two_channels_open(void) +{ + for (int i = 0; i < 50; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); + /* 一圈的最后一次选通是回主干(读输入侧那片),所以收尾时通道是全关的。 */ + TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch()); + TEST_ASSERT_EQUAL_UINT8(0, sim_i2c_mux_mask()); + } + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fails()); + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_mux_resets() - g_resets0); /* 一切正常时不该再复位 */ +} + +/* 通道 5 上是 CH224A(PD 受电芯片),通道 6 / 7 悬空。巡检一次也不许选到它们。 + 选到通道 5 再去读 0x40 会一个字节也读不到,而现象是「这一路电压电流突然全是 0」 + —— 看上去像刀片拔了。这里在那三路上各挂一片 0x40,它们必须一次也没被碰过。 */ +static void test_the_unused_channels_are_never_selected(void) +{ + sim_i2c_dev_t *pd = sim_i2c_add_ch(TCA9548A_CH_PD, INA226_ADDR_RAIL, 2); + sim_i2c_dev_t *sp6 = sim_i2c_add_ch(6, INA226_ADDR_RAIL, 2); + sim_i2c_dev_t *sp7 = sim_i2c_add_ch(7, INA226_ADDR_RAIL, 2); + + for (int i = 0; i < 5; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + } + TEST_ASSERT_EQUAL_UINT32(0, pd->n_read); + TEST_ASSERT_EQUAL_UINT32(0, sp6->n_read); + TEST_ASSERT_EQUAL_UINT32(0, sp7->n_read); + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); /* 它们也没和刀片撞上过 */ +} + +/* ---- 节拍 ---------------------------------------------------------------- */ + +/* 500 圈。每一圈都要**正好**落在 20 ms 的格子上,而且真实唤醒时刻(µs 级) + 对理想时刻的偏差始终 < 1 ms —— 这就是 §10.3 的「20 ms ±1 ms」。 + 会漂的实现(干完睡 20)在这里第一圈就出局:R2 一圈自己要占 2.34 ms, + 那样周期是 22.34 ms,500 圈之后差出 1.17 s。 */ static void test_safety_holds_a_20ms_beat_for_500_cycles(void) { - safety_snap_t snap; - t_safety_init(&snap); - sched_periodic_t tick; sched_periodic_init(&tick, task_desc(TASK_SAFETY)->period_ms); /* 先确认「干活」在 sim 里真的花掉了时间。不然下面 500 圈测的只是 「delay 参数传对了没」—— 巡检耗时为零的话,什么实现都能踩准 20 ms。 */ const uint64_t t0 = hal_time_now_us(); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); TEST_ASSERT_EQUAL_UINT64(t_safety_budget_us(), hal_time_now_us() - t0); sched_periodic_wait(&tick); - uint32_t prev_ms = hal_time_now_ms(); + /* ⚠️ 相位起点是**第一次醒来那一刻**,不是 0:R2 的启动自己就要跑 mux 认件 + + 五片 PCA9555 初始化,上百次事务在虚拟时钟上是真花掉了的。拿绝对 0 当起点, + 测出来的是「启动花了多久」,和节拍没关系。 */ + const uint64_t base_us = hal_time_now_us(); + uint32_t prev_ms = hal_time_now_ms(); for (int i = 1; i < 500; i++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); sched_periodic_wait(&tick); const uint32_t now_ms = hal_time_now_ms(); TEST_ASSERT_EQUAL_UINT32(20, now_ms - prev_ms); prev_ms = now_ms; - /* 理想时刻是 (i+1)×20 ms;实际唤醒的 µs 与它相差不到一个毫秒格。 */ - const int64_t ideal_us = (int64_t)(i + 1) * 20000; + const int64_t ideal_us = (int64_t)base_us + (int64_t)i * 20000; const int64_t err_us = (int64_t)hal_time_now_us() - ideal_us; TEST_ASSERT_TRUE(err_us > -1000 && err_us < 1000); } TEST_ASSERT_EQUAL_UINT32(0, tick.overruns); - TEST_ASSERT_EQUAL_UINT32(500, snap.cycles); - TEST_ASSERT_EQUAL_UINT32(0, snap.skipped); + TEST_ASSERT_EQUAL_UINT32(500, g_snap.cycles); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.skipped); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.events); } -/* ---- 验收主项二:I²C 占用 ------------------------------------------------ */ - -/* §10.3 手算:一次寄存器读 ≈46 个位周期,12 次 INA + 1 次 PCA ≈1.5 ms / 20 ms ≈8 %。 - 这里按线上真实发生的字节数算,得 48 位/次 × 13 = 624 位 = 1560 µs = 7.8 %。 - 差的两位是 STOP 和一个重复 START,§10.3 没数进去(hal_i2c_acct.h 里记了这件事)。 */ -static void test_i2c_load_per_cycle_matches_section_10_3(void) +/* 20 ms 的周期必须**长于** INA226 的一帧转换时间(AVG=4 × 2×1.1 ms = 8.8 ms)。 + 短了的话同一帧会被读两次,而现象是「电流特别稳」—— 不是任何一种错误, + 不会 NAK、不会报警、遥测曲线还更好看。R2 一圈占 2.34 ms,离 8.8 ms 还很远, + 所以每一圈拿到的都是新的一帧。 */ +static void test_the_period_outlives_one_ina226_conversion(void) { - safety_snap_t snap; - t_safety_init(&snap); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); - - TEST_ASSERT_EQUAL_UINT32(48, hal_i2c_txn_bits(1, 2)); - TEST_ASSERT_EQUAL_UINT32(1560, snap.bus_us); - - const uint32_t pm = t_safety_load_permille(snap.bus_us, - task_desc(TASK_SAFETY)->period_ms); - TEST_ASSERT_EQUAL_UINT32(78, pm); - /* §10.3 的「约 8 %」:7.0 %~8.5 % 都算对得上,超出就是巡检里多读/少读了东西。 */ - TEST_ASSERT_TRUE(pm >= 70 && pm <= 85); - - char line[128]; - const int n = t_safety_format(&snap, line, sizeof(line)); - TEST_ASSERT_TRUE(n > 0 && n < (int)sizeof(line)); - TEST_ASSERT_NOT_NULL(strstr(line, "bus=1560 us/20 ms")); - TEST_ASSERT_NOT_NULL(strstr(line, "load=7.8%")); - printf(" %s(§10.3 手算:约 1.5 ms / 约 8 %%)\n", line); + const uint32_t period_us = task_desc(TASK_SAFETY)->period_ms * 1000u; + TEST_ASSERT_EQUAL_UINT32(8800, (uint32_t)INA226_CONV_US); + TEST_ASSERT_TRUE(period_us > (uint32_t)INA226_CONV_US); + /* 两倍余量:巡检自己占的总线时间加进去也不能逼近一帧。 */ + TEST_ASSERT_TRUE(period_us >= 2u * (uint32_t)INA226_CONV_US); + TEST_ASSERT_TRUE(t_safety_budget_us() < (uint32_t)INA226_CONV_US); + TEST_ASSERT_TRUE(task_table_ok()); /* 任务表自检里钉着同一条 */ } -/* 静态预算与实测必须一模一样。不一样只有两种可能:巡检里多读/少读了东西, - 或者别的任务在同一圈里插了事务进来 —— 两种都该被这条拦住。 - (这也是为什么预算函数不是一个写死的常数:它按同一个算式从事务形状来。) */ -static void test_measured_bus_time_equals_the_static_budget(void) -{ - safety_snap_t snap; - t_safety_init(&snap); - for (int i = 0; i < 10; i++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); - TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), snap.bus_us); - } -} +/* ---- 加锁纪律 ------------------------------------------------------------ */ -/* 一圈正好 13 次事务,而且每片器件被读的次数是钉死的 —— - 多读一次寄存器(比如顺手 check 一下 Calibration)会当场变红, - 然后写的人才会想起来去把预算那一行也改了。 */ -static void test_one_cycle_is_exactly_thirteen_transactions(void) -{ - safety_snap_t snap; - t_safety_init(&snap); - const uint32_t before = hal_i2c_acct_txns(); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); - TEST_ASSERT_EQUAL_UINT32(TXNS_PER_CYCLE, hal_i2c_acct_txns() - before); - - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_dev(rail_cfg(rail)->ina_addr)->n_read); - } - TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_dev(INA226_ADDR_INPUT)->n_read); - TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_dev(PCA9555_ADDR)->n_read); -} - -/* 五路读的是**表里的**地址。挂一片在「0x40+ch 算出来」的地址上, - 它一次也不该被碰到 —— 这是 A7 那条禁令在巡检路径上的回归拦网。 */ -static void test_rails_are_read_by_table_not_by_formula(void) -{ - sim_i2c_dev_t *wrong = sim_i2c_add(0x46, 2); /* 谁都不是的地址 */ - safety_snap_t snap; - t_safety_init(&snap); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); - TEST_ASSERT_EQUAL_UINT32(0, wrong->n_read); - TEST_ASSERT_EQUAL_UINT32(0, wrong->n_write); - - /* 顺带确认值确实按路对上了(每路电流不同)。 */ - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_TRUE(snap.rail_ok[rail - 1]); - TEST_ASSERT_EQUAL_INT32(rail * 1000 * INA226_CURRENT_LSB_UA, - snap.rail[rail - 1].current_ua); - } -} - -/* ---- 加锁纪律 ------------------------------------------------------------- */ - -/* 一整圈里的每一次事务都持着 I²C 锁;出了这个函数锁必须已经放掉。 */ +/* 一整圈里的每一次事务都持着 I²C 锁;出了这个函数锁必须已经放掉。 + R2 里这一圈要拿六次锁(五路 + 主干),每一次都得规规矩矩。 */ static void test_every_transaction_happens_under_the_i2c_lock(void) { sim_lock_require(HAL_LOCK_I2C, true); - safety_snap_t snap; - t_safety_init(&snap); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + const uint32_t takes0 = sim_lock_takes(HAL_LOCK_I2C); + for (int i = 0; i < 5; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + } TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); /* 出门放锁 */ TEST_ASSERT_EQUAL_INT(-1, sim_lock_owner(HAL_LOCK_I2C)); + /* 一圈六次 `xact` = 六次拿锁(R1 是一把大锁包住整圈,只拿一次)。 + 这个数正是「给 t_actuator 留的缝」有几条 —— 变少了缝就没了。 */ + TEST_ASSERT_EQUAL_UINT32(5u * ((uint32_t)N + 1u), sim_lock_takes(HAL_LOCK_I2C) - takes0); } -/* 上一条不是句空话:不拿锁直接读一片,违规计数必须 +1。 - (检查本身失灵的话,上一条会永远绿。) */ +/* 上一条不是句空话:不拿锁直接读一片 INA226,违规计数必须 +1。 + 检查本身失灵的话,上一条会永远绿。 */ static void test_the_lock_check_itself_can_fail(void) { sim_lock_require(HAL_LOCK_I2C, true); @@ -192,129 +421,203 @@ static void test_the_lock_check_itself_can_fail(void) TEST_ASSERT_EQUAL_UINT32(2, sim_lock_violations(HAL_LOCK_I2C)); /* 两次事务 */ } -/* 拿不到锁:**跳过这一圈**,不死等(t_safety.h 规矩 1)。 - 等掉的是 5 ms,还在 20 ms 之内,所以节拍不丢。 */ +/* 拿不到锁:**跳过,不死等**(t_safety.h 规矩 1)。死等的现象是巡检整个停摆, + 而它是优先级最高的那个任务 —— 停摆了没有任何人会发现。 + + ⚠️ R2 的一笔新账:一圈要等**六次**锁,最坏 6 × 5 = 30 ms,**比 20 ms 的周期还长** + (R1 只等一次,5 ms)。这条断言把这个数钉在这里:谁改了超时常量或者 `xact` + 的次数,都得先来正面回答「一圈最坏等多久」这个问题。 */ static void test_a_busy_bus_skips_the_cycle_instead_of_waiting_forever(void) { - sim_lock_set_task(1); /* 假装 t_link 正在用总线 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); /* 先跑一圈好的,留下基准 */ + const int32_t mv0 = g_snap.rail[0].bus_mv; + const uint32_t txns0 = hal_i2c_acct_txns(); + const uint32_t en_writes0 = sim_gpio_writes(rail_cfg(1)->efuse_en); + + sim_lock_set_task(1); /* 假装 t_actuator 正在按键,总线在它手里 */ TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); sim_lock_set_task(0); - safety_snap_t snap; - t_safety_init(&snap); const uint32_t t0 = hal_time_now_ms(); - const uint32_t txns = hal_i2c_acct_txns(); + TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&g_snap)); - TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&snap)); - TEST_ASSERT_EQUAL_UINT32(1, snap.skipped); - TEST_ASSERT_EQUAL_UINT32(1, snap.cycles); - TEST_ASSERT_EQUAL_UINT32(0, hal_i2c_acct_txns() - txns); /* 一次事务也没发 */ - TEST_ASSERT_EQUAL_UINT32(T_SAFETY_LOCK_TIMEOUT_MS, hal_time_elapsed_ms(t0)); - TEST_ASSERT_TRUE(T_SAFETY_LOCK_TIMEOUT_MS < task_desc(TASK_SAFETY)->period_ms); + /* 这一圈**什么也没做**:一次事务没发、一根引脚没动、快照原封不动。 */ + TEST_ASSERT_EQUAL_UINT32(0, hal_i2c_acct_txns() - txns0); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.txns); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.bus_us); + TEST_ASSERT_EQUAL_INT32(mv0, g_snap.rail[0].bus_mv); + TEST_ASSERT_EQUAL_UINT32(en_writes0, sim_gpio_writes(rail_cfg(1)->efuse_en)); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.events); + + /* 但这一圈**被记下来了**:不计数的跳过等于悄悄失明。 */ + TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped); + TEST_ASSERT_EQUAL_UINT32(2, g_snap.cycles); + /* ⭐ **整圈的账,不是单次的账。** R2 一圈要拿六次锁,六次各等满 5 ms 就是 + 30 ms —— 比 20 ms 的周期还长,现象是巡检成片丢拍而每一次 `xact` 都老老实实 + 按时返回了。所以 `T_SAFETY_LOCK_BUDGET_MS` 是一条整圈的闸: + 花到 10 ms 就不再试剩下的路。 + + 这里断言的正是那条闸:**只试了 2 次**(5 + 5 = 10 ms 用完预算), + 而不是六次全试一遍。把预算那一段删掉的话,下面两条会变成 6 和 30。 */ + TEST_ASSERT_EQUAL_UINT32(T_SAFETY_LOCK_BUDGET_MS / T_SAFETY_LOCK_TIMEOUT_MS, + i2cbus_lock_timeouts()); + TEST_ASSERT_EQUAL_UINT32(T_SAFETY_LOCK_BUDGET_MS, hal_time_elapsed_ms(t0)); + /* 而且整圈花掉的时间**必须短于巡检周期**,否则节拍本身就保不住了。 */ + TEST_ASSERT_TRUE(hal_time_elapsed_ms(t0) < task_desc(TASK_SAFETY)->period_ms); + + /* 总线还回来,下一圈照常。 */ + sim_lock_set_task(1); + hal_lock_give(HAL_LOCK_I2C); + sim_lock_set_task(0); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped); } /* ---- 器件出问题的时候(§10.2:失联 ≠ 断电) ------------------------------ */ -/* 六片里坏一片,剩下五片照读、PCA 照读。中断这一圈的话, - 现象是「一片 INA226 掉线,五路判据全瞎」。 */ -static void test_one_dead_device_does_not_stop_the_round(void) +/* 一片刀片的 INA226 掉线,**剩下四路的读照常**。中断这一圈的现象是 + 「一片 INA226 掉线,五路判据全瞎」—— 而那四台手机什么毛病都没有。 */ +static void test_one_dead_ina226_does_not_stop_the_other_four(void) { - sim_i2c_set_present(rail_cfg(3)->ina_addr, false); + sim_i2c_set_present_ch(ch_of(3), rail_cfg(3)->ina_addr, false); - safety_snap_t snap; - t_safety_init(&snap); - TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&snap)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap)); - TEST_ASSERT_FALSE(snap.rail_ok[2]); + TEST_ASSERT_FALSE(g_snap.rail_ok[2]); for (int rail = 1; rail <= N; rail++) { - if (rail != 3) { - TEST_ASSERT_TRUE(snap.rail_ok[rail - 1]); + if (rail == 3) { + continue; } + TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]); + TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv); + TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]); } - TEST_ASSERT_TRUE(snap.input_ok); - TEST_ASSERT_TRUE(snap.io_ok); - TEST_ASSERT_EQUAL_UINT32(1, snap.i2c_errors); + /* 坏掉那一路的 PCA9555 没跟着遭殃(是两颗器件,不是一颗)。 */ + TEST_ASSERT_TRUE(g_snap.io_ok[2]); + TEST_ASSERT_EQUAL_UINT8(k_port1[2], g_snap.io[2].port1); + TEST_ASSERT_TRUE(g_snap.input_ok); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.i2c_errors); + + /* 读 V 那一次就 NAK 了,读 I 那一次不会再发 ⇒ 这一圈**少一次事务**。 + 把这个数写下来,是为了让「失联之后还傻读第二次」也会变红。 */ + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns() - 1u, g_snap.txns); + TEST_ASSERT_EQUAL_UINT32( + t_safety_budget_us() - (uint32_t)hal_i2c_bits_to_us(hal_i2c_txn_bits(1, 2)), + g_snap.bus_us); } /* 读失败时保留**上一圈的值**并把 ok 标成 false —— 不清零。 - 清零的话,A10 的过压判据会看到一个 0 V 的「正常」电压, - 而 ok 标志正是让它先看一眼「这数是不是新的」。 */ + 清零的话,判据会看到一个 0 V 的「正常数」;而 `ok` 正是让它先看一眼 + 「这数是不是新的」。拿一个没更新的电压去判过压是最难查的那种错。 */ static void test_a_failed_read_keeps_the_stale_value_and_flags_it(void) { - safety_snap_t snap; - t_safety_init(&snap); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); - const int32_t good_mv = snap.rail[0].bus_mv; - TEST_ASSERT_EQUAL_INT32(3900, good_mv); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_INT32(RAIL_MV(1), g_snap.rail[0].bus_mv); - sim_i2c_set_present(rail_cfg(1)->ina_addr, false); - TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&snap)); - TEST_ASSERT_FALSE(snap.rail_ok[0]); - TEST_ASSERT_EQUAL_INT32(good_mv, snap.rail[0].bus_mv); /* 还是上一圈那个 */ + sim_i2c_set_present_ch(ch_of(1), rail_cfg(1)->ina_addr, false); + /* 器件掉线之后把寄存器也改掉:万一实现偷偷读到了,值就会跟着变。 */ + put_vi(1, 3000, 0); + + for (int i = 0; i < 5; i++) { + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap)); + TEST_ASSERT_FALSE(g_snap.rail_ok[0]); + TEST_ASSERT_EQUAL_INT32(RAIL_MV(1), g_snap.rail[0].bus_mv); /* 还是上一圈那个 */ + TEST_ASSERT_EQUAL_INT32(RAIL_UA(1), g_snap.rail[0].current_ua); + } + /* 器件回来了就立刻用新值(`ok` 不是个一去不返的标志)。 */ + sim_i2c_set_present_ch(ch_of(1), rail_cfg(1)->ina_addr, true); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_TRUE(g_snap.rail_ok[0]); + TEST_ASSERT_EQUAL_INT32(3000, g_snap.rail[0].bus_mv); } -/* 器件掉线之后节拍照样是 20 ms(NAK 的事务也占总线时间,但占得更少)。 - ⚠️ 这条测的是「巡检不会因为总线出错就乱了节奏」。 */ -static void test_the_beat_survives_a_dead_device(void) +/* 同一路上的两颗器件互不牵连:PCA9555 读不到,不影响这一路 INA226 已经读到的值。 + `cycle_rail()` 里「器件读失败不提前返回」那一条就是这个意思 —— + 提前返回的现象是「一片 I/O 扩展器失联,这一路的过压判据也跟着停了」。 */ +static void test_a_dead_pca9555_does_not_spoil_the_same_rails_ina226(void) { - sim_i2c_set_present(rail_cfg(2)->ina_addr, false); + sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, false); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap)); + + TEST_ASSERT_FALSE(g_snap.io_ok[1]); + TEST_ASSERT_TRUE(g_snap.rail_ok[1]); /* INA226 是好的,值是新的 */ + TEST_ASSERT_EQUAL_INT32(RAIL_MV(2), g_snap.rail[1].bus_mv); + TEST_ASSERT_EQUAL_INT32(RAIL_UA(2), g_snap.rail[1].current_ua); + for (int rail = 1; rail <= N; rail++) { + if (rail != 2) { + TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]); + } + } + TEST_ASSERT_EQUAL_UINT32(1, g_snap.i2c_errors); +} + +/* 器件掉线之后节拍照样是 20 ms(NAK 的事务也占总线时间,只是占得更少)。 + 测的是「巡检不会因为总线出错就乱了节奏」。 */ +static void test_the_beat_survives_a_dead_blade(void) +{ + sim_i2c_set_present_ch(ch_of(5), rail_cfg(5)->ina_addr, false); + sim_i2c_set_present_ch(ch_of(5), PCA9555_ADDR, false); - safety_snap_t snap; - t_safety_init(&snap); sched_periodic_t tick; sched_periodic_init(&tick, 20); uint32_t prev = hal_time_now_ms(); for (int i = 0; i < 50; i++) { - (void)t_safety_step(&snap); + (void)t_safety_step(&g_snap); sched_periodic_wait(&tick); TEST_ASSERT_EQUAL_UINT32(20, hal_time_now_ms() - prev); prev = hal_time_now_ms(); } TEST_ASSERT_EQUAL_UINT32(0, tick.overruns); - TEST_ASSERT_EQUAL_UINT32(50, snap.i2c_errors); + TEST_ASSERT_EQUAL_UINT32(100, g_snap.i2c_errors); /* 每圈两颗器件各一次 */ + /* 别的四路一点没受影响 —— 这才是「失联 ≠ 别的路也不看了」。 */ + for (int rail = 1; rail <= 4; rail++) { + TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]); + TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv); + } } -/* PCA9555 的输入被读进来了,而且语义判读能用(A10 要拿它做判据)。 */ -static void test_pca9555_inputs_land_in_the_snapshot(void) -{ - sim_pca9555_drive(0, 0xFF & ~(1u << pca9555_flt_bit(4))); /* CH4 的 FLT# 拉低 */ - - safety_snap_t snap; - t_safety_init(&snap); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); - - TEST_ASSERT_TRUE(snap.io_ok); - TEST_ASSERT_TRUE(pca9555_rail_fault(&snap.io, 4)); - TEST_ASSERT_FALSE(pca9555_rail_fault(&snap.io, 1)); -} - -/* 第一圈的占用里不该混进启动期间的事务(游标要在 init 时对齐到「现在」)。 */ +/* 第一圈的占用里不该混进启动期间的事务(游标要在 `t_safety_init()` 时对齐到「现在」)。 + 混进去的现象是「开机那一下占用率 40 %」,然后再也不复现。 */ static void test_startup_traffic_is_not_charged_to_the_first_cycle(void) { - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(PCA9555_ADDR)); /* 假装启动扫了一下 */ + /* 假装启动时又扫了一遍总线。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + (void)hal_i2c_probe(TCA9548A_ADDR); + (void)hal_i2c_probe(INA226_ADDR_INPUT); + hal_lock_give(HAL_LOCK_I2C); - safety_snap_t snap; - t_safety_init(&snap); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); - TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), snap.bus_us); + t_safety_init(&g_snap); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); } int main(void) { UNITY_BEGIN(); - RUN_TEST(test_safety_holds_a_20ms_beat_for_500_cycles); - RUN_TEST(test_i2c_load_per_cycle_matches_section_10_3); + RUN_TEST(test_one_cycle_is_exactly_the_budgeted_transaction_count); RUN_TEST(test_measured_bus_time_equals_the_static_budget); - RUN_TEST(test_one_cycle_is_exactly_thirteen_transactions); - RUN_TEST(test_rails_are_read_by_table_not_by_formula); + RUN_TEST(test_the_bus_account_matches_plan_5_5); + + RUN_TEST(test_each_rail_is_read_on_its_own_mux_channel); + RUN_TEST(test_each_blade_reports_its_own_pca9555_inputs); + RUN_TEST(test_the_mux_never_has_two_channels_open); + RUN_TEST(test_the_unused_channels_are_never_selected); + + RUN_TEST(test_safety_holds_a_20ms_beat_for_500_cycles); + RUN_TEST(test_the_period_outlives_one_ina226_conversion); + RUN_TEST(test_every_transaction_happens_under_the_i2c_lock); RUN_TEST(test_the_lock_check_itself_can_fail); RUN_TEST(test_a_busy_bus_skips_the_cycle_instead_of_waiting_forever); - RUN_TEST(test_one_dead_device_does_not_stop_the_round); + + RUN_TEST(test_one_dead_ina226_does_not_stop_the_other_four); RUN_TEST(test_a_failed_read_keeps_the_stale_value_and_flags_it); - RUN_TEST(test_the_beat_survives_a_dead_device); - RUN_TEST(test_pca9555_inputs_land_in_the_snapshot); + RUN_TEST(test_a_dead_pca9555_does_not_spoil_the_same_rails_ina226); + RUN_TEST(test_the_beat_survives_a_dead_blade); RUN_TEST(test_startup_traffic_is_not_charged_to_the_first_cycle); return UNITY_END(); } diff --git a/firmware/test/test_t_safety_rules.c b/firmware/test/test_t_safety_rules.c index 45547f1..274b778 100644 --- a/firmware/test/test_t_safety_rules.c +++ b/firmware/test/test_t_safety_rules.c @@ -1,744 +1,1218 @@ -/* A10:安全巡检的两条判据(SR-5 / SW-8)。计划里这一包的验收原话是 - 「注入过压 → 20 ms 内关路并记事件;注入 −60 mA → 断 VBUS」。 - - ⚠️ 断言一律打在**线上真实发生的事**上:两根 EN 线看 `sim_gpio_level()` 与 - 全局写序号 `sim_gpio_write_seq()`(只看终值的话,「先断 VBUS 再关 eFuse」 - 和反过来长得一模一样),电压电流从**假 INA226 的寄存器**注入 —— - 直接改快照里的 `bus_mv` 等于跳过驱动,那样连「读的是哪一片」都证明不了。 - - 本文件里的 `beat()` 就是真板上两个任务合起来干的事:推一步执行器、跑一圈巡检、 - 睡到下一个 20 ms 格子。判据的「持续 20 ms」只有在真的按 20 ms 打拍子时才有意义。 */ -#include - -#include "hal_gpio.h" -#include "hal_i2c.h" -#include "hal_lock.h" -#include "hal_time.h" -#include "ina226.h" -#include "pca9555.h" -#include "phonerail.h" -#include "rail.h" -#include "rail_cfg.h" -#include "sched.h" -#include "sim_hal.h" -#include "sr595.h" -#include "t_actuator.h" -#include "t_safety.h" -#include "tasks.h" -#include "unity.h" - -#define N PHONERAIL_RAIL_COUNT - -/* 稳态:五路都设在 3900 mV(`RAIL_MV_SAFE`),电流 1 A 正向。 */ -#define NOMINAL_MV 3900 - -static safety_snap_t g_snap; -static sched_periodic_t g_tick; - -/* ---- 事件出口:把 A11 的环形缓冲换成一个能断言的小盒子 ------------------- */ - -static safety_event_t g_ev[8]; -static int g_n_ev; -/* 回调被调的那一刻两把锁都不该被持着(t_safety.c 规矩 1)。 */ -static bool g_ev_held_lock; - -/* 事件被记下来的那一刻,动作**必须已经做完了**(t_safety.c:先保命,再记账)。 - 顺序反过来在功能上看不出任何区别 —— 除非像这里一样,在记录的那一刻回头看线。 */ -static bool g_ev_before_action; - -static void catch_event(void *ctx, const safety_event_t *ev) -{ - (void)ctx; - if (hal_lock_held(HAL_LOCK_I2C) || hal_lock_held(HAL_LOCK_SR595)) { - g_ev_held_lock = true; - } - switch ((safety_ev_t)ev->type) { - case SAFETY_EV_OVER_VOLT: - case SAFETY_EV_UNDER_VOLT: - if (rail_efuse_is_on(ev->rail) || rail_vbus_is_on(ev->rail)) { - g_ev_before_action = true; /* SR-5:记这条时电早该断了 */ - } - break; - case SAFETY_EV_REVERSE: - case SAFETY_EV_PUSHED_UP: - if (rail_vbus_is_on(ev->rail)) { - g_ev_before_action = true; /* SW-8:记这条时 VBUS 早该断了 */ - } - break; - default: - break; - } - if (g_n_ev < (int)(sizeof(g_ev) / sizeof(g_ev[0]))) { - g_ev[g_n_ev] = *ev; - } - g_n_ev++; -} - -/* ---- 假板子 -------------------------------------------------------------- */ - -/* 把某一路的实测值注进假 INA226 的寄存器(mV 必须是 5 的倍数:1.25 mV/LSB)。 */ -static void inject(int rail, int mv, int ua) -{ - TEST_ASSERT_EQUAL_INT(0, mv % 5); - sim_i2c_set_reg(rail_cfg(rail)->ina_addr, INA226_REG_BUS_V, (uint16_t)(mv * 4 / 5)); - sim_i2c_set_reg(rail_cfg(rail)->ina_addr, INA226_REG_CURRENT, - (uint16_t)(int16_t)(ua / INA226_CURRENT_LSB_UA)); -} - -static void board_up(void) -{ - const hal_i2c_cfg_t cfg = { .sda_gpio = 14, .scl_gpio = 15, .freq_hz = 400000 }; - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg)); - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); /* 十根 EN 线,全部拉低 */ - rail_setpoint_reset(); /* 五路设定值回到 NVM 默认 */ - - for (int rail = 1; rail <= N; rail++) { - sim_i2c_add(rail_cfg(rail)->ina_addr, 2); - inject(rail, NOMINAL_MV, 1000000); /* 3.900 V / 1 A,稳稳当当 */ - } - sim_i2c_add(INA226_ADDR_INPUT, 2); - sim_pca9555_attach(PCA9555_ADDR); - - sim_sr595_attach(SR595_PIN_SER, SR595_PIN_SRCLK, SR595_PIN_RCLK, SR595_PIN_OE_N); - TEST_ASSERT_EQUAL_INT(HAL_OK, sr595_init()); - t_actuator_init(NULL, NULL); /* PG「永远读不到」—— 硬重启会走完并记 NO_PG */ - - g_n_ev = 0; - g_ev_held_lock = false; - g_ev_before_action = false; - memset(g_ev, 0, sizeof(g_ev)); - t_safety_set_event_sink(catch_event, NULL); - - t_safety_init(&g_snap); - sched_periodic_init(&g_tick, task_desc(TASK_SAFETY)->period_ms); -} - -void setUp(void) -{ - sim_hal_reset(); - board_up(); -} -void tearDown(void) {} - -/* 一拍 = 真板上两个任务在这 20 ms 里干的事。执行器要先推,因为判据打断的正是它。 */ -static void beat(void) -{ - (void)t_actuator_step(); - (void)t_safety_step(&g_snap); - sched_periodic_wait(&g_tick); -} - -static void beats(int n) -{ - for (int i = 0; i < n; i++) { - beat(); - } -} - -/* 开一路电并熬过缓启动窗口。判据在这个窗口里是**不武装**的(斜坡还没走完, - 这时候比电压等于每次上电都自己把自己关掉)。 */ -static void power_up(int rail) -{ - const int before = g_n_ev; - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, true)); - beats(1 + (T_SAFETY_SETTLE_MS / (int)task_desc(TASK_SAFETY)->period_ms) + 1); - TEST_ASSERT_TRUE(rail_efuse_is_on(rail)); - TEST_ASSERT_EQUAL_INT(before, g_n_ev); /* 上电加稳态几拍,不该冒出新事件 */ -} - -static int en_level(int rail) -{ - return sim_gpio_level(rail_cfg(rail)->efuse_en); -} -static int vbus_level(int rail) -{ - return sim_gpio_level(rail_cfg(rail)->vbus_en); -} - -/* ---- 常量与文档对账(改了任何一个就是改了需求) -------------------------- */ - -static void test_thresholds_match_the_documents(void) -{ - /* SR-5 / SW-2 的两个数,逐字来自需求 §9 那两行。 */ - TEST_ASSERT_EQUAL_INT(100, T_SAFETY_DEV_MV); - TEST_ASSERT_EQUAL_INT(20, T_SAFETY_DEV_HOLD_MS); - - /* 判据数的是**圈**,SR-5 写的是**毫秒** —— 这一条把两者钉在一起。 - 巡检周期哪天改了,它会当场对不上,而不是悄悄把「持续 20 ms」变成 40 ms。 */ - TEST_ASSERT_EQUAL_INT(2, T_SAFETY_DEV_HOLD_CYCLES); - TEST_ASSERT_EQUAL_UINT32((T_SAFETY_DEV_HOLD_CYCLES - 1) * task_desc(TASK_SAFETY)->period_ms, - (uint32_t)T_SAFETY_DEV_HOLD_MS); - - /* SW-8 的两个数:设计文档 §15.2 对策 2 的 −50 mA,需求 SW-8 的 200 ms 预算。 */ - TEST_ASSERT_EQUAL_INT32(-50000, T_SAFETY_REV_UA); - TEST_ASSERT_EQUAL_INT(200, T_SAFETY_REV_BUDGET_MS); - /* 反灌不设确认窗口 ⇒ 最坏 2 个周期,必须离 200 ms 的预算远得看得出来。 */ - TEST_ASSERT_TRUE(2 * (int)task_desc(TASK_SAFETY)->period_ms < T_SAFETY_REV_BUDGET_MS / 2); - - /* 缓启动窗口必须盖得住 tON + 斜坡那 17 ms(§7.4),而且要是周期的整数倍 - 以上 —— 比一个周期还短的窗口等于没有。 */ - TEST_ASSERT_TRUE(T_SAFETY_SETTLE_MS > (int)ACT_HR_RAMP_MS); - TEST_ASSERT_TRUE(T_SAFETY_SETTLE_MS >= 2 * (int)task_desc(TASK_SAFETY)->period_ms); - - /* §12.1 注入的是 ±150 mV / −60 mA,两个都必须落在阈值**外侧**, - 否则验收测的是「阈值刚好卡在注入值上」。 */ - TEST_ASSERT_TRUE(150 > T_SAFETY_DEV_MV); - TEST_ASSERT_TRUE(-60000 < T_SAFETY_REV_UA); -} - -/* ---- 验收主项一:注入过压 → 20 ms 内关路并记事件 ------------------------- */ - -static void test_overvoltage_cuts_the_rail_within_one_period_and_logs_it(void) -{ - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); /* §12.1 的 +150 mV */ - - beat(); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 第一次看见:只起表,不动电 */ - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); /* 满 20 ms:这一拍断电 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type); - TEST_ASSERT_EQUAL_UINT8(1, g_ev[0].rail); - TEST_ASSERT_EQUAL_INT32(NOMINAL_MV + 150, g_ev[0].bus_mv); /* 记的是实测值 */ - TEST_ASSERT_EQUAL_INT32(1000000, g_ev[0].current_ua); - TEST_ASSERT_FALSE(g_ev_held_lock); - TEST_ASSERT_FALSE(g_ev_before_action); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.events); -} - -/* 从**第一次看见**到断电正好一个周期。 - ⚠️ 这一条把假总线的耗时关掉(sim_hal.h 允许的那个正当理由:拿虚拟时钟 - 当纯计数器)—— 开着的话每一拍还要加上巡检自己占的 1.56 ms 总线时间, - 量出来是 19/21 ms 交替,那 1 ms 是巡检花的,不是判据晚了。 */ -static void test_the_cut_lands_exactly_one_hold_window_after_first_sight(void) -{ - sim_i2c_set_bus_time(false); - power_up(2); - inject(2, NOMINAL_MV - 150, 1000000); - - const uint32_t t_first_sight = hal_time_now_ms(); - beat(); - beat(); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(T_SAFETY_DEV_HOLD_MS, g_ev[0].t_ms - t_first_sight); -} - -/* ⭐ 同一件事,把总线耗时打开(也就是真板上的样子):判据必须**照样在第二圈** - 断电,量出来的墙钟是 19 或 21 ms。 - - 这一条是本包最值钱的回归拦网:判据一旦改回按墙钟算 `>= 20 ms`,19 ms 那半数 - 情况就要等到第三圈 —— 40 ms,超了 §12.1 的验收,而且是**间歇性**的 - (同一个故障注两次,一次过一次不过)。它跑 20 次,两种相位都会踩到。 */ -static void test_the_cut_is_never_late_because_of_sub_millisecond_jitter(void) -{ - power_up(2); - for (int i = 0; i < 20; i++) { - /* 每一轮都从「刚开电」重来,让第一次看见落在不同的亚毫秒相位上。 */ - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(2, true)); - inject(2, NOMINAL_MV, 1000000); - beats(6); /* 熬过缓启动窗口 */ - g_n_ev = 0; - - inject(2, NOMINAL_MV + 150, 1000000); - const uint32_t t0 = hal_time_now_ms(); - beat(); - TEST_ASSERT_EQUAL_INT(1, en_level(2)); /* 第一圈只起表 */ - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(2)); /* 第二圈必须断,一次都不许拖 */ - - /* 墙钟量出来是 19~22 ms:一圈自己占 1.56 ms 总线,加上毫秒截断。 - 那几毫秒是巡检花的,不是判据晚的 —— 判据数的是圈,上面两条断言才是主判据。 */ - const uint32_t late = g_ev[0].t_ms - t0; - TEST_ASSERT_TRUE(late >= T_SAFETY_DEV_HOLD_MS - 2 && late <= T_SAFETY_DEV_HOLD_MS + 2); - } -} - -/* 偏低同样关路(SR-5 是 ±,不是「过压」)。掉压的现因多半是排线接触不良, - 而那正是这块板子最常见的故障(§10.7 开头那句)。 */ -static void test_undervoltage_cuts_the_rail_too(void) -{ - power_up(3); - inject(3, NOMINAL_MV - 150, 1000000); - beats(2); - - TEST_ASSERT_EQUAL_INT(0, en_level(3)); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); -} - -/* 「持续」的意思是连续。只闪了一圈就回到范围内的偏差不许关路 —— - 不然一次负载阶跃(0→4 A)就能把五台手机全关掉。 */ -static void test_a_one_cycle_glitch_does_not_cut_anything(void) -{ - power_up(1); - for (int i = 0; i < 20; i++) { - inject(1, NOMINAL_MV + 150, 1000000); - beat(); - inject(1, NOMINAL_MV, 1000000); - beat(); - } - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); -} - -/* 阈值是「**超过** 100 mV」:正好 100 mV 不算。 - 写成 >= 的话,SW-4 允许运维把某一路微调 100 mV 之后那一路就再也开不起来。 */ -static void test_exactly_one_hundred_millivolts_is_still_in_range(void) -{ - power_up(1); - inject(1, NOMINAL_MV + T_SAFETY_DEV_MV, 1000000); - beats(10); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - - inject(1, NOMINAL_MV - T_SAFETY_DEV_MV, 1000000); - beats(10); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); -} - -/* 比的是**该路 DAC 的设定值**,不是 3900 这个常数。SW-4 允许单路微调, - 拿常数比的话,一台被调到 4.0 V 的手机会被当成过压关掉 —— 而且只有那一路。 */ -static void test_the_reference_is_the_setpoint_not_a_constant(void) -{ - /* 假两片 DAC,把 CH1 调到 4010 mV(SR-13 的上限)。 */ - sim_i2c_add(0x4A, 2); - sim_i2c_add(0x4B, 2); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_set_mv(1, RAIL_MV_MAX)); - TEST_ASSERT_EQUAL_INT(RAIL_MV_MAX, rail_setpoint_mv(1)); - - inject(1, RAIL_MV_MAX, 1000000); /* 实测跟着设定走 */ - power_up(1); - beats(10); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - - /* 回到 3900:现在这才是「偏低 110 mV」。 */ - inject(1, NOMINAL_MV, 1000000); - beats(2); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); -} - -/* ---- 判据什么时候**不**武装(三道闸,每一道都是一次真事故的预防) -------- */ - -/* 关着的路不判据。不设这一条的现象是:巡检每 20 ms 就「发现」五路各偏低 - 3900 mV,然后没完没了地去关已经关着的路、刷满事件日志。 */ -static void test_a_rail_that_is_off_is_never_judged(void) -{ - inject(1, 0, 0); /* 关着的路就是 0 V —— 离设定值差着 3900 mV */ - beats(50); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(0, sim_gpio_writes(rail_cfg(1)->efuse_en) - 1u); /* 只有 init 那一次 */ -} - -/* 刚开电的那几十毫秒不判据:tON 5.1 ms + 斜坡 11.5 ms 期间电压本来就在爬。 - 没有这个窗口的现象是**每次上电都自己把自己关掉**,而且五路一起。 */ -static void test_the_soft_start_window_is_not_judged(void) -{ - inject(1, 1200, 100000); /* 斜坡爬到一半 */ - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(1, true)); - - const int in_window = T_SAFETY_SETTLE_MS / (int)task_desc(TASK_SAFETY)->period_ms; - beats(in_window); /* 窗口之内:不管电压多离谱都不判 */ - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - - /* 窗口一过,判据武装,还是这么低就该关了。 */ - beats(3); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); -} - -/* 器件失联的那一路不判据(§10.10:「保持供电,标记为盲跑,**禁用该路的 - SR-5 / SW-8 判据**」)。快照里留着的是上一圈的值,拿它去关电是最难查的那种错。 - 两条判据共用同一道闸,所以这里拿 SR-5 验它就够。 */ -static void test_a_blind_rail_keeps_its_power(void) -{ - power_up(4); - - /* ⚠️ 顺序是有讲究的:**先让坏值真的读进快照一次**,再让器件失联。 - 反过来写(先失联再改寄存器)的话快照里躺着的是个正常值, - 「盲跑那一路照样判据」这个错就一点痕迹都不会留下 —— 测试永远绿。 */ - inject(4, NOMINAL_MV - 150, 1000000); - beat(); - TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* 第一个样本只起表 */ - sim_i2c_set_present(rail_cfg(4)->ina_addr, false); - - beats(50); /* 1 秒,远超两条判据各自的窗口 */ - TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* §10.10:盲跑 = 保持供电 */ - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - TEST_ASSERT_FALSE(g_snap.rail_ok[3]); - /* 快照里躺着的确实是那个越界的旧值 —— 这一条是上面那句「顺序有讲究」的凭据。 */ - TEST_ASSERT_EQUAL_INT32(NOMINAL_MV - 150, g_snap.rail[3].bus_mv); - - /* 器件回来了,值还是坏的 —— 这时才该关,而且要重新数满 20 ms。 */ - sim_i2c_set_present(rail_cfg(4)->ina_addr, true); - beat(); - TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* 第一个新鲜样本只起表 */ - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(4)); -} - -/* ---- 关路这件事本身 ------------------------------------------------------ */ - -/* 只关出事的那一路,另外四路一根线都不许动。 */ -static void test_only_the_offending_rail_is_touched(void) -{ - for (int rail = 1; rail <= N; rail++) { - power_up(rail); - } - const uint32_t before[N] = { - sim_gpio_writes(rail_cfg(1)->efuse_en), sim_gpio_writes(rail_cfg(2)->efuse_en), - sim_gpio_writes(rail_cfg(3)->efuse_en), sim_gpio_writes(rail_cfg(4)->efuse_en), - sim_gpio_writes(rail_cfg(5)->efuse_en), - }; - - inject(3, NOMINAL_MV + 150, 1000000); - beats(2); - - TEST_ASSERT_EQUAL_INT(0, en_level(3)); - for (int rail = 1; rail <= N; rail++) { - if (rail == 3) { - continue; - } - TEST_ASSERT_EQUAL_INT(1, en_level(rail)); - TEST_ASSERT_EQUAL_UINT32(before[rail - 1], sim_gpio_writes(rail_cfg(rail)->efuse_en)); - TEST_ASSERT_EQUAL_UINT32(0, sim_gpio_writes(rail_cfg(rail)->vbus_en) - 1u); - } - TEST_ASSERT_EQUAL_INT(1, g_n_ev); -} - -/* 关路 = **先断 VBUS 再关 eFuse**(`rail_all_off()` 的同一条理由:中间不留 - 「手机只靠 USB 供着电」那个既不是开也不是关的窗口)。 - 只看两个脚的终值是看不出顺序的,所以看全局写序号。 */ -static void test_the_cut_drops_vbus_before_the_efuse(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true)); - inject(1, NOMINAL_MV - 150, 1000000); /* 欠压,不是反灌 */ - beats(2); - - TEST_ASSERT_EQUAL_INT(0, vbus_level(1)); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_TRUE(sim_gpio_write_seq(rail_cfg(1)->vbus_en) < - sim_gpio_write_seq(rail_cfg(1)->efuse_en)); -} - -/* 关掉之后判据自己解除武装:不再刷第二条事件,也不再去写那两个脚。 */ -static void test_a_cut_rail_does_not_keep_firing(void) -{ - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); - beats(2); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - - const uint32_t writes = sim_gpio_writes(rail_cfg(1)->efuse_en); - beats(100); /* 2 秒 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(writes, sim_gpio_writes(rail_cfg(1)->efuse_en)); -} - -/* 被关掉的那一路重新上电之后,确认窗口要**从头数**。 - `dev_n` 关路时不清零的话,这一路下次开电只要看见一个越界样本就立刻跳闸 —— - 少了整个确认窗口,而现象只是「这一路特别容易掉」,查不到根上。 */ -static void test_a_rail_that_comes_back_starts_its_hold_window_over(void) -{ - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); - beats(2); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - - inject(1, NOMINAL_MV, 1000000); /* 修好了,重新上电 */ - power_up(1); - - inject(1, NOMINAL_MV + 150, 1000000); /* 又坏了 */ - beat(); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 第一圈还是只起表,不是立刻跳 */ - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(2, g_n_ev); -} - -/* ⭐ 关路之前必须先 `t_actuator_abort()`。 - 不 abort 的现象最阴:硬重启的 `HR_OFF` 会在 4 s 之后自己把 `EFUSE_EN` 抬回去, - 于是「过压关掉的那一路」几秒钟后又带电了,而事件日志里只有一条「已关路」。 */ -static void test_a_running_hard_reset_cannot_bring_the_rail_back(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(1)); - - /* 断电窗口 4 s 之后 `EFUSE_EN` 被抬回来,序列进入等 PG(PG 永远读不到)。 */ - beats((ACT_HR_OFF_MS + T_SAFETY_SETTLE_MS) / 20 + 2); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(ACT_ST_HR_PG, (int)t_actuator_state(1)); - - inject(1, NOMINAL_MV + 150, 1000000); - beats(2); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); - - /* 再跑 10 秒 —— 电不许自己回来。 */ - beats(500); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); -} - -/* abort 顺带把按下去的键松开了。按键序列跑到一半被过压打断, - 键要是留在按下状态,手机再也起不来,而遥测上五路电压电流全都正常。 */ -static void test_a_cut_releases_whatever_key_was_held(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON)); - beat(); - TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(rail_cfg(1)->key_bit[SR595_KEY_PWR])); - - inject(1, NOMINAL_MV + 150, 1000000); - beats(3); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(rail_cfg(1)->key_bit[SR595_KEY_PWR])); - TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); -} - -/* ---- 验收主项二:注入 −60 mA → 断 VBUS(SW-8) --------------------------- */ - -static void test_reverse_current_drops_vbus(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true)); - inject(1, NOMINAL_MV, -60000); /* §12.1 的 −60 mA */ - - const uint32_t t0 = hal_time_now_ms(); - beat(); - TEST_ASSERT_EQUAL_INT(0, vbus_level(1)); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* **只断 VBUS**,直供不动 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_REVERSE, g_ev[0].type); - TEST_ASSERT_EQUAL_INT32(-60000, g_ev[0].current_ua); - TEST_ASSERT_FALSE(g_ev_before_action); - /* SW-8 的预算是 200 ms,实际是一圈。 */ - TEST_ASSERT_TRUE(hal_time_elapsed_ms(t0) < (uint32_t)T_SAFETY_REV_BUDGET_MS); -} - -/* 反灌**不等确认窗口**(与过压判据有意不对称):一圈看见就断。 - 误断 VBUS 只丢 USB 数据,漏判会把能量泵回 12V 母线连累另外四台。 */ -static void test_reverse_current_is_not_debounced(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true)); - inject(1, NOMINAL_MV, -60000); - beat(); /* 就一圈 */ - TEST_ASSERT_EQUAL_INT(0, vbus_level(1)); -} - -/* 没到 −50 mA 不算。手机在直供下本来就会有小幅的双向电流。 */ -static void test_a_small_negative_current_is_not_a_backfeed(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true)); - inject(1, NOMINAL_MV, -40000); - beats(50); - TEST_ASSERT_EQUAL_INT(1, vbus_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); -} - -/* VBUS 本来就断着的时候不判反灌:这条判据只在「USB 那一侧通着」时武装, - 也正是它让「断完之后不再每 20 ms 刷一条事件」成立。 */ -static void test_backfeed_is_only_judged_while_vbus_is_on(void) -{ - power_up(1); /* VBUS 没开 */ - inject(1, NOMINAL_MV, -200000); - beats(50); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(0, sim_gpio_writes(rail_cfg(1)->vbus_en) - 1u); -} - -/* 顶高:VBUS 期间电压被抬过设定值 +100 mV,同样立刻断 VBUS(设计文档 §15.2)。 - 然后 20 ms 后 SR-5 才决定要不要把这一路也关掉 —— 两条判据是分层的: - 先撤掉能量来源,再决定要不要关电。 */ -static void test_being_pushed_up_drops_vbus_first_then_sr5_cuts_the_rail(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true)); - inject(1, NOMINAL_MV + 150, 1000000); - - beat(); - TEST_ASSERT_EQUAL_INT(0, vbus_level(1)); /* 第一圈:VBUS 没了 */ - TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 电还开着 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_PUSHED_UP, g_ev[0].type); - - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); /* 第二圈:还是高,SR-5 关路 */ - TEST_ASSERT_EQUAL_INT(2, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[1].type); -} - -/* ⭐ 断 VBUS 之前要打断 EDL 序列 —— 三个序列里只有它会写 `VBUS_EN`, - 不打断的话下一拍它就把 VBUS 又抬回来了(`EDL_EN=1 → VBUS_EN=1 → 等枚举`)。 */ -static void test_a_running_edl_sequence_cannot_bring_vbus_back(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_edl(1)); - beats(2); /* 短接 10 ms 之后 VBUS_EN 被抬起来,进入等枚举 */ - TEST_ASSERT_EQUAL_INT(1, vbus_level(1)); - TEST_ASSERT_EQUAL_INT(ACT_ST_EDL_ENUM, (int)t_actuator_state(1)); - - inject(1, NOMINAL_MV, -60000); - beat(); - TEST_ASSERT_EQUAL_INT(0, vbus_level(1)); - TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); - - beats(200); /* 4 秒 —— 枚举窗口 3 s 早过了,VBUS 不许自己回来 */ - TEST_ASSERT_EQUAL_INT(0, vbus_level(1)); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); -} - -/* 反灌不去打断跟 VBUS 无关的序列(硬重启只碰 `EFUSE_EN`)—— - 「出了事就把这一路上所有事都停掉」比看上去危险:那会把一次正在进行的 - 救砖重启打断,而重启和反灌是两码事。 */ -static void test_a_backfeed_does_not_abort_an_unrelated_sequence(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true)); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_VUP, ACT_KEY_MS_ON)); - - inject(1, NOMINAL_MV, -60000); - beat(); - TEST_ASSERT_EQUAL_INT(0, vbus_level(1)); - TEST_ASSERT_TRUE(t_actuator_busy(1)); - TEST_ASSERT_EQUAL_INT(ACT_ST_KEY_HOLD, (int)t_actuator_state(1)); -} - -/* 五路各判各的:CH5 反灌不许动 CH2 的 VBUS。 */ -static void test_backfeed_is_per_rail(void) -{ - power_up(2); - power_up(5); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(2, true)); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(5, true)); - - inject(5, NOMINAL_MV, -60000); - beats(5); - TEST_ASSERT_EQUAL_INT(0, vbus_level(5)); - TEST_ASSERT_EQUAL_INT(1, vbus_level(2)); - TEST_ASSERT_EQUAL_INT(1, en_level(5)); - TEST_ASSERT_EQUAL_UINT8(5, g_ev[0].rail); -} - -/* ---- 判据不许改变巡检的形状 ---------------------------------------------- */ - -/* 一条判据也不许发 I²C 事务:设定值、两根 EN、正在跑的序列全部有影子可查。 - 多读一次就把 §10.3 那 7.8 % 的预算顶穿。 */ -static void test_the_rules_do_not_add_any_i2c_traffic(void) -{ - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); - - const uint32_t before = hal_i2c_acct_txns(); - beat(); /* 起表那一圈 */ - beat(); /* 断电那一圈 —— 判据在这一圈干了活 */ - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_UINT32(2u * (INA226_COUNT * 2u + 1u), hal_i2c_acct_txns() - before); - TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us); -} - -/* 判据跑在**放锁之后**:整圈的加锁违规为 0,出门锁已经放掉。 - 拿着 I²C 锁去 `t_actuator_abort()` 会撞上「两把锁不许嵌套」(hal_lock.h)。 */ -static void test_the_rules_run_after_the_bus_lock_is_released(void) -{ - power_up(1); - sim_lock_require(HAL_LOCK_I2C, true); - sim_lock_require(HAL_LOCK_SR595, true); - - TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON)); - inject(1, NOMINAL_MV + 150, 1000000); - beats(3); - - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); - TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_SR595)); - TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); - TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_SR595)); - TEST_ASSERT_FALSE(g_ev_held_lock); -} - -/* 拿不到总线锁的那一圈整圈作废,判据一条都不许跑 —— 那一圈的值是上一圈的。 - 跑的话,「总线忙」就会被当成「电压没变」,而实际上根本没读。 */ -static void test_a_skipped_cycle_runs_no_rules(void) -{ - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); - beat(); /* 起表 */ - - sim_lock_set_task(1); /* 假装 t_link 占着总线 */ - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); - sim_lock_set_task(0); - - TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&g_snap)); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 满 20 ms 了,但这一圈没数 */ - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped); - - sim_lock_set_task(1); - hal_lock_give(HAL_LOCK_I2C); - sim_lock_set_task(0); - beat(); /* 总线回来了,这一圈才断电 */ - TEST_ASSERT_EQUAL_INT(0, en_level(1)); -} - -/* 没接事件出口也照样断电(A11 还没来,日志接不上不是不断电的理由)。 */ -static void test_the_cut_happens_even_with_no_event_sink(void) -{ - t_safety_set_event_sink(NULL, NULL); - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); - beats(2); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.events); /* 快照里仍然记着 */ - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_snap.last.type); -} - -int main(void) -{ - UNITY_BEGIN(); - RUN_TEST(test_thresholds_match_the_documents); - - RUN_TEST(test_overvoltage_cuts_the_rail_within_one_period_and_logs_it); - RUN_TEST(test_the_cut_lands_exactly_one_hold_window_after_first_sight); - RUN_TEST(test_the_cut_is_never_late_because_of_sub_millisecond_jitter); - RUN_TEST(test_undervoltage_cuts_the_rail_too); - RUN_TEST(test_a_one_cycle_glitch_does_not_cut_anything); - RUN_TEST(test_exactly_one_hundred_millivolts_is_still_in_range); - RUN_TEST(test_the_reference_is_the_setpoint_not_a_constant); - - RUN_TEST(test_a_rail_that_is_off_is_never_judged); - RUN_TEST(test_the_soft_start_window_is_not_judged); - RUN_TEST(test_a_blind_rail_keeps_its_power); - - RUN_TEST(test_only_the_offending_rail_is_touched); - RUN_TEST(test_the_cut_drops_vbus_before_the_efuse); - RUN_TEST(test_a_cut_rail_does_not_keep_firing); - RUN_TEST(test_a_rail_that_comes_back_starts_its_hold_window_over); - RUN_TEST(test_a_running_hard_reset_cannot_bring_the_rail_back); - RUN_TEST(test_a_cut_releases_whatever_key_was_held); - - RUN_TEST(test_reverse_current_drops_vbus); - RUN_TEST(test_reverse_current_is_not_debounced); - RUN_TEST(test_a_small_negative_current_is_not_a_backfeed); - RUN_TEST(test_backfeed_is_only_judged_while_vbus_is_on); - RUN_TEST(test_being_pushed_up_drops_vbus_first_then_sr5_cuts_the_rail); - RUN_TEST(test_a_running_edl_sequence_cannot_bring_vbus_back); - RUN_TEST(test_a_backfeed_does_not_abort_an_unrelated_sequence); - RUN_TEST(test_backfeed_is_per_rail); - - RUN_TEST(test_the_rules_do_not_add_any_i2c_traffic); - RUN_TEST(test_the_rules_run_after_the_bus_lock_is_released); - RUN_TEST(test_a_skipped_cycle_runs_no_rules); - RUN_TEST(test_the_cut_happens_even_with_no_event_sink); - return UNITY_END(); -} +/* A10-R2 之二:安全巡检的**判据**(SR-5 / SW-8 + R2 新增的两条)。 + 一圈的形状与总线账在 `test_t_safety.c` 里,这里只管「看见什么、做什么、按什么顺序」。 + + 计划里这一包的验收原话是「注入过压 → 20 ms 内关路并记事件;注入 −60 mA → 断 VBUS」, + R2 又加了两条:「刀片不在位 ⇒ 不许带电」「mux 失联 ⇒ 五路全断」(计划 §5.8)。 + + ---- R2 把这一层最要紧的一件事换掉了 ------------------------------------ + + R1:`EFUSE_EN` 与 `VBUS_EN` 是一对 GPIO,断哪个都是几百纳秒,顺序靠 + `sim_gpio_write_seq()` 两个序号一比就证明了。 + + R2:**`VBUS_EN` 搬到了刀片 PCA9555 的 P0.4 上**(计划 §5.4)。于是 + · 断 VBUS 变成一次跨 mux 的 I²C 事务,**必须在锁内、在本路的 `xact` 里做** + —— 放锁之后再拿一次锁,中间隔着一次锁争用,而且那时通道可能已经被别人 + 切走了,那一写就落到**另一片刀片**上:另一台手机的 USB 被拔了, + 出事那一台的 VBUS 还通着。两边都不报错。 + · `EFUSE_EN` 相反,它仍是 GPIO 直连,**放锁之后**关才对。 + ⭐ 带星的三条用例就是这件事的验收:不光要证明「顺序对」,还要证明 + **断 VBUS 那一写发生时,mux 正选在这一路上**。 + + ⚠️ 断言一律打在假器件的可观测面上:`EFUSE_EN` 看 `sim_gpio_level()` 与全局写 + 序号,`VBUS_EN` 看 `sim_pca9555_pin()`(栅极上的实际电平)与一个挂在假 PCA9555 + 上的钩子,电压电流从**假 INA226 的寄存器**注入。对着驱动自己的影子断言等于 + 自己证明自己 —— 影子是写成功才更新的,「写到了另一片上」它一个字都不会说。 + + 本文件里的 `beat()` 就是真板上两个任务合起来干的事:推一步执行器、跑一圈巡检、 + 睡到下一个 20 ms 格子。判据的「持续 20 ms」只有在真的按 20 ms 打拍子时才有意义。 */ +#include + +#include "board_pins.h" +#include "hal_gpio.h" +#include "hal_i2c.h" +#include "hal_i2c_acct.h" +#include "hal_lock.h" +#include "hal_time.h" +#include "i2cbus.h" +#include "ina226.h" +#include "pca9555.h" +#include "phonerail.h" +#include "rail.h" +#include "rail_cfg.h" +#include "sched.h" +#include "sim_hal.h" +#include "slot.h" +#include "t_actuator.h" +#include "t_safety.h" +#include "tasks.h" +#include "tca9548a.h" +#include "unity.h" + +#define N PHONERAIL_RAIL_COUNT + +/* 稳态:五路都设在 3900 mV(`RAIL_MV_SAFE`),电流 1 A 正向。 */ +#define NOMINAL_MV 3900 + +#define DAC_ADDR_A 0x4A +#define DAC_ADDR_B 0x4B + +static safety_snap_t g_snap; +static sched_periodic_t g_tick; + +static int ch_of(int rail) { return (int)rail_cfg(rail)->mux_ch; } + +/* ---- ⭐ 盯住每一片刀片的 Port0 是「什么时候、在哪个通道上」被写的 -------- + + 这是 R2 规矩 3 唯一的直接凭据。只看终值的话,下面三种实现长得一模一样: + ① 锁内、通道选在本路上写(对的); + ② 放锁之后重新拿锁再写(此刻通道可能已经被 `t_actuator` 切走了); + ③ 干脆写到了另一片上(那台手机的 USB 被拔,出事这台的 VBUS 还通着)。 + 所以在假器件的写钩子里把「这一刻 mux 选在几号通道、锁在不在手上、本路的 + `EFUSE_EN` 写到第几号了」一起记下来。 */ +typedef struct { + uint32_t n; /* 这一片的 Port0 被写过几次 */ + int mux_ch; /* 最后一次写发生时,假 mux 实际选通的是哪一路 */ + bool lock_held; /* 最后一次写发生时,I²C 锁在不在自己手上 */ + uint32_t efuse_seq; /* 那一刻本路 `EFUSE_EN` 的全局写序号(0 = 还没被写过) */ + /* ⭐ 那一刻 I²C 锁**总共被拿过几次**。这个数才是「锁内还是锁外」的硬凭据: + `hal_lock_held()` 在「放锁之后重新拿一次锁再写」的实现里**同样是 true** + (那时锁确实在自己手上),通道也同样是选对的 —— 两条断言都拦不住它。 + 而「重新拿一次锁」必然让本圈的拿锁次数多出一次,这个数骗不了人。 */ + uint32_t lock_takes; + uint8_t value; /* 落进输出锁存器的字节 */ +} out0_watch_t; + +static out0_watch_t g_w[N]; +static sim_i2c_hook_t g_inner_hook; /* 假 PCA9555 自己那一份,必须接着跑 */ + +static void watch_hook(sim_i2c_dev_t *d, sim_i2c_ev_t ev) +{ + if (g_inner_hook != NULL) { + g_inner_hook(d, ev); /* 先让假器件把输入寄存器合成好 / 查危险方向 */ + } + if (ev != SIM_I2C_EV_POST_WRITE) { + return; + } + /* 只关心「写输出锁存器」那一种写;读输入时线上也有一个字节的寄存器指针。 */ + if (d->last_write_len < 2 || d->last_write[0] != PCA9555_REG_OUTPUT0) { + return; + } + for (int rail = 1; rail <= N; rail++) { + if (ch_of(rail) != d->bus) { + continue; + } + out0_watch_t *w = &g_w[rail - 1]; + w->n++; + w->mux_ch = sim_i2c_mux_ch(); + w->lock_held = hal_lock_held(HAL_LOCK_I2C); + w->efuse_seq = sim_gpio_write_seq(rail_cfg(rail)->efuse_en); + w->lock_takes = sim_lock_takes(HAL_LOCK_I2C); + w->value = d->last_write[1]; + } +} + +static void watch_reset(void) { memset(g_w, 0, sizeof(g_w)); } + +/* ---- 事件出口:把 A11 的环形缓冲换成一个能断言的小盒子 ------------------- */ + +static safety_event_t g_ev[8]; +static int g_n_ev; +/* 回调被调的那一刻锁都不该被持着(t_safety.h:回调是放锁之后才调的)。 */ +static bool g_ev_held_lock; +/* 事件被记下来的那一刻,动作**必须已经做完了**(先保命,再记账)。 + 顺序反过来在功能上看不出任何区别 —— 除非像这里一样,在记录的那一刻回头看线。 */ +static bool g_ev_before_action; + +static void catch_event(void *ctx, const safety_event_t *ev) +{ + (void)ctx; + if (hal_lock_held(HAL_LOCK_I2C)) { + g_ev_held_lock = true; + } + switch ((safety_ev_t)ev->type) { + case SAFETY_EV_OVER_VOLT: + case SAFETY_EV_UNDER_VOLT: + case SAFETY_EV_SLOT_EMPTY: + if (rail_efuse_is_on(ev->rail)) { + g_ev_before_action = true; /* 记这条时电早该断了 */ + } + break; + case SAFETY_EV_REVERSE: + case SAFETY_EV_PUSHED_UP: + if (slot_vbus_is_on(ev->rail)) { + g_ev_before_action = true; /* SW-8:记这条时 VBUS 早该断了 */ + } + break; + case SAFETY_EV_MUX_LOST: + for (int rail = 1; rail <= N; rail++) { + if (rail_efuse_is_on(rail)) { + g_ev_before_action = true; /* 五路早该全断了 */ + } + } + break; + default: + break; + } + if (g_n_ev < (int)(sizeof(g_ev) / sizeof(g_ev[0]))) { + g_ev[g_n_ev] = *ev; + } + g_n_ev++; +} + +/* ---- 注入与观测 ---------------------------------------------------------- */ + +/* 从**那一路通道上**那片假 INA226 的寄存器注入。直接改快照等于跳过驱动和 mux, + 那样连「读的是哪一片」都证明不了。 */ +static void inject(int rail, int mv, int ua) +{ + TEST_ASSERT_EQUAL_INT(0, mv % 5); + sim_i2c_set_reg_ch(ch_of(rail), rail_cfg(rail)->ina_addr, INA226_REG_BUS_V, + (uint16_t)(mv * 4 / 5)); + sim_i2c_set_reg_ch(ch_of(rail), rail_cfg(rail)->ina_addr, INA226_REG_CURRENT, + (uint16_t)(int16_t)(ua / INA226_CURRENT_LSB_UA)); +} + +static int en_level(int rail) { return sim_gpio_level(rail_cfg(rail)->efuse_en); } +static uint32_t en_writes(int rail) { return sim_gpio_writes(rail_cfg(rail)->efuse_en); } + +/* `VBUS_EN` 在刀片 PCA9555 的 P0.4 上。看的是**栅极上的实际电平**, + 不是 `slot_vbus_is_on()`(那是驱动自己的影子)。 */ +static int vbus_pin(int rail) +{ + return sim_pca9555_pin(rail - 1, 0, PCA9555_BIT_VBUS_EN); +} + +/* ---- 假板子 -------------------------------------------------------------- */ + +static void board_up(void) +{ + const hal_i2c_cfg_t cfg = { .sda_gpio = BOARD_PIN_I2C_SDA, + .scl_gpio = BOARD_PIN_I2C_SCL, + .freq_hz = 400000 }; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); + + sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N); + for (int rail = 1; rail <= N; rail++) { + const int ch = ch_of(rail); + sim_i2c_add_ch(ch, rail_cfg(rail)->ina_addr, 2); + inject(rail, NOMINAL_MV, 1000000); /* 3.900 V / 1 A,稳稳当当 */ + sim_pca9555_attach_ch(rail - 1, ch, PCA9555_ADDR); + sim_pca9555_drive(rail - 1, 1, 0x0F); /* 四个开漏信号静息全高 = 没有故障 */ + } + sim_i2c_add(INA226_ADDR_INPUT, 2); + sim_i2c_add(DAC_ADDR_A, 2); + sim_i2c_add(DAC_ADDR_B, 2); + + /* 钩子要挂在假 PCA9555 自己那一份**之后**(链上去,不是顶掉 —— + 顶掉的话输入寄存器就没人合成,五路的 PG/FLT 会全读成 0)。 */ + g_inner_hook = sim_i2c_dev_ch(ch_of(1), PCA9555_ADDR)->hook; + TEST_ASSERT_NOT_NULL(g_inner_hook); + for (int rail = 1; rail <= N; rail++) { + sim_i2c_dev_ch(ch_of(rail), PCA9555_ADDR)->hook = watch_hook; + } + + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); /* 五根 EN 线,全部拉低 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_prsnt_init()); + + /* ⚠️ `PRSNT#` 是上拉输入,没人驱动就是高 = 刀片不在位。不驱低的话每一圈都会 + 触发「刀片不在位」判据 —— 现象是所有用例都莫名多出五条事件。 */ + for (int rail = 1; rail <= N; rail++) { + sim_gpio_drive(rail_cfg(rail)->prsnt_n, 0); + } + slot_prsnt_poll(); + + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail)); + } + rail_setpoint_reset(); /* 五路设定值回到 NVM 默认 */ + t_actuator_init(NULL, NULL); /* PG「永远读不到」—— 硬重启会走完并记 NO_PG */ + + g_n_ev = 0; + g_ev_held_lock = false; + g_ev_before_action = false; + memset(g_ev, 0, sizeof(g_ev)); + watch_reset(); /* `slot_init()` 写过一次 OUTPUT0,不算在用例头上 */ + t_safety_set_event_sink(catch_event, NULL); + + t_safety_init(&g_snap); + sched_periodic_init(&g_tick, task_desc(TASK_SAFETY)->period_ms); +} + +void setUp(void) +{ + sim_hal_reset(); + board_up(); +} + +/* 每条用例收尾都查三件事:没有两路通道同时开着(那会读到两片的线与)、 + 没有把接地脚配成输出、事件回调没在持锁时被调。 */ +void tearDown(void) +{ + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); + TEST_ASSERT_FALSE(g_ev_held_lock); + TEST_ASSERT_FALSE(g_ev_before_action); +} + +/* 一拍 = 真板上两个任务在这 20 ms 里干的事。执行器要先推,因为判据打断的正是它。 */ +static void beat(void) +{ + (void)t_actuator_step(); + (void)t_safety_step(&g_snap); + sched_periodic_wait(&g_tick); +} + +static void beats(int n) +{ + for (int i = 0; i < n; i++) { + beat(); + } +} + +/* 开一路电并熬过缓启动窗口。判据在这个窗口里是**不武装**的(斜坡还没走完, + 这时候比电压等于每次上电都自己把自己关掉)。 */ +static void power_up(int rail) +{ + const int before = g_n_ev; + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, true)); + beats(1 + (T_SAFETY_SETTLE_MS / (int)task_desc(TASK_SAFETY)->period_ms) + 1); + TEST_ASSERT_TRUE(rail_efuse_is_on(rail)); + TEST_ASSERT_EQUAL_INT(1, en_level(rail)); + TEST_ASSERT_EQUAL_INT(before, g_n_ev); /* 上电加稳态几拍,不该冒出新事件 */ +} + +/* 把这一路的 VBUS 通上(执行器路径:自带一次 `xact`)。 */ +static void vbus_on(int rail) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(rail, true)); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(rail)); +} + +/* ---- 常量与文档对账(改了任何一个就是改了需求) -------------------------- */ + +static void test_thresholds_match_the_documents(void) +{ + /* SR-5 / SW-2 的两个数,逐字来自需求 §9 那两行。 */ + TEST_ASSERT_EQUAL_INT(100, T_SAFETY_DEV_MV); + TEST_ASSERT_EQUAL_INT(20, T_SAFETY_DEV_HOLD_MS); + + /* 判据数的是**圈**,SR-5 写的是**毫秒** —— 这一条把两者钉在一起。 + 巡检周期哪天改了,它会当场对不上,而不是悄悄把「持续 20 ms」变成 40 ms。 */ + TEST_ASSERT_EQUAL_INT(2, T_SAFETY_DEV_HOLD_CYCLES); + TEST_ASSERT_EQUAL_UINT32((T_SAFETY_DEV_HOLD_CYCLES - 1) * task_desc(TASK_SAFETY)->period_ms, + (uint32_t)T_SAFETY_DEV_HOLD_MS); + + /* SW-8 的两个数:设计文档 §15.2 对策 2 的 −50 mA,需求 SW-8 的 200 ms 预算。 */ + TEST_ASSERT_EQUAL_INT32(-50000, T_SAFETY_REV_UA); + TEST_ASSERT_EQUAL_INT(200, T_SAFETY_REV_BUDGET_MS); + /* R2 里断 VBUS 多了一次 I²C 事务(≈0.16 ms),判据延迟变成「一圈 + 一次事务」。 + 它必须离 200 ms 的预算远得看得出来 —— 计划 §5.4 说的「仍有 10 倍余量」。 */ + TEST_ASSERT_TRUE((int)task_desc(TASK_SAFETY)->period_ms * 2 + + (int)(t_safety_budget_us() / 1000u) < + T_SAFETY_REV_BUDGET_MS / 2); + + /* 缓启动窗口必须盖得住 tON + 斜坡那 17 ms(§7.4),而且要是周期的整数倍以上 + —— 比一个周期还短的窗口等于没有。 */ + TEST_ASSERT_TRUE(T_SAFETY_SETTLE_MS > (int)ACT_HR_RAMP_MS); + TEST_ASSERT_TRUE(T_SAFETY_SETTLE_MS >= 2 * (int)task_desc(TASK_SAFETY)->period_ms); + + /* §12.1 注入的是 ±150 mV / −60 mA,两个都必须落在阈值**外侧**, + 否则验收测的是「阈值刚好卡在注入值上」。 */ + TEST_ASSERT_TRUE(150 > T_SAFETY_DEV_MV); + TEST_ASSERT_TRUE(-60000 < T_SAFETY_REV_UA); + + /* ⭐ R2 新增:mux 连续选不中多少次才算失联。 + 1 的话一次总线毛刺就停五台手机;太大的话失联之后还有一大截时间在拿旧数据 + 跑判据。这个数是「先按 3 写、上板实测误码率之后再定」的待定项(计划 §11), + 改它必须连同这条注释一起改。 */ + TEST_ASSERT_EQUAL_INT(3, T_SAFETY_MUX_FAIL_N); + TEST_ASSERT_TRUE(T_SAFETY_MUX_FAIL_N > 1); + + /* 两条新事件必须排在枚举**末尾**:evlog 里存的是这几个数字,插在中间会让 + 已经落盘的旧记录改变含义(`t_safety.h` 那句「flash 格式」)。 */ + TEST_ASSERT_EQUAL_INT(SAFETY_EV_PUSHED_UP + 1, SAFETY_EV_MUX_LOST); + TEST_ASSERT_EQUAL_INT(SAFETY_EV_MUX_LOST + 1, SAFETY_EV_SLOT_EMPTY); +} + +/* ---- 验收主项一:注入过压 → 20 ms 内关路并记事件(SR-5) ----------------- */ + +static void test_overvoltage_cuts_the_rail_within_one_period_and_logs_it(void) +{ + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); /* §12.1 的 +150 mV */ + + beat(); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 第一次看见:只起表,不动电 */ + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); /* 满 20 ms:这一拍断电 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type); + TEST_ASSERT_EQUAL_UINT8(1, g_ev[0].rail); + TEST_ASSERT_EQUAL_INT32(NOMINAL_MV + 150, g_ev[0].bus_mv); /* 记的是实测值 */ + TEST_ASSERT_EQUAL_INT32(1000000, g_ev[0].current_ua); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.events); +} + +/* 从**第一次看见**到断电正好一个周期。 + ⚠️ 这一条把假总线的耗时关掉(sim_hal.h 允许的那个正当理由:拿虚拟时钟当纯 + 计数器)—— 开着的话每一拍还要加上巡检自己占的 2.34 ms,量出来是 18/22 ms + 之间跳,那几毫秒是巡检花的,不是判据晚了。 */ +static void test_the_cut_lands_exactly_one_hold_window_after_first_sight(void) +{ + sim_i2c_set_bus_time(false); + power_up(2); + inject(2, NOMINAL_MV - 150, 1000000); + + const uint32_t t_first_sight = hal_time_now_ms(); + beat(); + beat(); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(T_SAFETY_DEV_HOLD_MS, g_ev[0].t_ms - t_first_sight); +} + +/* ⭐ 同一件事,把总线耗时打开(也就是真板上的样子):判据必须**照样在第二圈** + 断电。R2 一圈占 2.34 ms,比 R1 的 1.56 ms 更长,相位残差更大。 + + 判据一旦改回按墙钟算 `>= 20 ms`,落在 19 ms 那一侧的情况就要等到第三圈 —— + 40 ms,超了 §12.1 的验收,而且是**间歇性**的(同一个故障注两次,一次过一次不过)。 + 跑 20 轮,各种亚毫秒相位都会踩到。 */ +static void test_the_cut_is_never_late_because_of_sub_millisecond_jitter(void) +{ + power_up(2); + for (int i = 0; i < 20; i++) { + /* 每一轮都从「刚开电」重来,让第一次看见落在不同的亚毫秒相位上。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(2, true)); + inject(2, NOMINAL_MV, 1000000); + beats(6); /* 熬过缓启动窗口 */ + g_n_ev = 0; + + inject(2, NOMINAL_MV + 150, 1000000); + const uint32_t t0 = hal_time_now_ms(); + beat(); + TEST_ASSERT_EQUAL_INT(1, en_level(2)); /* 第一圈只起表 */ + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(2)); /* 第二圈必须断,一次都不许拖 */ + + /* 墙钟量出来在 20 ms 上下几毫秒:一圈自己占 2.34 ms 总线,加上毫秒截断。 + 那几毫秒是巡检花的,不是判据晚的 —— 上面两条断言才是主判据。 */ + const uint32_t late = g_ev[0].t_ms - t0; + TEST_ASSERT_TRUE(late >= T_SAFETY_DEV_HOLD_MS - 3 && late <= T_SAFETY_DEV_HOLD_MS + 3); + } +} + +/* 偏低同样关路(SR-5 是 ±,不是「过压」)。掉压的现因多半是排线接触不良, + 而那正是这块板子最常见的故障。 */ +static void test_undervoltage_cuts_the_rail_too(void) +{ + power_up(3); + inject(3, NOMINAL_MV - 150, 1000000); + beats(2); + + TEST_ASSERT_EQUAL_INT(0, en_level(3)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); +} + +/* 「持续」的意思是连续。只闪了一圈就回到范围内的偏差不许关路 —— + 不然一次负载阶跃(0→4 A)就能把五台手机全关掉。 */ +static void test_a_one_cycle_glitch_does_not_cut_anything(void) +{ + power_up(1); + for (int i = 0; i < 20; i++) { + inject(1, NOMINAL_MV + 150, 1000000); + beat(); + inject(1, NOMINAL_MV, 1000000); + beat(); + } + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + +/* 阈值是「**超过** 100 mV」:正好 100 mV 不算。 + 写成 >= 的话,SW-4 允许运维把某一路微调 100 mV 之后那一路就再也开不起来。 */ +static void test_exactly_one_hundred_millivolts_is_still_in_range(void) +{ + power_up(1); + inject(1, NOMINAL_MV + T_SAFETY_DEV_MV, 1000000); + beats(10); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + + inject(1, NOMINAL_MV - T_SAFETY_DEV_MV, 1000000); + beats(10); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + +/* 比的是**该路 DAC 的设定值**,不是 3900 这个常数。SW-4 允许单路微调, + 拿常数比的话,一台被调到 4.010 V 的手机会被当成过压关掉 —— 而且只有那一路。 */ +static void test_the_reference_is_the_setpoint_not_a_constant(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_set_mv(1, RAIL_MV_MAX)); + TEST_ASSERT_EQUAL_INT(RAIL_MV_MAX, rail_setpoint_mv(1)); + + inject(1, RAIL_MV_MAX, 1000000); /* 实测跟着设定走 */ + power_up(1); + beats(10); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + + /* 回到 3900:现在这才是「偏低 110 mV」。 */ + inject(1, NOMINAL_MV, 1000000); + beats(2); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); +} + +/* ---- 判据什么时候**不**武装(三道闸,每一道都是一次真事故的预防) -------- */ + +/* 关着的路不判据。不设这一条的现象是:巡检每 20 ms 就「发现」五路各偏低 + 3900 mV,然后没完没了地去关已经关着的路、刷满事件日志。 */ +static void test_a_rail_that_is_off_is_never_judged(void) +{ + inject(1, 0, 0); /* 关着的路就是 0 V —— 离设定值差着 3900 mV */ + beats(50); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(1, en_writes(1)); /* 只有 rail_en_init() 那一次 */ + TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); /* 也没去写刀片的输出锁存器 */ +} + +/* 刚开电的那几十毫秒不判据:tON 5.1 ms + 斜坡 11.5 ms 期间电压本来就在爬。 + 没有这个窗口的现象是**每次上电都自己把自己关掉**,而且五路一起。 */ +static void test_the_soft_start_window_is_not_judged(void) +{ + inject(1, 1200, 100000); /* 斜坡爬到一半 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(1, true)); + + const int in_window = T_SAFETY_SETTLE_MS / (int)task_desc(TASK_SAFETY)->period_ms; + beats(in_window); /* 窗口之内:不管电压多离谱都不判 */ + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + + /* 窗口一过,判据武装,还是这么低就该关了。 */ + beats(3); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); +} + +/* 器件失联的那一路不判据(§10.10:「保持供电,标记为盲跑,**禁用该路的 + SR-5 / SW-8 判据**」)。快照里留着的是上一圈的值,拿它去关电是最难查的那种错。 */ +static void test_a_blind_rail_keeps_its_power(void) +{ + power_up(4); + + /* ⚠️ 顺序是有讲究的:**先让坏值真的读进快照一次**,再让器件失联。 + 反过来写(先失联再改寄存器)的话快照里躺着的是个正常值, + 「盲跑那一路照样判据」这个错就一点痕迹都不会留下 —— 测试永远绿。 */ + inject(4, NOMINAL_MV - 150, 1000000); + beat(); + TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* 第一个样本只起表 */ + sim_i2c_set_present_ch(ch_of(4), rail_cfg(4)->ina_addr, false); + + beats(50); /* 1 秒,远超两条判据各自的窗口 */ + TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* §10.10:盲跑 = 保持供电 */ + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_FALSE(g_snap.rail_ok[3]); + /* 快照里躺着的确实是那个越界的旧值 —— 这是上面那句「顺序有讲究」的凭据。 */ + TEST_ASSERT_EQUAL_INT32(NOMINAL_MV - 150, g_snap.rail[3].bus_mv); + + /* 器件回来了,值还是坏的 —— 这时才该关,而且要重新数满 20 ms。 */ + sim_i2c_set_present_ch(ch_of(4), rail_cfg(4)->ina_addr, true); + beat(); + TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* 第一个新鲜样本只起表 */ + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(4)); +} + +/* ---- ⭐ 关路这件事本身:R2 的顺序与「写给了哪一片」 ---------------------- */ + +/* ⭐⭐ 本包最要紧的一条。关路 = **先断 VBUS(锁内、本路通道上)再关 eFuse(锁外)**。 + 三件事一起证: + ① 那一写发生时,假 mux 实际选通的就是这一路 —— 不是别人的通道,也不是全关; + ② 那一刻 I²C 锁在自己手上(放锁之后再拿一次锁去写,中间隔着一次锁争用); + ③ 那一刻本路 `EFUSE_EN` 还没被写 —— 也就是 eFuse 是**之后**才关的。 + 只看两个终值的话,「先关 eFuse 再断 VBUS」和这条长得一模一样,而那个顺序会 + 留下一小段「手机只靠 USB 供着电」的窗口:既不是开,也不是关。 */ +static void test_the_cut_drops_vbus_inside_the_lock_on_this_rails_channel(void) +{ + const int rail = 3; /* CH3 —— DAC 在**另一片**上、mux 通道 2,最容易算错的那一路 */ + power_up(rail); + vbus_on(rail); + watch_reset(); + + const uint32_t en_seq_before = sim_gpio_write_seq(rail_cfg(rail)->efuse_en); + + inject(rail, NOMINAL_MV - 150, 1000000); /* 欠压,不是反灌 */ + beat(); /* 第一圈只起表 */ + TEST_ASSERT_EQUAL_UINT32(0, g_w[rail - 1].n); + + /* 断电那一圈从这里开始数拿锁次数:一圈是五路 + 主干 = 六次 `xact`, + 断 VBUS 那一写必须落在**第 rail 次**那一次里(也就是读这一路的同一次持锁)。 */ + const uint32_t takes_before = sim_lock_takes(HAL_LOCK_I2C); + beat(); + TEST_ASSERT_EQUAL_UINT32((uint32_t)N + 1u, sim_lock_takes(HAL_LOCK_I2C) - takes_before); + + /* 终值:两边都断了。 */ + TEST_ASSERT_EQUAL_INT(0, vbus_pin(rail)); + TEST_ASSERT_EQUAL_INT(0, en_level(rail)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); + + /* ① 断 VBUS 那一写,落在**这一路自己的通道**上。 */ + TEST_ASSERT_EQUAL_UINT32(1, g_w[rail - 1].n); + TEST_ASSERT_EQUAL_INT(ch_of(rail), g_w[rail - 1].mux_ch); + /* ② 而且是**读这一路的那一次持锁里**写下去的,不是放锁之后重新拿一次锁。 + 重新拿一次锁的代价不是慢:中间隔着一次锁争用,`t_actuator` 完全可能在那 + 一瞬间把通道切到别的刀片上,那一写就落到另一台手机的 PCA9555 上 —— + 别人的 USB 被拔了,出事这一台的 VBUS 还通着,两边都不报错。 */ + TEST_ASSERT_TRUE(g_w[rail - 1].lock_held); + TEST_ASSERT_EQUAL_UINT32((uint32_t)rail, g_w[rail - 1].lock_takes - takes_before); + /* ③ 那一刻 `EFUSE_EN` 还停在断电前的写序号上 ⇒ eFuse 是后关的。 */ + TEST_ASSERT_EQUAL_UINT32(en_seq_before, g_w[rail - 1].efuse_seq); + TEST_ASSERT_TRUE(sim_gpio_write_seq(rail_cfg(rail)->efuse_en) > en_seq_before); + /* 写下去的字节里 VBUS 位是 0(顺带确认没把三个键按下去)。 */ + TEST_ASSERT_EQUAL_UINT8(0, g_w[rail - 1].value); + + /* 另外四片刀片一个字节都没被写过 —— 「写到了另一片上」的直接拦网。 */ + for (int r = 1; r <= N; r++) { + if (r == rail) { + continue; + } + TEST_ASSERT_EQUAL_UINT32(0, g_w[r - 1].n); + TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(r - 1, 0)); + } +} + +/* 只关出事的那一路,另外四路一根线、一个字节都不许动。 */ +static void test_only_the_offending_rail_is_touched(void) +{ + for (int rail = 1; rail <= N; rail++) { + power_up(rail); + vbus_on(rail); + } + watch_reset(); + uint32_t before[N]; + for (int rail = 1; rail <= N; rail++) { + before[rail - 1] = en_writes(rail); + } + + inject(3, NOMINAL_MV + 150, 1000000); + beats(2); + + TEST_ASSERT_EQUAL_INT(0, en_level(3)); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(3)); + for (int rail = 1; rail <= N; rail++) { + if (rail == 3) { + continue; + } + TEST_ASSERT_EQUAL_INT(1, en_level(rail)); + TEST_ASSERT_EQUAL_UINT32(before[rail - 1], en_writes(rail)); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(rail)); /* 别人的 USB 不许被拔 */ + TEST_ASSERT_EQUAL_UINT32(0, g_w[rail - 1].n); + } + /* CH3 的 VBUS 也通着,所以它出的是两条:第一圈 SW-8「被顶高」断 VBUS, + 第二圈 SR-5 关路(两条判据分层,见后面那条专门的用例)。 + 要紧的是这两条**都只指向 CH3**。 */ + TEST_ASSERT_EQUAL_INT(2, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(3, g_ev[0].rail); + TEST_ASSERT_EQUAL_UINT8(3, g_ev[1].rail); +} + +/* 关掉之后判据自己解除武装:不再刷第二条事件,也不再去写那两个东西。 */ +static void test_a_cut_rail_does_not_keep_firing(void) +{ + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); + beats(2); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + + const uint32_t writes = en_writes(1); + watch_reset(); + beats(100); /* 2 秒 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(writes, en_writes(1)); + TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); +} + +/* 被关掉的那一路重新上电之后,确认窗口要**从头数**。 + `dev_n` 关路时不清零的话,这一路下次开电只要看见一个越界样本就立刻跳闸 —— + 少了整个确认窗口,而现象只是「这一路特别容易掉」,查不到根上。 */ +static void test_a_rail_that_comes_back_starts_its_hold_window_over(void) +{ + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); + beats(2); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + + inject(1, NOMINAL_MV, 1000000); /* 修好了,重新上电 */ + power_up(1); + + inject(1, NOMINAL_MV + 150, 1000000); /* 又坏了 */ + beat(); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 第一圈还是只起表,不是立刻跳 */ + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(2, g_n_ev); +} + +/* ⭐ 关路之前必须先 `t_actuator_abort()`。 + 不 abort 的现象最阴:硬重启的 `HR_OFF` 会在 4 s 之后自己把 `EFUSE_EN` 抬回去, + 于是「过压关掉的那一路」几秒钟后又带电了,而事件日志里只有一条「已关路」。 */ +static void test_a_running_hard_reset_cannot_bring_the_rail_back(void) +{ + power_up(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(1)); + + /* 断电窗口 4 s 之后 `EFUSE_EN` 被抬回来,序列进入等 PG(PG 永远读不到)。 */ + beats((ACT_HR_OFF_MS + T_SAFETY_SETTLE_MS) / 20 + 2); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(ACT_ST_HR_PG, (int)t_actuator_state(1)); + + inject(1, NOMINAL_MV + 150, 1000000); + beats(2); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); + + /* 再跑 10 秒 —— 电不许自己回来。 */ + beats(500); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +/* abort 顺带把按下去的键松开了。按键序列跑到一半被过压打断, + 键要是留在按下状态,手机再也起不来,而遥测上五路电压电流全都正常。 + ⚠️ R2 里「键」在刀片 PCA9555 的 P0.0 上,所以这一条顺带也证明了收尾那一次 + I²C 写落到了正确的那一片上。 */ +static void test_a_cut_releases_whatever_key_was_held(void) +{ + power_up(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON)); + beat(); + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_PWR)); + + inject(1, NOMINAL_MV + 150, 1000000); + beats(3); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_PWR)); + TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); + /* 别的四片的键也没被顺手按下(影子记在另一片上的经典后果)。 */ + for (int r = 2; r <= N; r++) { + TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(r - 1, 0)); + } +} + +/* ---- 验收主项二:注入 −60 mA → 断 VBUS(SW-8) --------------------------- */ + +static void test_reverse_current_drops_vbus(void) +{ + power_up(1); + vbus_on(1); + watch_reset(); + const uint32_t en_w = en_writes(1); + inject(1, NOMINAL_MV, -60000); /* §12.1 的 −60 mA */ + + const uint32_t t0 = hal_time_now_ms(); + const uint32_t takes_before = sim_lock_takes(HAL_LOCK_I2C); + beat(); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* **只断 VBUS**,直供不动 */ + TEST_ASSERT_EQUAL_UINT32(en_w, en_writes(1)); /* eFuse 那根线一次都没被碰 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_REVERSE, g_ev[0].type); + TEST_ASSERT_EQUAL_INT32(-60000, g_ev[0].current_ua); + + /* ⭐ 这一写同样是锁内、本路通道上的(SW-8 走的是和 SR-5 同一条路)。 */ + TEST_ASSERT_EQUAL_UINT32(1, g_w[0].n); + TEST_ASSERT_EQUAL_INT(ch_of(1), g_w[0].mux_ch); + TEST_ASSERT_TRUE(g_w[0].lock_held); + /* CH1 是本圈第一次 `xact`:断 VBUS 那一写就在那一次持锁里。 */ + TEST_ASSERT_EQUAL_UINT32(1, g_w[0].lock_takes - takes_before); + TEST_ASSERT_EQUAL_UINT32((uint32_t)N + 1u, sim_lock_takes(HAL_LOCK_I2C) - takes_before); + + /* SW-8 的预算是 200 ms,实际是一圈 + 一次事务。 */ + TEST_ASSERT_TRUE(hal_time_elapsed_ms(t0) < (uint32_t)T_SAFETY_REV_BUDGET_MS); +} + +/* 反灌**不等确认窗口**(与过压判据有意不对称):一圈看见就断。 + 误断 VBUS 只丢一次 USB 数据,手机还由直供电着;漏判会把能量泵回 12V 母线, + **连累另外四台**。 */ +static void test_reverse_current_is_not_debounced(void) +{ + power_up(1); + vbus_on(1); + inject(1, NOMINAL_MV, -60000); + beat(); /* 就一圈 */ + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +/* 没到 −50 mA 不算。手机在直供下本来就会有小幅的双向电流。 */ +static void test_a_small_negative_current_is_not_a_backfeed(void) +{ + power_up(1); + vbus_on(1); + watch_reset(); + inject(1, NOMINAL_MV, -40000); + beats(50); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); +} + +/* VBUS 本来就断着的时候不判反灌:这条判据只在「USB 那一侧通着」时武装。 + 也正是它让「断完之后不再每 20 ms 刷一条事件」成立。 */ +static void test_backfeed_is_only_judged_while_vbus_is_on(void) +{ + power_up(1); /* VBUS 没开 */ + watch_reset(); + inject(1, NOMINAL_MV, -200000); + beats(50); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); /* 一次都没去写刀片 */ +} + +/* 断完之后不会每 20 ms 再刷一条事件、也不会每 20 ms 再发一次 I²C 写。 + 会刷的话,事件日志那块 flash 每秒被写 50 次 —— 判据「一直在响」和判据 + 「不灵」一样糟,而前者还会把真正的那条事件淹掉。 */ +static void test_a_backfeed_does_not_refire_every_cycle(void) +{ + power_up(1); + vbus_on(1); + inject(1, NOMINAL_MV, -60000); + beat(); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + + watch_reset(); + beats(100); /* 2 秒,故障一直在 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); +} + +/* 顶高:VBUS 期间电压被抬过设定值 +100 mV,同样立刻断 VBUS(设计文档 §15.2)。 + 然后 20 ms 后 SR-5 才决定要不要把这一路也关掉 —— 两条判据是分层的: + 先撤掉能量来源,再决定要不要关电。 */ +static void test_being_pushed_up_drops_vbus_first_then_sr5_cuts_the_rail(void) +{ + power_up(1); + vbus_on(1); + inject(1, NOMINAL_MV + 150, 1000000); + + beat(); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); /* 第一圈:VBUS 没了 */ + TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 电还开着 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_PUSHED_UP, g_ev[0].type); + + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); /* 第二圈:还是高,SR-5 关路 */ + TEST_ASSERT_EQUAL_INT(2, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[1].type); +} + +/* ⭐ 断 VBUS 之前要打断 EDL 序列 —— 三个序列里只有它会写 `VBUS_EN`, + 不打断的话下一拍它就把 VBUS 又抬回来了(`EDL_EN=1 → VBUS_EN=1 → 等枚举`)。 */ +static void test_a_running_edl_sequence_cannot_bring_vbus_back(void) +{ + power_up(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_edl(1)); + beats(2); /* 短接 10 ms 之后 VBUS_EN 被抬起来,进入等枚举 */ + TEST_ASSERT_EQUAL_INT(1, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(ACT_ST_EDL_ENUM, (int)t_actuator_state(1)); + + inject(1, NOMINAL_MV, -60000); + beat(); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); + + beats(200); /* 4 秒 —— 枚举窗口 3 s 早过了,VBUS 不许自己回来 */ + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +/* 反灌不去打断跟 VBUS 无关的序列(按键只碰 P0.0~2,硬重启只碰 `EFUSE_EN`)—— + 「出了事就把这一路上所有事都停掉」比看上去危险:那会把一次正在进行的 + 救砖重启打断,而重启和反灌是两码事。 */ +static void test_a_backfeed_does_not_abort_an_unrelated_sequence(void) +{ + power_up(1); + vbus_on(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_VUP, ACT_KEY_MS_ON)); + + inject(1, NOMINAL_MV, -60000); + beat(); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_TRUE(t_actuator_busy(1)); + TEST_ASSERT_EQUAL_INT(ACT_ST_KEY_HOLD, (int)t_actuator_state(1)); + /* 键还按着 —— 断 VBUS 那一次写不许把同一个字节里的键位顺手清掉。 */ + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_VUP)); +} + +/* 五路各判各的:CH5 反灌不许动 CH2 的 VBUS。 */ +static void test_backfeed_is_per_rail(void) +{ + power_up(2); + power_up(5); + vbus_on(2); + vbus_on(5); + + inject(5, NOMINAL_MV, -60000); + beats(5); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(5)); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(2)); + TEST_ASSERT_EQUAL_INT(1, en_level(5)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(5, g_ev[0].rail); +} + +/* ---- ⭐ R2 新增判据之一:`PRSNT#`(刀片不在位) -------------------------- */ + +/* 刀片被拔了而这一路还开着 ⇒ 立刻关路 + 记 `SAFETY_EV_SLOT_EMPTY`。 + 空槽带电 = 金手指裸露带 12V,手一碰就是短路,而电压电流读出来什么异常都没有 + (那一路的 INA226 跟着刀片一起走了,快照里留的是上一圈的旧值)。 */ +static void test_an_empty_slot_that_is_powered_gets_cut_at_once(void) +{ + power_up(2); + TEST_ASSERT_TRUE(g_snap.present[1]); + + sim_gpio_drive(rail_cfg(2)->prsnt_n, 1); /* 刀片拔走:上拉把 PRSNT# 拉高 */ + beat(); + + TEST_ASSERT_FALSE(g_snap.present[1]); + TEST_ASSERT_EQUAL_INT(0, en_level(2)); /* 不等确认窗口,这一圈就断 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_SLOT_EMPTY, g_ev[0].type); + TEST_ASSERT_EQUAL_UINT8(2, g_ev[0].rail); + + /* 别的四路一根线都没动(`PRSNT#` 是一路一根,不是一根线或)。 */ + for (int rail = 1; rail <= N; rail++) { + if (rail != 2) { + TEST_ASSERT_TRUE(g_snap.present[rail - 1]); + } + } + + /* 断完之后不再刷第二条 —— 判据只在「本来是开着的」时候动作。 */ + beats(100); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +/* ⭐ 专门的一条:**五个槽全空、五路全关**(也就是这块板子刚上电、还没插刀片的 + 样子)—— 判据一条事件都不许出。 + 少了「本来是开着的」这个前提,现象是开机就以每秒 250 条的速度刷 + `SLOT_EMPTY`,evlog 那块 flash 几分钟就写穿,而且真出事那条会被淹掉。 */ +/* ⭐ `PRSNT#` 接触不良(刀片还在、PCA9555 还应答)时,VBUS 也要断掉。 + + 这条和上面那条的区别是**刀片在不在**: + · 真拔走了 —— PCA9555 跟着走了,断 VBUS 那一步必然 NAK,无所谓; + · 金手指 B31 虚接 / `PRSNT#` 接触不良 —— 刀片还在、总线还通, + 这时只关 `EFUSE_EN` 会留下「直供断了、USB 还灌着」的半开状态: + 手机靠 VBUS 吊着命,遥测上这一路电压是 0、电流也接近 0,看着像"已经关了"。 + 所以判据要两样都断。⚠️ 顺序是**先 eFuse(GPIO,必成)再 VBUS(I²C,可能失败)**: + 断电优先,不能为了一次可能等锁的 I²C 事务把关电推后。 */ +static void test_an_empty_slot_also_drops_vbus_when_the_blade_still_answers(void) +{ + power_up(3); + vbus_on(3); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(3)); + + /* 只把 `PRSNT#` 拉高 —— 刀片本身(假 PCA9555)还在总线上好好应答着。 */ + sim_gpio_drive(rail_cfg(3)->prsnt_n, 1); + beat(); + + TEST_ASSERT_FALSE(g_snap.present[2]); + TEST_ASSERT_EQUAL_INT(0, en_level(3)); /* 直供断了 */ + TEST_ASSERT_EQUAL_INT(0, vbus_pin(3)); /* ⭐ VBUS 也断了,不留半开状态 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_SLOT_EMPTY, g_ev[0].type); + + /* 别的四路的 VBUS 一个都没被顺手带下去。 */ + for (int rail = 1; rail <= N; rail++) { + if (rail != 3) { + TEST_ASSERT_EQUAL_INT(0, vbus_pin(rail)); /* 本来就没开,确认没被乱开 */ + } + } + + /* 断完不再刷第二条。 */ + beats(50); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +static void test_empty_slots_that_are_off_never_fire(void) +{ + for (int rail = 1; rail <= N; rail++) { + sim_gpio_drive(rail_cfg(rail)->prsnt_n, 1); + } + watch_reset(); + beats(200); /* 4 秒 */ + + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_FALSE(g_snap.present[rail - 1]); + TEST_ASSERT_EQUAL_UINT32(1, en_writes(rail)); /* 只有 rail_en_init() 那一次 */ + TEST_ASSERT_EQUAL_UINT32(0, g_w[rail - 1].n); + } +} + +/* 插回去之后判据自己解除 —— 空槽判据读的是**这一圈**的引脚,不是一个一去不返的闩。 + 不重读的现象是「热插拔之后这一路再也开不起来」,而 `PRSNT#` 上量出来是好的。 */ +static void test_a_slot_that_comes_back_can_be_powered_again(void) +{ + power_up(1); + sim_gpio_drive(rail_cfg(1)->prsnt_n, 1); + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + + sim_gpio_drive(rail_cfg(1)->prsnt_n, 0); /* 刀片插回来了 */ + g_n_ev = 0; + power_up(1); + beats(20); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + +/* ---- ⭐ R2 新增判据之二:mux 失联(两段式) ------------------------------ */ + +/* TCA9548A 是单点:它一挂,五路的电压电流**全部看不见**,SR-5 的整条监控链断了。 + 这时「继续供电但看不见」比「停机」危险 —— 所以判据是五路全断。 + + ⭐ 但**必须先脉冲一次 `MUX_RST#` 再判**:一次总线毛刺就停五台手机,代价太大。 + 这条用例把两段拆开看:第一圈只复位、电一路都不许断;第二圈才全断。 + 少了复位那一步的实现,第一圈就会把五台机器全停掉 —— 而现象只是「偶尔全体掉电」。 */ +static void test_a_lost_mux_resets_once_before_it_cuts_all_five(void) +{ + for (int rail = 1; rail <= N; rail++) { + power_up(rail); + } + const uint32_t rst0 = sim_i2c_mux_resets(); + + sim_i2c_set_present(TCA9548A_ADDR, false); /* mux 失联 */ + + /* 第一圈:连续选不中够数了,**先复位**。五路电一路都不许断。 */ + beat(); + TEST_ASSERT_TRUE(i2cbus_select_fail_streak() >= (uint32_t)T_SAFETY_MUX_FAIL_N); + TEST_ASSERT_EQUAL_UINT32(rst0 + 1u, sim_i2c_mux_resets()); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_recovers); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.mux_trips); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(1, en_level(rail)); + } + + /* 第二圈:复位过了还是选不中 ⇒ 认定失联,五路全断,记一条整板级事件。 */ + beat(); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_MUX_LOST, g_ev[0].type); + TEST_ASSERT_EQUAL_UINT8(0, g_ev[0].rail); /* ⚠️ 整板级事件,rail 是 0 */ + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(0, en_level(rail)); + TEST_ASSERT_FALSE(rail_efuse_is_on(rail)); + } + + /* 第三圈往后:既不再复位,也不再刷事件。 + 两个数字里任何一个每 20 ms 涨一次,都是「判据一直在响」—— 五路已经断了, + 再断一次没有任何用处,而 evlog 那块 flash 一分钟就被写 3000 次。 */ + beats(100); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_recovers); + TEST_ASSERT_EQUAL_UINT32(rst0 + 1u, sim_i2c_mux_resets()); +} + +/* mux 回来之后判据重新武装:下一次失联还要老老实实走「先复位、再全断」两段。 + 不重新武装的现象是「第二次失联直接全断,没有那一次挽救性的复位」。 */ +static void test_the_mux_rule_rearms_after_the_bus_comes_back(void) +{ + power_up(1); + sim_i2c_set_present(TCA9548A_ADDR, false); + beats(2); /* 复位一次 → 全断一次 */ + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + + sim_i2c_set_present(TCA9548A_ADDR, true); /* 总线回来了 */ + beats(3); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); /* 回来这件事本身不是事件 */ + + /* 再挂一次:必须还是「先复位,下一圈才全断」。 */ + sim_i2c_set_present(TCA9548A_ADDR, false); + beat(); + TEST_ASSERT_EQUAL_UINT32(2, g_snap.mux_recovers); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); /* 这一圈还不许断 */ + beat(); + TEST_ASSERT_EQUAL_UINT32(2, g_snap.mux_trips); + TEST_ASSERT_EQUAL_INT(2, g_n_ev); +} + +/* 一次毛刺不许停五台机器:连续失败 N−1 次之后又选中了,判据一动不动。 + 判据要是拿「累计失败次数」而不是「连续」去判,跑上几个小时的板子一定会踩到 —— + 而现象是「半夜五台机器一起掉电,早上看日志只有一条 mux 失联」。 */ +static void test_a_short_glitch_does_not_stop_five_phones(void) +{ + for (int rail = 1; rail <= N; rail++) { + power_up(rail); + } + const uint32_t rst0 = sim_i2c_mux_resets(); + + for (int i = 0; i < 20; i++) { + /* 每一圈都毛刺 N−1 次 —— 加起来远超 N,但一次也没有「连续 N 次」。 */ + sim_i2c_inject_nak(TCA9548A_ADDR, T_SAFETY_MUX_FAIL_N - 1); + beat(); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); + } + TEST_ASSERT_TRUE(i2cbus_select_fails() >= 20u * (uint32_t)(T_SAFETY_MUX_FAIL_N - 1)); + TEST_ASSERT_EQUAL_UINT32(rst0, sim_i2c_mux_resets()); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.mux_trips); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(1, en_level(rail)); + } +} + +/* ---- 判据不许改变巡检的形状 ---------------------------------------------- */ + +/* 判据本身一条 I²C 事务也不许发:它要的三个量(设定值、`EFUSE_EN` 的状态、 + 正在跑的序列)全部有影子可查。多读一次就把计划 §5.5 的 12.4 % 顶穿。 + + ⚠️ R2 里**断 VBUS 本身是一次 I²C 写**(`VBUS_EN` 在 PCA9555 上),这是设计, + 不是「判据多读了东西」。所以这里分开数:起表那一圈严格等于一圈的名义事务数, + 断电那一圈恰好多**一次**(就那一次写),不许多出第二次。 */ +static void test_the_rules_do_not_add_any_i2c_traffic(void) +{ + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); + + beat(); /* 起表那一圈:判据干了活(起表),但一个字节也没多发 */ + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); + TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us); + + beat(); /* 断电那一圈:只多出「断 VBUS」那一次写(2 字节) */ + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns() + 1u, g_snap.txns); + TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us() + + (uint32_t)hal_i2c_bits_to_us(hal_i2c_txn_bits(2, 0)), + g_snap.bus_us); + + /* 关掉之后回到名义值 —— 判据不会在后面的圈里继续加事务。 */ + beat(); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); +} + +/* 加锁纪律:整圈(含判据、含断 VBUS 那一次写、含锁外关 eFuse)违规为 0。 */ +static void test_the_whole_cycle_keeps_the_lock_discipline(void) +{ + power_up(1); + vbus_on(1); + sim_lock_require(HAL_LOCK_I2C, true); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON)); + inject(1, NOMINAL_MV + 150, 1000000); + beats(5); + + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); /* 出门放锁 */ +} + +/* 上一条不是句空话:不拿锁直接读一片 INA226,违规必须 +1。 + 检查本身失灵的话,上一条会永远绿 —— 而「加锁纪律测试永远绿」正是最坏的情形。 */ +static void test_the_lock_check_itself_can_fail(void) +{ + sim_lock_require(HAL_LOCK_I2C, true); + const uint32_t before = sim_lock_violations(HAL_LOCK_I2C); + ina226_vi_t vi; + (void)ina226_read_vi(rail_cfg(1)->ina_addr, &vi); + TEST_ASSERT_EQUAL_UINT32(2, sim_lock_violations(HAL_LOCK_I2C) - before); /* 两次事务 */ +} + +/* 拿不到总线锁的那一圈整圈作废,判据一条都不许跑 —— 那一圈的值是上一圈的。 + 跑的话,「总线忙」就会被当成「电压没变」,而实际上根本没读。 */ +static void test_a_skipped_cycle_runs_no_rules(void) +{ + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); + beat(); /* 起表 */ + + sim_lock_set_task(1); /* 假装 t_actuator 占着总线 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + sim_lock_set_task(0); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 满 20 ms 了,但这一圈没数 */ + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped); + + sim_lock_set_task(1); + hal_lock_give(HAL_LOCK_I2C); + sim_lock_set_task(0); + beat(); /* 总线回来了,这一圈才断电 */ + TEST_ASSERT_EQUAL_INT(0, en_level(1)); +} + +/* 没接事件出口也照样断电(A11 还没来,日志接不上不是不断电的理由)。 */ +static void test_the_cut_happens_even_with_no_event_sink(void) +{ + t_safety_set_event_sink(NULL, NULL); + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); + beats(2); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.events); /* 快照里仍然记着 */ + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_snap.last.type); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_thresholds_match_the_documents); + + RUN_TEST(test_overvoltage_cuts_the_rail_within_one_period_and_logs_it); + RUN_TEST(test_the_cut_lands_exactly_one_hold_window_after_first_sight); + RUN_TEST(test_the_cut_is_never_late_because_of_sub_millisecond_jitter); + RUN_TEST(test_undervoltage_cuts_the_rail_too); + RUN_TEST(test_a_one_cycle_glitch_does_not_cut_anything); + RUN_TEST(test_exactly_one_hundred_millivolts_is_still_in_range); + RUN_TEST(test_the_reference_is_the_setpoint_not_a_constant); + + RUN_TEST(test_a_rail_that_is_off_is_never_judged); + RUN_TEST(test_the_soft_start_window_is_not_judged); + RUN_TEST(test_a_blind_rail_keeps_its_power); + + RUN_TEST(test_the_cut_drops_vbus_inside_the_lock_on_this_rails_channel); + RUN_TEST(test_only_the_offending_rail_is_touched); + RUN_TEST(test_a_cut_rail_does_not_keep_firing); + RUN_TEST(test_a_rail_that_comes_back_starts_its_hold_window_over); + RUN_TEST(test_a_running_hard_reset_cannot_bring_the_rail_back); + RUN_TEST(test_a_cut_releases_whatever_key_was_held); + + RUN_TEST(test_reverse_current_drops_vbus); + RUN_TEST(test_reverse_current_is_not_debounced); + RUN_TEST(test_a_small_negative_current_is_not_a_backfeed); + RUN_TEST(test_backfeed_is_only_judged_while_vbus_is_on); + RUN_TEST(test_a_backfeed_does_not_refire_every_cycle); + RUN_TEST(test_being_pushed_up_drops_vbus_first_then_sr5_cuts_the_rail); + RUN_TEST(test_a_running_edl_sequence_cannot_bring_vbus_back); + RUN_TEST(test_a_backfeed_does_not_abort_an_unrelated_sequence); + RUN_TEST(test_backfeed_is_per_rail); + + RUN_TEST(test_an_empty_slot_that_is_powered_gets_cut_at_once); + RUN_TEST(test_an_empty_slot_also_drops_vbus_when_the_blade_still_answers); + RUN_TEST(test_empty_slots_that_are_off_never_fire); + RUN_TEST(test_a_slot_that_comes_back_can_be_powered_again); + + RUN_TEST(test_a_lost_mux_resets_once_before_it_cuts_all_five); + RUN_TEST(test_the_mux_rule_rearms_after_the_bus_comes_back); + RUN_TEST(test_a_short_glitch_does_not_stop_five_phones); + + RUN_TEST(test_the_rules_do_not_add_any_i2c_traffic); + RUN_TEST(test_the_whole_cycle_keeps_the_lock_discipline); + RUN_TEST(test_the_lock_check_itself_can_fail); + RUN_TEST(test_a_skipped_cycle_runs_no_rules); + RUN_TEST(test_the_cut_happens_even_with_no_event_sink); + return UNITY_END(); +} diff --git a/firmware/test/test_t_thermal.c b/firmware/test/test_t_thermal.c new file mode 100644 index 0000000..e757155 --- /dev/null +++ b/firmware/test/test_t_thermal.c @@ -0,0 +1,466 @@ +/* A16 / R-6:风扇闭环与 TACH 采样窗口(需求 FR-10 / FR-13 / TR-6,计划 §5.10)。 + + 这一包的判据全部长在三件**写错了不会报错**的事情上: + + ① `FAN_EN` 复位态是「不转」。忘了主动拉高 ⇒ 温度闭环、PWM、遥测全部正常, + 只是一点风都没有。功能测试看不出来,因为没有任何一个返回值会变。 + ② PWM 经 Q6 反相。写反了 ⇒ **越热转得越慢**,而遥测里「占空比随温度上升」 + 这句话仍然是对的 —— 看仪表盘的人永远发现不了。 + ③ TACH 在 PWM 关断期间没有脉冲(TR-6)。按「这一圈没数到脉冲就是停转」写 + ⇒ 低占空比下**稳定误报**,大家很快学会无视风扇告警,等到真停转那天 + 告警照常发出来、照常没人看。 + + 所以下面的断言一律打在**假风扇**这个可观测面上(转速、边沿数、引脚电平), + 不去对着模块自己的影子变量断言 —— 那是自己证明自己。 */ +#include + +#include "board_pins.h" +#include "evlog.h" +#include "hal_pwm.h" +#include "hal_time.h" +#include "sim_hal.h" +#include "t_thermal.h" +#include "tasks.h" +#include "unity.h" + +/* 假风扇按设计文档 §22.6 的数:满速 11000 RPM、0 % PWM 仍转 3300 RPM、2 PPR。 */ +#define FAN_RPM_FULL 11000u +#define FAN_RPM_MIN 3300u + +/* 一圈 1 s —— 和任务表里 `t_thermal` 的周期一致(tasks.c 定死,本包不改它)。 */ +static uint32_t period_ms(void) { return task_desc(TASK_THERMAL)->period_ms; } + +/* ---- 喂进来的两样东西(温度与 TACH 计数器,见 t_thermal.h 的「外部喂进来」)---- */ + +static int32_t s_temp_mc[T_THERMAL_NTC_COUNT]; +static bool s_temp_ok[T_THERMAL_NTC_COUNT]; + +static bool temp_src(void *ctx, int idx, int32_t *mc) +{ + (void)ctx; + if (!s_temp_ok[idx]) { + return false; + } + *mc = s_temp_mc[idx]; + return true; +} + +/* TACH 直接取假风扇的累计边沿数 —— 板上这个数来自 PCNT。 */ +static bool tach_src(void *ctx, int idx, uint32_t *edges) +{ + (void)ctx; + *edges = sim_fan_edges(idx); + return true; +} + +/* 下一条落盘记录会拿到的序号(和 test_evlog.c 一个写法)。 */ +static uint32_t next_seq(void) +{ + evlog_stats_t st; + evlog_stats(&st); + return st.next_seq; +} + +static void set_temp(int32_t mc) +{ + for (int i = 0; i < T_THERMAL_NTC_COUNT; i++) { + s_temp_mc[i] = mc; + s_temp_ok[i] = true; + } +} + +static void fans_up(void) +{ + for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) { + sim_fan_attach(i, T_THERMAL_PWM_CH, BOARD_PIN_FAN_EN, + FAN_RPM_FULL, FAN_RPM_MIN, T_THERMAL_TACH_PPR); + /* **关断期不吐脉冲** —— TR-6 说的就是这种风扇,也是最坏情况。 */ + sim_fan_set_gated(i, true); + } +} + +void setUp(void) +{ + sim_hal_reset(); + fans_up(); + set_temp(25000); /* 25 °C:常温开机 */ +} +void tearDown(void) {} + +/* 跑 n 圈,每圈把虚拟时钟往前拨一个周期。真板上是 `sched_periodic_wait()` 睡过去的。 */ +static void run(thermal_snap_t *s, uint32_t n) +{ + for (uint32_t i = 0; i < n; i++) { + sim_time_advance_ms(period_ms()); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_step(s)); + } +} + +/* 起模块 + 跑过启动宽限(`T_THERMAL_SPINUP_MS`),让停转判据开始工作。 */ +static void boot(thermal_snap_t *s) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_init(s, temp_src, tach_src, NULL)); + run(s, T_THERMAL_SPINUP_MS / 1000u + 1u); +} + +/* ---- ① 上电就得把 12V 打开 ---------------------------------------------- */ + +/* `FAN_EN`(IO48)复位时高阻 + R749 下拉 ⇒ 12V 是断的。固件不主动拉高就一直没风。 + ⚠️ 光断言引脚电平还不够 —— 引脚为高但顺序写错(先使能供电、后配 PWM)在真板上 + 也是一段全速乱转,所以这里还断言**假风扇真的转起来了**。 + 写错了会怎样:删掉 `t_thermal_init()` 里最后那行 `hal_gpio_write(FAN_EN, 1)`, + 整块板子的行为只有一处变化 —— 不散热。任何看返回值的测试都是绿的。 */ +static void test_init_drives_fan_en_high_and_fans_actually_spin(void) +{ + /* 起之前:谁也没配过这个脚,更没人驱动它。 */ + TEST_ASSERT_EQUAL_INT_MESSAGE(-1, sim_gpio_level(BOARD_PIN_FAN_EN), "起之前不该有人动 FAN_EN"); + TEST_ASSERT_EQUAL_UINT32(0, sim_fan_rpm(0)); + + thermal_snap_t s; + TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_init(&s, temp_src, tach_src, NULL)); + + TEST_ASSERT_EQUAL_INT_MESSAGE(1, sim_gpio_level(BOARD_PIN_FAN_EN), "FAN_EN 没被拉高 = 没风"); + /* 先写 0 再写 1(§22.6 的上电顺序:先关、配好 PWM、再给电)。 */ + TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, sim_gpio_writes(BOARD_PIN_FAN_EN), "上电顺序不是「先 0 后 1」"); + TEST_ASSERT_EQUAL_INT(BOARD_PIN_FAN_PWM, sim_pwm_pin(T_THERMAL_PWM_CH)); + TEST_ASSERT_EQUAL_UINT32(T_THERMAL_PWM_HZ, sim_pwm_freq(T_THERMAL_PWM_CH)); + + /* 两只都要转 —— 一路 PWM 喂两个座(J26/J27),漏掉一只就是半个风道。 */ + TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(0) > 0, "进风扇没转"); + TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(1) > 0, "排风扇没转"); +} + +/* ---- ② PWM 反相:越热必须越快 ------------------------------------------- */ + +/* Q6 是低边管,栅极高 = 把风扇的 PWM 线拉低 ⇒ `D_gpio = 1 − D_fan`。 + ⚠️ 断言分两层:写进 hal_pwm 的值是反的(接口层),**假风扇的转速跟着温度涨** + (物理层)。只断言第一层的话,把 `gpio_duty_of()` 和调用处一起改反仍然是绿的。 + 写错了会怎样:温度越高风扇越慢,而遥测里 `duty` 照常单调上升 —— 看不出来。 */ +static void test_hotter_never_means_slower(void) +{ + thermal_snap_t s; + boot(&s); + + uint16_t prev_duty = 0; + uint32_t prev_rpm = 0; + for (int32_t t = 20000; t <= 90000; t += 5000) { + set_temp(t); + run(&s, 1); + + TEST_ASSERT_TRUE_MESSAGE(s.duty >= prev_duty, "温度升了,占空比反而降"); + TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(0) >= prev_rpm, "温度升了,风扇反而慢了(PWM 反相写反了)"); + /* 接口层:真写进去的是反相后的值。 */ + TEST_ASSERT_EQUAL_INT_MESSAGE((int)(1000u - s.duty), sim_pwm_permille(T_THERMAL_PWM_CH), + "写进 hal_pwm 的不是 1000 − 风扇侧占空比"); + prev_duty = s.duty; + prev_rpm = sim_fan_rpm(0); + } + TEST_ASSERT_EQUAL_UINT16_MESSAGE(1000, s.duty, "90 °C 还没满速"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(FAN_RPM_FULL, sim_fan_rpm(0), "满速时假风扇没到额定转速"); +} + +/* 纯函数那一层:曲线形状 + 两个端点。分段线性写成整数除法很容易在端点差 1, + 而端点正是「安静」和「救命」两件事各自的极限。 */ +static void test_duty_curve_is_monotonic_and_bounded(void) +{ + TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, t_thermal_duty_for(0, true)); + TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, t_thermal_duty_for(T_THERMAL_TEMP_LO_MC, true)); + TEST_ASSERT_EQUAL_UINT16(1000, t_thermal_duty_for(T_THERMAL_TEMP_HI_MC, true)); + TEST_ASSERT_EQUAL_UINT16(1000, t_thermal_duty_for(200000, true)); + + uint16_t prev = 0; + for (int32_t t = -10000; t <= 120000; t += 250) { + const uint16_t d = t_thermal_duty_for(t, true); + TEST_ASSERT_TRUE_MESSAGE(d >= prev, "占空比曲线不单调"); + TEST_ASSERT_TRUE(d <= 1000); + prev = d; + } +} + +/* 温度读不到 ⇒ **满速**。需求 §10.10 对「温度读不到」给的处置就是这一条: + 不要按「读不到温度 = 不热」处理。 + 写错了会怎样:NTC 开路(一根线的事)之后风扇按 0 °C 跑最低速,机器照常工作, + 直到夏天某个下午集体降频 —— 而日志里温度那一栏很干净,因为它根本没有值。 */ +static void test_unreadable_temperature_means_full_speed(void) +{ + thermal_snap_t s; + boot(&s); + set_temp(25000); + run(&s, 1); + TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); + + s_temp_ok[0] = false; + s_temp_ok[1] = false; + run(&s, 1); + TEST_ASSERT_FALSE(s.temp_valid); + TEST_ASSERT_EQUAL_UINT16_MESSAGE(1000, s.duty, "温度读不到时没有转满速"); + TEST_ASSERT_EQUAL_UINT32(1, s.temp_errors); +} + +/* 两路 NTC 取**高**的那一个:一路在电感区、一路在 MOS 区,凉的那一路完全可能是 + 「那半边没在用」。取平均的现象是 90 °C 的热点被 30 °C 的凉点平成 60 °C —— 一个 + 哪儿都不存在、但看着很健康的数,而且平均值**永远不会**触发降载。 */ +static void test_two_ntc_take_the_hotter_one(void) +{ + thermal_snap_t s; + boot(&s); + s_temp_ok[0] = true; s_temp_mc[0] = 30000; + s_temp_ok[1] = true; s_temp_mc[1] = 90000; + run(&s, 1); + TEST_ASSERT_EQUAL_INT32_MESSAGE(90000, s.temp_max_mc, "两路 NTC 没取高的那一个"); + TEST_ASSERT_EQUAL_UINT16(1000, s.duty); +} + +/* ---- ③ TR-6:采样窗口 ---------------------------------------------------- */ + +/* **本包的核心用例。** 风扇转得好好的,只是占空比在下限(30 %),而这种风扇在 + PWM 关断期间一个脉冲都没有 ⇒ 按「一个固定墙钟窗口里脉冲数不够就是停转」写的 + 实现会**稳定误报**。 + + 本用例先证明这个坑是真的(低占空比下每秒脉冲数掉到满速的约 1/6), + 再证明我们没掉进去(跑 60 s 一次都没判停转),最后证明我们不是靠「干脆永远不判」 + 混过去的(窗口照常在结算)—— 把误报换成漏报是这条判据最容易出的「修复」。 */ +static void test_low_duty_windows_are_not_mistaken_for_a_stall(void) +{ + thermal_snap_t s; + boot(&s); + set_temp(25000); /* 凉 ⇒ 占空比压到下限 */ + run(&s, 1); + TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); + + /* 坑本身:同一只转着的风扇,每秒脉冲数只有满速的一小截。 + 满速是 11000 RPM × 2 PPR / 60 = 366 脉冲/s;按它的一半(183)做门限的话, + 下面这 60 圈**每一圈都会**被判成停转。 */ + const uint32_t naive_threshold = FAN_RPM_FULL * T_THERMAL_TACH_PPR / 60u / 2u; + uint32_t worst_per_second = 0xFFFFFFFFu; + const uint32_t edges_0 = sim_fan_edges(0); + + for (int i = 0; i < 60; i++) { + const uint32_t before = sim_fan_edges(0); + run(&s, 1); + const uint32_t d = sim_fan_edges(0) - before; + if (d < worst_per_second) { + worst_per_second = d; + } + TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(0) > 0, "假风扇自己停了,这个用例就白测了"); + TEST_ASSERT_FALSE_MESSAGE(s.fan[0].stalled, "低占空比被误判成停转(TR-6)"); + TEST_ASSERT_FALSE_MESSAGE(s.fan[1].stalled, "低占空比被误判成停转(TR-6)"); + } + TEST_ASSERT_TRUE_MESSAGE(worst_per_second < naive_threshold, + "假风扇没有重现 TR-6 的场景(关断期照样吐脉冲),这个用例失去意义"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].stalls, "误报了停转"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[1].stalls, "误报了停转"); + + /* 没有靠「永远不判」混过去:60 s 里窗口按导通时间结算了好几个,转速也出来了。 */ + TEST_ASSERT_TRUE_MESSAGE(s.fan[0].windows >= 3, "窗口根本没结算过 —— 把误报换成了漏报"); + TEST_ASSERT_TRUE_MESSAGE(s.fan[0].rpm_valid, "没算出转速"); + TEST_ASSERT_TRUE_MESSAGE(s.fan[0].rpm > 0, "转速算成了 0"); + + /* ---- 机制本身:窗口**跟着占空比自动拉长了** ---- + 这两条才是判据成立的理由。固定墙钟窗口(比如「每圈判一次」)在这 60 s 里会 + 结算约 57 个窗口、每个窗口只有 56 个脉冲 —— 离门限只剩 14 倍余量,而占空比 + 再低一截余量就没了,那时它才开始误报。**误报是有门槛的**:按今天这个占空比 + 测不出来,等将来限功率把风扇压到 5 % 才出事,那时没人会想到是这里。 + 按导通时间拉长窗口之后,「窗口里该有多少脉冲」与占空比无关,余量恒定。 */ + const uint32_t settled = s.fan[0].windows; + const uint32_t counted = sim_fan_edges(0) - edges_0; + TEST_ASSERT_TRUE_MESSAGE(settled <= 12, "窗口没有跟着占空比拉长(还是一圈判一次)"); + TEST_ASSERT_TRUE_MESSAGE(counted / settled >= 50u * T_THERMAL_TACH_MIN_EDGES, + "每个窗口的脉冲数离门限太近 —— 占空比再低一截就要误报了"); +} + +/* 真停转(PWM 满速 + 风扇一个脉冲也不吐)必须判得出来,而且要记一条事件。 + 写错了会怎样:风扇卡死/线脱落之后板子一声不吭地继续满载跑, + 现象是「某台机器偶尔重启」,查到风扇上要花几天。 */ +static void test_a_real_stall_is_caught_and_logged(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + evlog_flush(0); /* 把 BOOT 那条落掉,后面的序号就是本用例自己的 */ + const uint32_t seq = next_seq(); + + thermal_snap_t s; + boot(&s); + /* 70 °C:满速(导通时间攒得最快,判据也最快),但**够不着降载门限** —— + 本用例数的是日志条数,混进一条降载事件就分不清是谁记的了。 */ + set_temp(70000); + run(&s, 2); + TEST_ASSERT_EQUAL_UINT16(1000, s.duty); + + sim_fan_set_stalled(0, true); + sim_fan_set_stalled(1, true); + run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u); + + TEST_ASSERT_TRUE_MESSAGE(s.fan[0].stalled, "真停转没判出来"); + TEST_ASSERT_TRUE_MESSAGE(s.fan[1].stalled, "真停转没判出来"); + TEST_ASSERT_EQUAL_UINT32(1, s.fan[0].stalls); + + /* 持续停着不会每个窗口再记一条 —— 256 条的 RAM 缓冲经不起每 3 s 一条, + 真正救命的过压/反灌事件会被挤掉,而日志看上去「记录得非常完整」。 */ + run(&s, 20); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, s.fan[0].stalls, "停转事件在刷屏"); + + TEST_ASSERT_EQUAL_UINT32(2, evlog_flush(0)); /* 两只风扇各一条 */ + evlog_rec_t r[2]; + memset(r, 0, sizeof(r)); + TEST_ASSERT_EQUAL_UINT32(2, evlog_read(seq, r, 2)); + TEST_ASSERT_EQUAL_UINT8(LOG_EV_FAN_STALL, r[0].type); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, r[0].rail, "风扇是整板级事件,rail 应当是 0"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, r[0].arg, "第一条应当是进风扇(arg = 风扇号)"); + TEST_ASSERT_EQUAL_UINT8(LOG_EV_FAN_STALL, r[1].type); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(1, r[1].arg, "第二条应当是排风扇"); +} + +/* 两路 TACH 是**独立**的两套(Q9/Q10 各一路)。一只停了不能把另一只也报停 —— + 报串了的现象是「两只一起坏」,于是维修先去查共用的 12V/PWM,而真相是某一只的 + 线掉了。这在真板上是最容易出现的单点故障(KF2510 插头)。 */ +static void test_two_tach_channels_do_not_bleed_into_each_other(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + evlog_flush(0); + + thermal_snap_t s; + boot(&s); + set_temp(70000); /* 满速但不降载,理由同上一个用例 */ + run(&s, 2); + + sim_fan_set_stalled(0, true); /* 只停进风那只 */ + run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u); + + TEST_ASSERT_TRUE_MESSAGE(s.fan[0].stalled, "停掉的那只没报"); + TEST_ASSERT_FALSE_MESSAGE(s.fan[1].stalled, "另一只被连坐了"); + TEST_ASSERT_EQUAL_UINT32(1, s.fan[0].stalls); + TEST_ASSERT_EQUAL_UINT32(0, s.fan[1].stalls); + TEST_ASSERT_TRUE_MESSAGE(s.fan[1].rpm > 0, "还在转的那只转速被算成 0"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, evlog_flush(0), "事件条数不是 1"); +} + +/* 启动宽限:`FAN_EN` 刚拉高的头几秒风扇还在起转,这时候判停转就是**每次上电必报**。 + 报多了的代价不是噪声,是这条告警从此没人看 —— 等到真停转那天它照常发出来、 + 照常被划过去。 + + ⚠️ 宽限时长必须盖得住「播种一圈 + 一个完整判据窗口」,否则第一个窗口在宽限 + 到期之后才结算,宽限判断成了死代码(见 `T_THERMAL_SPINUP_MS` 上的警告)。 + 本用例就是钉这件事的:把宽限判断整段删掉,它必须变红。 */ +static void test_spinup_grace_suppresses_the_first_windows(void) +{ + thermal_snap_t s; + TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_init(&s, temp_src, tach_src, NULL)); + set_temp(70000); /* 满速:窗口攒得最快,也就最容易在宽限内结算出一个假停转 */ + + /* 起转期间一个脉冲也没有(真风扇就是这样:12V 刚通,转子还没动)。 */ + sim_fan_set_stalled(0, true); + sim_fan_set_stalled(1, true); + run(&s, T_THERMAL_SPINUP_MS / 1000u - 1u); /* 还在宽限内 */ + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].stalls, "启动宽限内就报了停转"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[1].stalls, "启动宽限内就报了停转"); + + sim_fan_set_stalled(0, false); + sim_fan_set_stalled(1, false); + run(&s, 10); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].stalls, "起转完成后仍然报了停转"); + TEST_ASSERT_FALSE(s.fan[0].stalled); + /* 宽限过后判据要**真的在工作**(不是被宽限永久关掉了)。 */ + TEST_ASSERT_TRUE_MESSAGE(s.fan[0].windows > 0, "宽限之后判据没恢复"); +} + +/* ---- ④ 过温降载:记一条,并且只记一条 ----------------------------------- */ + +/* 温度在门限上抖是常态(±0.5 °C 的 ADC 噪声足够)。没有迟滞的现象是每秒一条 + 降载事件,4 分多钟就把 256 条的 RAM 环形缓冲刷满一圈,把过压/反灌那些真正 + 救命的事件挤掉 —— 而 `evlog` 只会在下一次落盘时留下一条 LOST,没人会注意。 */ +static void test_derate_is_edge_triggered_with_hysteresis(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + evlog_flush(0); + const uint32_t seq = next_seq(); + + thermal_snap_t s; + boot(&s); + set_temp(T_THERMAL_DERATE_MC - 1000); + run(&s, 1); + TEST_ASSERT_FALSE(s.derate); + + set_temp(T_THERMAL_DERATE_MC + 500); + run(&s, 1); + TEST_ASSERT_TRUE(s.derate); + TEST_ASSERT_EQUAL_UINT32(1, s.derates); + + /* 在门限上抖 20 次:迟滞没做的话这里会变成 20 条事件。 */ + for (int i = 0; i < 20; i++) { + set_temp(T_THERMAL_DERATE_MC + ((i % 2) ? 500 : -500)); + run(&s, 1); + } + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, s.derates, "降载事件在刷屏(迟滞没做)"); + + /* 真凉下来才解除,再热起来才允许记第二条。 */ + set_temp(T_THERMAL_DERATE_CLEAR_MC - 1000); + run(&s, 1); + TEST_ASSERT_FALSE(s.derate); + set_temp(T_THERMAL_DERATE_MC + 500); + run(&s, 1); + TEST_ASSERT_EQUAL_UINT32(2, s.derates); + + TEST_ASSERT_EQUAL_UINT32(2, evlog_flush(0)); + evlog_rec_t r[2]; + memset(r, 0, sizeof(r)); + TEST_ASSERT_EQUAL_UINT32(2, evlog_read(seq, r, 2)); + TEST_ASSERT_EQUAL_UINT8(LOG_EV_THERMAL_DERATE, r[0].type); + /* `evlog.h` 对这个类型的约定:bus_mv 放温度 m°C。放错了日志里就是一个 + 看着完全合法、但意思全错的数(比如把它当成电压)。 */ + TEST_ASSERT_EQUAL_INT32_MESSAGE(T_THERMAL_DERATE_MC + 500, r[0].bus_mv, + "降载事件里没记温度(evlog.h:bus_mv = 温度 m°C)"); +} + +/* `t_thermal` 一次也不许碰 flash:唯一碰 flash 的是 `t_log`(`evlog.h` 文件头)。 + 在 1 s 周期的任务里写 flash 的现象是**偶发的几十毫秒卡顿**,而它和高优先级的 + `t_safety` 抢的是同一颗芯片的总线 —— 20 ms 的巡检节拍会跟着抖,查起来毫无线索。 */ +static void test_thermal_never_touches_flash(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + evlog_flush(0); + const uint32_t writes = sim_store_writes(); + + thermal_snap_t s; + boot(&s); + set_temp(95000); /* 过温 ⇒ 记降载 */ + run(&s, 2); + sim_fan_set_stalled(0, true); + run(&s, 10); /* 停转 ⇒ 再记事件 */ + TEST_ASSERT_TRUE(s.derates > 0 && s.fan[0].stalls > 0); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(writes, sim_store_writes(), "t_thermal 自己落盘了"); +} + +/* ---- ⑤ 没 init 就 step ---------------------------------------------------- */ + +/* 启动顺序排错(A15 的降级矩阵)时,这个函数会在 init 失败之后照样被调到。 + 返回 HAL_ERR_STATE 而不是「拿一个全 0 的快照继续跑」—— 后者的现象是 + 风扇停在 0 %、温度报 0 °C、TACH 永远读不到,看上去一切正常。 + + ⚠️ 这一条**必须排在第一个跑**:`s_inited` 是模块级状态,`sim_hal_reset()` + 管不着它(那是假板子的开关,不是固件的),init 过一次就回不去了。 */ +static void test_step_before_init_is_refused(void) +{ + thermal_snap_t s; + memset(&s, 0, sizeof(s)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, t_thermal_step(&s)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_thermal_step(NULL)); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_step_before_init_is_refused); /* 必须第一个,见函数上的警告 */ + RUN_TEST(test_init_drives_fan_en_high_and_fans_actually_spin); + RUN_TEST(test_hotter_never_means_slower); + RUN_TEST(test_duty_curve_is_monotonic_and_bounded); + RUN_TEST(test_unreadable_temperature_means_full_speed); + RUN_TEST(test_two_ntc_take_the_hotter_one); + RUN_TEST(test_low_duty_windows_are_not_mistaken_for_a_stall); + RUN_TEST(test_a_real_stall_is_caught_and_logged); + RUN_TEST(test_two_tach_channels_do_not_bleed_into_each_other); + RUN_TEST(test_spinup_grace_suppresses_the_first_windows); + RUN_TEST(test_derate_is_edge_triggered_with_hysteresis); + RUN_TEST(test_thermal_never_touches_flash); + return UNITY_END(); +} diff --git a/firmware/test/test_tca9548a.c b/firmware/test/test_tca9548a.c new file mode 100644 index 0000000..12cf551 --- /dev/null +++ b/firmware/test/test_tca9548a.c @@ -0,0 +1,329 @@ +/* R-0:TCA9548A 驱动(底板 U35,0x70)。 + + 验收主项:**选通道发出去的是一个裸字节,不是「寄存器号 + 值」。** + 按后者写,通道会被选成「寄存器号」那个字节的值,而且从 HAL 到驱动到应用 + 一路全是 HAL_OK —— 板子上的现象是「读到的电压电流全都正常,就是不对号」。 + + 第二主项:控制字是**掩码**不是编号(同时开两路 = 两片同址 INA226 一起应答, + 读回来的是线与,数值正常还很稳),以及 `probe()` 要能把 + 「0x70 上蹲着别的东西」和「0x70 根本不应答」分成两种错误码。 + + ⚠️ 本文件只测驱动,不挂 `MUX_RST#`(`rst_pin` 传 -1)—— + 驱动本来就不该碰 GPIO,复位那条路径是 `i2cbus` 的事,在 test_i2cbus.c 里测。 */ +#include "tca9548a.h" + +#include "hal_i2c.h" +#include "sim_hal.h" +#include "unity.h" + +/* 主干上的输入侧 INA226(0x43)——「0x70 不与主干撞车」那条要有个真东西做对照。 */ +#define TRUNK_INA 0x43 +/* 刀片支路上的 INA226。五片同址,正是 D8 的设计。 */ +#define BLADE_INA 0x40 + +void setUp(void) { sim_hal_reset(); } + +/* 同一地址在当前可见范围里出现不止一次 = 通道开重了。 + 除了那条**故意**把通道开重的用例,全文件恒为 0。 */ +void tearDown(void) {} + +/* 假板子:一颗 mux + 主干一片 0x43 + 通道 0 / 通道 1 各一片 0x40。 */ +static sim_i2c_dev_t *mount(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL)); + sim_i2c_add_mux(TCA9548A_ADDR, -1); + sim_i2c_add(TRUNK_INA, 2); + sim_i2c_add_ch(0, BLADE_INA, 2); + sim_i2c_add_ch(1, BLADE_INA, 2); + sim_i2c_dev_t *mux = sim_i2c_dev(TCA9548A_ADDR); + TEST_ASSERT_NOT_NULL(mux); + return mux; +} + +static int popcount8(unsigned v) +{ + int n = 0; + while (v != 0u) { + n += (int)(v & 1u); + v >>= 1; + } + return n; +} + +/* ---- 验收主项一:一个裸字节 ---------------------------------------------- */ + +/* ★ 这一条就是文件头第 1 条的全部内容:线上必须**只有一个字节**, + 而且那个字节就是掩码本身。 + 写错了会怎样:见下面 test_the_reg_plus_value_spelling_is_silently_wrong。 + 为什么功能测试看不出来:写法错了以后照样「选到了某个通道」, + 五片刀片的读数全都在量程内、也会随负载变化,只是对应错了槽位。 */ +static void test_select_puts_exactly_one_byte_on_the_wire(void) +{ + sim_i2c_dev_t *mux = mount(); + + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 3)); + + TEST_ASSERT_EQUAL_UINT32(1, mux->n_write); + TEST_ASSERT_EQUAL_UINT8(1, mux->last_write_len); /* ★ 不是 2 */ + TEST_ASSERT_EQUAL_HEX8(0x08, mux->last_write[0]); /* ★ 掩码本身,不是通道号 3 */ + TEST_ASSERT_EQUAL_HEX8(0x08, sim_i2c_mux_mask()); + TEST_ASSERT_EQUAL_INT(3, sim_i2c_mux_ch()); + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); +} + +/* ★★ 反证:按「寄存器号 + 值」写会怎样。 + 这是最像笔误、也最容易在 code review 里被放过去的一种写法 —— + 绝大多数 I²C 器件就是这么写的。 + + 结果:器件只看第一个字节,于是「选通道 3」变成了掩码 0x03 = **通道 0 和 1 同时开**, + HAL 返回 HAL_OK,驱动返回 HAL_OK,上层拿到一个完全正常的读数。 + 真板上这时两片 0x40 一起应答,SDA 上是两片的线与:数值在量程内、还很稳定 + —— 功能测试、甚至示波器看波形都发现不了。 */ +static void test_the_reg_plus_value_spelling_is_silently_wrong(void) +{ + mount(); + + const uint8_t wr[2] = { 0x03, 0x08 }; /* 「写寄存器 3,值 = 通道 3 的掩码」 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(TCA9548A_ADDR, wr, sizeof(wr))); /* ★ 不报错 */ + + TEST_ASSERT_EQUAL_HEX8(0x03, sim_i2c_mux_mask()); /* 想开 3,实际开了 0 和 1 */ + TEST_ASSERT_EQUAL_INT(-2, sim_i2c_mux_ch()); /* 多于一路 */ + + /* 后果:0x40 这个地址现在同时看得见两片。 */ + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(BLADE_INA)); /* ★ 照样应答 */ + TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_ambiguous()); + + /* 对照:驱动的写法一次只开一路,同样一次 probe 不会再增加 ambiguous。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 3)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, hal_i2c_probe(BLADE_INA)); /* 通道 3 上什么都没有 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 1)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(BLADE_INA)); + TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_ambiguous()); /* 没有新增 */ +} + +/* ---- 验收主项二:掩码不是编号 -------------------------------------------- */ + +/* 八个通道逐个对:掩码必须是 1<last_write_len); + TEST_ASSERT_EQUAL_HEX8((uint8_t)(1u << ch), sim_i2c_mux_mask()); + TEST_ASSERT_EQUAL_INT(ch, sim_i2c_mux_ch()); + TEST_ASSERT_EQUAL_INT(1, popcount8(sim_i2c_mux_mask())); /* ★ 永远只开一路 */ + } + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); +} + +/* 全关 = 掩码 0:主干可见、所有支路断开。`i2cbus` 的「主干」就是这个状态。 */ +static void test_none_closes_every_branch(void) +{ + mount(); + + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 0)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(BLADE_INA)); + + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, TCA9548A_CH_NONE)); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_i2c_mux_mask()); + TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch()); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, hal_i2c_probe(BLADE_INA)); /* 支路断了就是不应答 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(TRUNK_INA)); /* 主干不受影响 */ +} + +/* 越界的通道号必须在**发出任何字节之前**被挡掉。 + 不挡的话 `1u << 8` 溢出成 0 = 悄悄全关,现象是「五路一起失联」而不是报错。 */ +static void test_out_of_range_channel_sends_nothing(void) +{ + sim_i2c_dev_t *mux = mount(); + + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 2)); + const uint32_t n = mux->n_write; + + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, tca9548a_select(TCA9548A_ADDR, -2)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, tca9548a_select(TCA9548A_ADDR, TCA9548A_CH_COUNT)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, tca9548a_select(TCA9548A_ADDR, 99)); + + TEST_ASSERT_EQUAL_UINT32(n, mux->n_write); /* 一个字节都没发 */ + TEST_ASSERT_EQUAL_HEX8(0x04, sim_i2c_mux_mask()); /* 通道也没被动过 */ +} + +/* `mask_to_ch()` 的判据穷举:恰好一位 → 0~7;全 0 → NONE;**多于一位 → -2**。 + -2 不是「另一种通道」,是「这块板上不该出现的状态」—— + 把它当成合法返回值去用(比如拿去做数组下标)会越界。 */ +static void test_mask_to_ch_is_exhaustive(void) +{ + for (int m = 0; m <= 0xFF; m++) { + const int got = tca9548a_mask_to_ch((uint8_t)m); + if (m == 0) { + TEST_ASSERT_EQUAL_INT(TCA9548A_CH_NONE, got); + } else if (popcount8((unsigned)m) > 1) { + TEST_ASSERT_EQUAL_INT(-2, got); + } else { + TEST_ASSERT_TRUE(got >= 0 && got < TCA9548A_CH_COUNT); + TEST_ASSERT_EQUAL_HEX8((uint8_t)m, (uint8_t)(1u << got)); + } + } + /* 几个最容易被写错的具体值 */ + TEST_ASSERT_EQUAL_INT(0, tca9548a_mask_to_ch(0x01)); + TEST_ASSERT_EQUAL_INT(7, tca9548a_mask_to_ch(0x80)); + TEST_ASSERT_EQUAL_INT(-2, tca9548a_mask_to_ch(0x03)); /* 就是上面那个笔误的结果 */ + TEST_ASSERT_EQUAL_INT(-2, tca9548a_mask_to_ch(0xFF)); + TEST_ASSERT_EQUAL_INT(TCA9548A_CH_NONE, tca9548a_mask_to_ch(0x00)); +} + +/* 选了就要能读回来 —— 读它同样是一个裸字节,不带寄存器指针。 + 这条是 `probe()` 的地基:写下去不报错但没生效(上拉太弱、支路被拉死) + 只有靠读回来才认得出。 */ +static void test_read_mask_reads_back_what_was_selected(void) +{ + mount(); + uint8_t m = 0xFF; + + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 4)); + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_read_mask(TCA9548A_ADDR, &m)); + TEST_ASSERT_EQUAL_HEX8(0x10, m); + + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, TCA9548A_CH_NONE)); + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_read_mask(TCA9548A_ADDR, &m)); + TEST_ASSERT_EQUAL_HEX8(0x00, m); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, tca9548a_read_mask(TCA9548A_ADDR, NULL)); +} + +/* ---- 认件的三种结局 ------------------------------------------------------ */ + +/* 好器件:全关之后读回 0。 */ +static void test_probe_accepts_a_fresh_mux(void) +{ + mount(); + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_probe(TCA9548A_ADDR)); + TEST_ASSERT_EQUAL_HEX8(0x00, sim_i2c_mux_mask()); /* probe 顺带把通道全关了 */ + TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch()); +} + +/* ★ 地址上应答了、但读回来不是 0 → HAL_ERR_BUS,**不是** NAK。 + 两者要分开:NAK 是「没插 / 焊虚 / 地址错」,BUS 是「0x70 上蹲着另一颗器件, + 或者写根本没生效」。后者在真板上的现象是「所有刀片都读不到」, + 而这里能在启动自检那一刻就叫出来。 + ⚠️ 这条用例里故意**不挂假 mux**(0x70 上换成一颗普通寄存器器件), + 因为要模拟的正是「这不是一颗听话的 TCA9548A」。 */ +static void test_probe_reports_bus_error_when_readback_is_not_zero(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL)); + sim_i2c_add(TCA9548A_ADDR, 1); /* 0x70 上的陌生人:会应答,但不听话 */ + sim_i2c_set_reg(TCA9548A_ADDR, 0x00, 0x55); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_BUS, tca9548a_probe(TCA9548A_ADDR)); +} + +/* 器件不在(没焊、没插、地址错)→ NAK,而且是在**写**那一步就返回。 */ +static void test_probe_naks_when_nobody_answers(void) +{ + mount(); + sim_i2c_set_present(TCA9548A_ADDR, false); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, tca9548a_probe(TCA9548A_ADDR)); +} + +/* 「全关」那一次写就 NAK 了,就不该再去读。 + 继续往下读的后果:读也 NAK,返回的错误码一样,但**总线上多了一次事务**; + 更糟的是如果读侥幸成功(毛刺只打在写上),会拿一个陈旧的掩码判「认件通过」。 + + 顺带钉住一件后面 i2cbus 要用到的事实:**失败的写不改变器件状态** —— + 掩码还停在上一次选的那一路。固件这边却「以为」通道变了, + 这正是 `i2cbus` 的通道影子必须在选通失败时作废的原因。 */ +static void test_probe_stops_at_the_first_failure(void) +{ + sim_i2c_dev_t *mux = mount(); + + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 2)); + const uint32_t n_read = mux->n_read; + + sim_i2c_inject_nak(TCA9548A_ADDR, 1); /* 只吃掉「全关」那一次写 */ + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, tca9548a_probe(TCA9548A_ADDR)); + + TEST_ASSERT_EQUAL_UINT32(n_read, mux->n_read); /* 没有多发那次读 */ + TEST_ASSERT_EQUAL_HEX8(0x04, sim_i2c_mux_mask()); /* 器件还停在通道 2 */ +} + +/* ---- 地址表 -------------------------------------------------------------- */ + +/* 0x70 不与**主干上**的 0x43 / 0x4A / 0x4B 撞。 + 撞上的后果:选通道那一个字节会被写进 DAC63204 的寄存器里 —— 输出电压乱跳。 */ +static void test_addr_does_not_collide_with_the_trunk(void) +{ + TEST_ASSERT_TRUE(tca9548a_addr_ok()); + TEST_ASSERT_NOT_EQUAL(0x43, TCA9548A_ADDR); /* 输入侧 INA226 */ + TEST_ASSERT_NOT_EQUAL(0x4A, TCA9548A_ADDR); /* DAC63204 #1 */ + TEST_ASSERT_NOT_EQUAL(0x4B, TCA9548A_ADDR); /* DAC63204 #2 */ + TEST_ASSERT_TRUE(TCA9548A_ADDR <= 0x7f); +} + +/* ★ 为什么 0x40 / 0x20 **不在**那张冲突表里 —— 这不是漏了,这是 D8 的全部意义。 + 它们在 mux 的支路上:主干上根本看不见,所以五片刀片可以完全同构 + (每片都是 0x40 + 0x20、零 strap 电阻、五片可互换)。 + 把它们加进冲突表的后果比漏掉更糟:`addr_ok()` 会在一块好板子上返回 false, + 启动自检直接把整机拦下来。 */ +static void test_branch_addresses_are_absent_from_the_table_on_purpose(void) +{ + mount(); + + /* 全关:主干上只有 0x43 和 0x70,没有 0x40。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, TCA9548A_CH_NONE)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, hal_i2c_probe(BLADE_INA)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(TRUNK_INA)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(TCA9548A_ADDR)); + + /* 选通道 0:0x40 出现,与主干的 0x43 / 0x70 同时可见、互不干扰。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 0)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(BLADE_INA)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(TRUNK_INA)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(TCA9548A_ADDR)); + + /* 换到通道 1:还是那个 0x40,但已经是**另一片**了。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 1)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(BLADE_INA)); + + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); /* 全程没有两片一起应答 */ +} + +/* 通道→去哪的那张表:槽 1 在通道 0、槽 5 在通道 4、CH224A 在通道 5。 + ⚠️ 顺排是当下的事实不是不变量,所以业务代码要从 rail_cfg 查 —— + 这里只钉住「这三个常量互不相同、且都在 0~7 内」。 */ +static void test_channel_map_constants_are_sane(void) +{ + TEST_ASSERT_TRUE(TCA9548A_CH_SLOT1 >= 0 && TCA9548A_CH_SLOT1 < TCA9548A_CH_COUNT); + TEST_ASSERT_TRUE(TCA9548A_CH_SLOT5 >= 0 && TCA9548A_CH_SLOT5 < TCA9548A_CH_COUNT); + TEST_ASSERT_TRUE(TCA9548A_CH_PD >= 0 && TCA9548A_CH_PD < TCA9548A_CH_COUNT); + TEST_ASSERT_NOT_EQUAL(TCA9548A_CH_SLOT1, TCA9548A_CH_SLOT5); + TEST_ASSERT_NOT_EQUAL(TCA9548A_CH_PD, TCA9548A_CH_SLOT1); + TEST_ASSERT_NOT_EQUAL(TCA9548A_CH_PD, TCA9548A_CH_SLOT5); + /* 五个槽正好占掉 SLOT1..SLOT5,PD 必须在这五个之外。 */ + TEST_ASSERT_TRUE(TCA9548A_CH_PD < TCA9548A_CH_SLOT1 || TCA9548A_CH_PD > TCA9548A_CH_SLOT5); + TEST_ASSERT_EQUAL_INT(-1, TCA9548A_CH_NONE); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_select_puts_exactly_one_byte_on_the_wire); + RUN_TEST(test_the_reg_plus_value_spelling_is_silently_wrong); + RUN_TEST(test_every_channel_is_one_bit_not_a_number); + RUN_TEST(test_none_closes_every_branch); + RUN_TEST(test_out_of_range_channel_sends_nothing); + RUN_TEST(test_mask_to_ch_is_exhaustive); + RUN_TEST(test_read_mask_reads_back_what_was_selected); + RUN_TEST(test_probe_accepts_a_fresh_mux); + RUN_TEST(test_probe_reports_bus_error_when_readback_is_not_zero); + RUN_TEST(test_probe_naks_when_nobody_answers); + RUN_TEST(test_probe_stops_at_the_first_failure); + RUN_TEST(test_addr_does_not_collide_with_the_trunk); + RUN_TEST(test_branch_addresses_are_absent_from_the_table_on_purpose); + RUN_TEST(test_channel_map_constants_are_sane); + return UNITY_END(); +} diff --git a/固件开发计划.md b/固件开发计划.md index 939cdcd..99ac3de 100644 --- a/固件开发计划.md +++ b/固件开发计划.md @@ -2,10 +2,11 @@ | 项 | 值 | |---|---| -| 版本 | v1.0(2026-09-06) | +| 版本 | **v2.0(2026-09-16)** —— 按 **R2 刀片式硬件**重排;v1.0(2026-09-06,R1 单板)见 git 历史 | | 范围 | `需求文档.md` 的 **D5 / D5a** —— ESP32-S3 固件与双链路命令接口 | | 架构依据 | `需求文档.md` **§10 软件架构设计**(本计划不重复架构,只排施工顺序) | -| 目标平台 | ESP32-S3-WROOM-1 / ESP-IDF v5.x / C | +| 硬件依据 | **`刀片式方案规划-R2.md`**(底板 + 5 片刀片)。R1 的 `五路直供电源板设计.md` 仍是**电路计算与阈值**的出处,但**板级拓扑一律以 R2 为准** | +| 目标平台 | ESP32-S3-WROOM-1-**N16R8** / ESP-IDF v5.4 / C | > **这份计划是给「一次会话 ≤ 20% 上下文」这个约束量身排的。** > 它不是甘特图,是一串**自带入场清单与验收判据的任务包**: @@ -13,16 +14,35 @@ --- -## 1. 决定这份计划形状的三个约束 +## 0. v2.0 改了什么(先看这一节) + +v1.0 写在 2026-09-06,那时硬件是 R1:**一块下板 + 一块载板**、 +六片不同地址的 INA226 挂一条 I²C、24 位 74HC595 链管按键、`VBUS_EN` 是 ESP32 直连 GPIO。 + +2026-09-08 起硬件改成 **R2 刀片式**(底板 + 5 片完全相同的刀片,PCI-E 金手指对插), +到 2026-09-16 底板与刀片的原理图、PCB、BOM 均已定稿并在收尾。 +**A1~A11 那批已完成的固件是照 R1 写的,其中有六处与现在的板子对不上**, +差异与影响逐条列在 **§5**,迁移任务包是 **§6.2 的 R-0~R-6**。 + +一句话:**逻辑层(sched / evlog / rail 换算 / t_actuator 的时序 / t_safety 的判据阈值)全部有效, +器件层与拓扑层要改。** 本次修订没有推翻任何一个时间常量或阈值 —— 那些来自电路计算,R2 没改电路。 + +⚠️ **R1 固件不再维护、也不做条件编译共存。** R1 的板子一片都没打样, +为一块不会存在的板子保留一套 `#ifdef` 分支,代价是此后每个任务包都要读两遍。 +R1 的代码在 git 里,需要时去那里翻。 + +--- + +## 1. 决定这份计划形状的四个约束 | # | 约束 | 对计划的影响 | |---|---|---| -| **C-a** | **硬件还没有。** PCB 仍在布线(`PROGRESS.md`:下板还欠 1044 条连接性) | 绝大部分固件必须能在**没有板子**的情况下写完并验完 ⇒ 先做模拟器,见 §4 | +| **C-a** | **硬件仍未到手**,但**已定稿**:底板与刀片的原理图 / PCB / BOM 都已收口 | 绝大部分固件仍要在**没有板子**的情况下写完并验完 ⇒ 模拟器继续是主力,见 §4.2。**但器件层不再有"待定"**:地址、位号、引脚全部可从板上缓存读出来(§5 的每一行都注明了出处) | | **C-b** | **一次会话 ≤ 20 % 上下文**(按 200K 窗口 ≈ **40K token**) | 每个任务包必须**自带入场读取清单并给出上限**;源文件行数要控制;禁止整篇读大文档,见 §2 | -| **C-c** | **保护不在软件里**(需求 SR-3 / SW-N1) | 固件写错的后果是「功能不好用」,不是「烧手机」⇒ 可以先把逻辑全部写完再上板调,不必等硬件边写边试 | +| **C-c** | **保护不在软件里**(需求 SR-3 / SW-N1) | 固件写错的后果是「功能不好用」,不是「烧手机」⇒ 可以先把逻辑全部写完再上板调 | +| **C-d** | **五片刀片完全同构**:同地址 INA226(0x40) + 同地址 PCA9555(0x20),靠底板 TCA9548A 分通道区分(R2 D8) | 「查表不要算」这条规矩**没有作废,只是换了对象**:现在因路而异的只有 **mux 通道**与 **`EFUSE_EN` GPIO** 两列,其余五路逐格相同。⚠️ 新的静默错误形态是**选错通道** —— 读回来的是另一台手机的电压电流,量纲、范围、变化趋势全都正常 | -C-c 是最容易被忽略但最解放人的一条:**这块板子的固件不是安全关键件**, -所以「离线写完 90 %、上板只调 10 %」这条路线是成立的。 +C-c 依然是最解放人的一条;C-d 是这一版新增的、也是最危险的一条。 --- @@ -32,12 +52,13 @@ C-c 是最容易被忽略但最解放人的一条:**这块板子的固件不 | 文件 | 大小 | ≈ token | 占 20 % 预算 | |---|---|---|---| -| `CLAUDE.md` | 3 KB | ~1K | 2.5 % | -| `需求文档.md` **全文** | 47 KB | ~15K | **38 %** | +| `CLAUDE.md` | 4 KB | ~1.3K | 3 % | +| `需求文档.md` **全文** | 67 KB | ~22K | **55 %** | |  └ 单看 §10 软件架构设计 | 19 KB | ~6K | 15 % | -|  └ 单看 §9 固件需求 | 2 KB | ~0.7K | 2 % | -| `PROGRESS.md` **全文** | 195 KB | ~60K | **150 % ⛔** | -| `五路直供电源板设计.md` **全文** | 438 KB | ~140K | **350 % ⛔** | +| `刀片式方案规划-R2.md` **全文** | 291 KB | ~95K | **240 % ⛔** | +|  └ 单看 §7.3 + §8.1(刀片 I²C 设备 + 固件改动) | 4 KB | ~1.3K | 3 % | +| `PROGRESS.md` **全文** | 534 KB | ~175K | **440 % ⛔** | +| `五路直供电源板设计.md` **全文** | 493 KB | ~160K | **400 % ⛔** | > 换算口径:UTF-8 中文约 3 字节/字,中文约 1 token/字,所以 **字节 ÷ 3 ≈ token**。 > 英文代码更省,约 4 字节/token。 @@ -52,17 +73,19 @@ C-c 是最容易被忽略但最解放人的一条:**这块板子的固件不 ### 2.3 硬规矩(违反其中任何一条,当次会话必然超预算) -1. **禁止整篇读 `五路直供电源板设计.md` 与 `PROGRESS.md`。** 需要什么就 `grep -n` 那个条目号 - 再 `sed -n 'a,bp'` 取那一段。这两份文件单独任何一个都装不进一次会话。 -2. **禁止整篇读 `需求文档.md`。** 按需求 ID 取:`grep -n "SR-13\|SW-8" 需求文档.md`。 - 要看架构就只读 §10 里对应的小节。 -3. **单个源文件控制在 ≈300 行以内。** 读一个 800 行的 C 文件≈8K token = 预算的 20 %。 - 超了就拆文件 —— 这条是**为了让后续会话读得起**,不是代码洁癖。 -4. **构建与测试输出一律 `2>&1 | tail -40`。** ESP-IDF 全量构建日志有几千行。 -5. **长任务后台跑**(`run_in_background`)**只看摘要**,不要贴全量。 -6. **一次会话只碰一个任务包。** 顺手做掉相邻的一小块,通常就是下一次会话被迫回头重读的原因。 -7. **固件会话与 EasyEDA 会话分开。** `easyeda-sch-geometry` skill + `eda-parts.json` + - 截图验证那一套,本身就要吃掉一大半预算,和固件混在一起必然两头都做不完。 +1. **禁止整篇读四份大文档**(`五路直供电源板设计.md` / `刀片式方案规划-R2.md` / `PROGRESS.md` / `需求文档.md`)。 + 需要什么就 `grep -n` 那个条目号再 `sed -n` 取那一段。 +2. **板级事实优先读机器可读的缓存,不要读文档**: + - `.claude/r2/_base_pcb_nets.json` —— 底板每个器件的**焊盘 → 网名**(`U20` 就是 ESP32 的全部引脚) + - `.claude/r2/_blade_pcb_nets.json` —— 刀片同上(`U4` 就是 PCA9555 的全部引脚) + - `.claude/_esp32_pinmap.json` 的 `r2_base` —— 模组**脚号 → 网名**(脚号不是 GPIO 号,换算见 §5.1) + + 一次 python 取一个器件 ≈ 200 token,比读文档便宜两个数量级,而且**它就是板子本身**。 +3. **单个源文件控制在 ≈300 行以内。** 超了就拆文件 —— 这条是为了让后续会话读得起。 +4. **构建与测试输出一律 `2>&1 | tail -40`。** +5. **长任务后台跑**(`run_in_background`)**只看摘要**。 +6. **一次会话只碰一个任务包。** +7. **固件会话与 EasyEDA 会话分开。** --- @@ -104,168 +127,439 @@ $ <命令> - ... ``` -> **为什么覆盖而不是追加**:`PROGRESS.md` 现在 195 KB、读一次就是 1.5 倍会话预算, -> 就是追加写出来的。交接文件的唯一读者是「下一次会话的开头 30 秒」,它只需要现状。 -> 历史留在 git log 里。 - ### 3.3 任务包的完成定义(DoD) -一个任务包算做完,必须同时满足: - -- [ ] 宿主机测试全绿(`ctest` 或等价命令),**且新增了针对本包的测试**; -- [ ] ESP-IDF 目标也能编过(`idf.py build`,不要求上板); +- [ ] 宿主机测试全绿(`bash host/test.sh`),**且新增了针对本包的测试**; +- [ ] ESP-IDF 目标也能编过(`.\build_idf.ps1`,不要求上板); - [ ] `HANDOFF.md` 已更新; - [ ] **没有跨会话的半成品** —— 宁可缩小本包范围,也不要留一个编不过的中间态。 --- -## 4. 工程结构与两个关键决定 +## 4. 工程结构与三个关键决定 -### 4.1 决定一:宿主机可编译 + 硬件抽象层 +### 4.1 决定一:宿主机可编译 + 硬件抽象层(不变) ``` firmware/ ├── main/ 应用层 —— 与硬件无关,宿主机上能编能跑 -│ ├── rail.c/.h 通道抽象、rail_set_mv()(§10.5) -│ ├── safety.c SR-5 / SW-8 巡检判据(§10.3) -│ ├── seq_*.c 三个执行器状态机(§10.6) -│ ├── cmd.c 单一命令处理器(§10.8) -│ ├── evlog.c 事件环形缓冲(§10.7) -│ └── thermal.c 风扇闭环(§10.3) +│ ├── board_pins.h 底板 ESP32 引脚(R2) +│ ├── rail_cfg.c/.h 五路一张表(R2:+ mux_ch、+ PCA9555 位,− vbus_en GPIO) +│ ├── rail.c/.h mV ↔ DAC 码(§10.5,R2 零改动) +│ ├── i2cbus.c/.h ⭐ R2 新增:mux 原子事务(见 §4.3) +│ ├── t_safety.c SR-5 / SW-8 巡检判据(§10.3) +│ ├── t_actuator.c 三个执行器状态机(§10.6) +│ ├── t_thermal.c 风扇闭环(A16) +│ ├── cmd.c 单一命令处理器(A12,§10.8) +│ ├── evlog.c 事件环形缓冲 + 落盘(已完成) +│ ├── sched.c 周期节拍(已完成) +│ └── tasks.c 五个任务那张表(已完成) ├── components/ -│ ├── phonerail_hal/ i2c / gpio / pwm / time 的**接口**(`include/`) +│ ├── phonerail_hal/ i2c / gpio / pwm / time / store / lock 的**接口** │ │ ├── esp/ 真实实现(ESP-IDF) │ │ └── sim/ 模拟实现(宿主机) -│ └── drv/ ina226 / dac63204 / pca9555 / hc595 / lm75 -├── sim/ 假板子:五路电压模型 + 手机负载 + 可注入故障 +│ └── drv/ ina226 / dac63204 / pca9555 / tca9548a / ch224a +│ (sr595 已退役,见 §5.2 与 R-2) └── test/ 宿主机单元测试与场景测试 ``` -**HAL 只抽象四样东西:I²C 事务、GPIO 电平、PWM/定时、单调时钟。** +**HAL 抽象六样东西**:I²C 事务、GPIO 电平、PWM/定时、单调时钟、掉电存储、锁。 不要抽象得更多 —— 抽象层每多一个概念,后面每次会话都要多读一遍。 -> ⚠️ **组件名不能叫 `hal`**(A2 实测):ESP-IDF 自己有一个 `components/hal`, -> `driver` 依赖它,同名会被工程里的这个盖掉。所以目录是 `phonerail_hal`。 -> 头文件名 `hal_i2c.h` / `hal_gpio.h` / `hal_pwm.h` / `hal_time.h` 不冲突 -> (IDF 那个的头都在 `hal/` 前缀下)。 +> ⚠️ **组件名不能叫 `hal`**(A2 实测):ESP-IDF 自己有 `components/hal`,同名会被盖掉。 -### 4.2 决定二:先写模拟器(A2 + A3 之前) - -没有模拟器,每个任务包的「验收」都只能写成「等板子到了再说」, -于是 C-a 会把整个计划堵死到打样之后。 - -模拟器要模拟的,只有**能让逻辑跑起来的最小集合**: +### 4.2 决定二:模拟器仍是主力(不变) | 模拟 | 到什么程度 | 不模拟什么 | |---|---|---| | I²C 器件 | 寄存器读写 + 可编程返回值 + 可注入 NAK | 真实时序、时钟延展 | +| **I²C mux** | ⭐ R2 新增:**通道状态决定哪些假器件可见**;选错通道就读到另一片 | 通道间串扰 | | 五路输出 | 一阶模型:`EN` 开 → 按 0.36 V/ms 斜坡到设定值;关 → 按 τ=22 ms 衰减 | 环路动态、纹波 | | 手机负载 | 一个可设定的电流值 + 「卡死」标志 | 真实功耗曲线 | -| 故障注入 | 过压、负电流(反灌)、器件失联、PG 不来、风扇停转 | —— | +| 故障注入 | 过压、负电流(反灌)、器件失联、PG 不来、风扇停转、**mux 失联**、**刀片不在位** | —— | > ⚠️ **模拟器不是用来证明设计对的**,它只用来证明**代码逻辑对**。 -> 电压、余量、时序裕量那些结论全部来自设计文档的计算与首板实测, -> 别拿模拟器跑出来的波形去推翻 §5 的任何一个数。 +> 电压、余量、时序裕量那些结论全部来自设计文档的计算与首板实测。 + +### 4.3 决定三(R2 新增):`i2cbus` —— 通道选择与事务是**一个**不可分的动作 + +R2 之后,一次「读 CH3 的电压」在总线上其实是两件事: +**先写 TCA9548A 选通道 2,再读 0x40 的寄存器**。 +这两件事之间如果被另一个任务插进来选了别的通道,读回来的是**另一台手机的数据**, +而且量纲、范围、变化趋势全部正常 —— 没有任何一层会报错。 + +所以在 `hal_i2c` 之上加一层薄薄的 `i2cbus`,规矩三条: + +1. **应用层不许直接调 `hal_i2c_*`。** 驱动照旧只调 `hal_i2c_*`(它们要能单独测), + 但驱动的**调用方**必须经 `i2cbus` 把通道选好并持锁。 +2. **`i2cbus_select(ch)` 不是一个可以单独调的公开函数。** 对外只暴露 + `i2cbus_xact(ch, fn, arg)`:拿锁 → 必要时选通道 → 跑 `fn` → 放锁。 + 通道号记在影子里,**相同通道不重发选通字节**(省掉每圈 5 次事务)。 +3. **`t_safety` 的一圈是五次 `i2cbus_xact`,不是一把大锁包住整圈。** + 每路一次 `xact`,中间给 `t_actuator` 留得进来的缝。 + +⚠️ **mux 影子必须在任何一次写通道失败时作废**(置成"未知"), +否则下一次「通道相同、跳过选通」会把事务发到一个其实没切过去的通道上。 --- -## 5. Track A —— 无硬件即可完成(**现在就能开始**) +## 5. ⭐ R1 → R2:硬件差异与固件影响(权威清单) + +**这一节是 v2.0 的核心。** 每一行的「板上出处」都可以用 §2.3 第 2 条的命令在 30 秒内复核。 +**动手改任何一个器件层文件之前,先核对这里对应的那一行。** + +### 5.1 底板 ESP32 引脚:整张表作废重来 + +R1 的 `board_pins.h` + `rail_cfg.c` 里那两列 GPIO **一个都不对**。 +R2 的事实来源是 `.claude/_esp32_pinmap.json` 的 `r2_base`(脚号 → 网名) +与 `.claude/r2/_base_pcb_nets.json` 的 `U20`(脚号 → 网名,从 PCB 焊盘读回)。 +两份已逐脚对上。**脚号 ≠ GPIO 号**: + +| 网 | 模组脚 | **GPIO** | R1 是 | 备注 | +|---|---:|---:|---|---| +| `EFUSE_EN1` | 17 | **IO9** | IO39 | 五路全变,且 R2 里**组内顺排**(R2 §29.5 为布线重排过) | +| `EFUSE_EN2` | 18 | **IO10** | IO48 | | +| `EFUSE_EN3` | 19 | **IO11** | IO6 | | +| `EFUSE_EN4` | 20 | **IO12** | IO41 | | +| `EFUSE_EN5` | 21 | **IO13** | IO8 | | +| `VBUS_EN1~5` | — | **不存在** | IO40/10/11/42/17 | ⛔ **挪到刀片 PCA9555 的 P0.4**,见 §5.4 | +| `SDA` | 34 | **IO41** | IO14 | | +| `SCL` | 35 | **IO42** | IO15 | | +| `PRSNT1#` | 8 | **IO15** | 无 | R2 新增,3V3 上拉(R606~R610)⇒ **低 = 刀片在位** | +| `PRSNT2#` | 9 | **IO16** | 无 | | +| `PRSNT3#` | 10 | **IO17** | 无 | | +| `PRSNT4#` | 11 | **IO18** | 无 | | +| `PRSNT5#` | 7 | **IO7** | 无 | ⚠️ 这一根**不在顺排里**(方案 B 重排,R2 §29.6) | +| `MUX_RST#` | 33 | **IO40** | 无 | R2 新增,3V3 上拉(R611)⇒ 低 = 复位 TCA9548A | +| `INT#` | 32 | **IO39** | IO38(`IOEXP_INT#`) | 五片刀片 PCA9555 的 INT# **wire-OR**(R604 上拉)⇒ 低 = 有某片报中断,**看不出是哪片** | +| `INA_ALERT` | 31 | **IO38** | 无(R1 走 PCA9555 P0.6) | 只有**底板输入侧 U13** 一片的 ALERT(R605 上拉) | +| `HUB_RST` | 12 | **IO8** | 无(R1 走 595) | 上拉到 `+3V3_HUB`(R716)⇒ 低有效。**只在启动自检脉冲一次,运行期不许动**(D13 代价 2) | +| `UP_VBUS_DET` | 6 | **IO6** | IO47 | R721/R722 分压 | +| `NTC1` / `NTC2` | 4 / 5 | **IO4 / IO5** | IO1 / IO2 | 仍是 ADC1(R660/R661 上拉分压) | +| `FAN_PWM` | 24 | **IO47** | IO18 | 两个风扇座共用 | +| `FAN_EN` | 25 | **IO48** | 无 | ⚠️ R2 新增:**复位低 = 风扇断电**,固件必须主动开(Q8 → Q7 两级) | +| `FAN_TACH_IN` | 23 | **IO21** | IO16 | 进风扇,经 Q9 开集**反相** + R753 上拉 | +| `FAN_TACH_OUT` | 22 | **IO14** | 无 | 出风扇,经 Q10 **反相** + R757 上拉 | +| `USB6_DM`/`DP` | 13/14 | IO19/IO20 | 同 | 原生 USB 硬连线,接 Hub 第 6 口(D13) | +| `IO0` | 27 | IO0 | — | 只接 J33 应急口,**不许当普通 GPIO 用** | +| `TXD0`/`RXD0` | 37/36 | IO43/IO44 | 同 | J33 应急串口,永远能进(D13 代价 2 的退路) | +| 备用 | 39/38 | IO1/IO2 | — | 全板只剩这两个空脚,都是 ADC1 | + +⛔ **一律不许占用**:IO0/IO3/IO45/IO46(strapping)、IO19/IO20(USB PHY)、 +IO26~IO32(模组内部 flash)、IO35~IO37(N16R8 的八线 PSRAM)、IO22~IO25(**S3 上不存在**)。 + +### 5.2 器件清单:多了两个,退役两个 + +| 器件 | R1 | **R2** | 板上出处 | +|---|---|---|---| +| INA226 | 6 片 6 个地址,一条总线 | **5 片全是 0x40**(刀片 `U3`,各在自己的 mux 通道)+ **1 片 0x43**(底板 `U13`,主干) | `_blade` `U3` pin1/2 = GND/GND;`_base` `U13` pin1/2 = GND/SCL | +| DAC63204 | 2 片 0x4A / 0x4B | **一模一样**(底板 `U6`/`U7`,**挂主干,不进 mux**) | `_base` `U6` pin7=SDA、`U7` pin7=SCL(ADDR strap) | +| PCA9555 | 1 片 0x20,**16 脚全是输入** | **5 片全是 0x20**(刀片 `U4`),**P0.0~P0.4 是输出** | `_blade` `U4`,见 §5.3 | +| 74HC595 ×3 | 24 位链,管按键 / EDL / HUB_RST | ⛔ **退役**(R2 D9) | 底板 BOM 里没有 | +| **TCA9548A** | 无 | **新增,0x70**(底板 `U35`) | `_base` `U35` pin1/2/21 = GND(A0/A1/A2) | +| **CH224A** | 无 | **新增**(底板 `U36`,PD 受电,挂 **mux 通道 5**) | `_base` `U36` pin2/3 = SCL6/SDA6 | +| LM75 | 载板上一片 0x48 | ⛔ **没有了**(载板整个不存在) | 底板 BOM 无 | + +mux 通道分配(`_base` `U35` 逐脚读回,**写进表里查,不要算**): + +| 通道 | 引脚 | 去哪 | +|---:|---|---| +| 0 | SD0/SC0 = `SDA1`/`SCL1` | 槽 1(`J34`) | +| 1 | `SDA2`/`SCL2` | 槽 2(`J35`) | +| 2 | `SDA3`/`SCL3` | 槽 3(`J36`) | +| 3 | `SDA4`/`SCL4` | 槽 4(`J37`) | +| 4 | `SDA5`/`SCL5` | 槽 5(`J38`) | +| **5** | `SDA6`/`SCL6` | **CH224A(`U36`)** | +| 6 / 7 | 悬空 | —— | + +### 5.3 刀片 PCA9555(`U4`):位表与方向全部重写 + +从 `.claude/r2/_blade_pcb_nets.json` 的 `U4` 逐脚读回(TSSOP-24): + +| 脚 | 位 | 网 | 方向 | 复位态 / 极性 | +|---:|---|---|---|---| +| 4 | P0.0 | `KEY_PWR` | **OUT** | 上电高阻 + 栅极 100 k 下拉 = 松开 ✅ | +| 5 | P0.1 | `KEY_VUP` | **OUT** | 同上 | +| 6 | P0.2 | `KEY_VDN` | **OUT** | 同上 | +| 7 | P0.3 | `EDL_EN` | **OUT** | 同上 | +| 8 | P0.4 | `VBUS_EN` | **OUT** | 高阻 + R701 100 k 下拉 = VBUS 断 ✅ | +| 9,10,11 | P0.5~7 | **GND** | **必须 IN** | ⛔ 配成输出 = 直接对地短路 | +| 13 | P1.0 | `EFUSE_FLT#` | IN | 开漏上拉 ⇒ **低 = 有故障** | +| 14 | P1.1 | `BUCK_PG` | IN | ⇒ 高 = Buck 正常(**R2 里是本路的,不再是五路线或**) | +| 15 | P1.2 | `INA_ALERT` | IN | ⇒ 低 = 本片 INA226 告警(不跨板) | +| 16 | P1.3 | `EFUSE_PG` | IN | ⇒ 高 = eFuse 输出良好 | +| 17~20 | P1.4~7 | **GND** | **必须 IN** | ⛔ 同上 | +| 1 | INT# | 五槽 wire-OR → ESP32 IO39 | — | 读走输入端口才释放 | +| 2,3,21 | A1,A2,A0 | GND | — | ⇒ 地址 **0x20** | + +⛔⛔ **这是 R2 固件里最危险的一处,写错一次板子就冒烟:** +PCA9555 的 **Output Port 寄存器复位值是 0xFF**,Config 复位值也是 0xFF(全输入)。 +所以初始化**必须是这个顺序**: + +``` +1. 写 OUTPUT0 = 0x00 (此时全是输入,写它对外没有任何影响) +2. 写 OUTPUT1 = 0x00 +3. 写 POLINV0/1 = 0x00 +4. 写 CONFIG1 = 0xFF (Port1 全输入) +5. 写 CONFIG0 = 0xE0 (P0.0~4 输出、P0.5~7 输入) ← 最后一步 +6. 逐个回读校验 +``` + +**顺序反过来(先配方向再写输出)的后果**:那一刻 P0.0~P0.4 输出 **1**, += 三个键全按下 + EDL 短接 + VBUS 上电,五台手机一起被按住电源键。 +而 `P0.5~7 / P1.4~7` 七个脚在刀片上**直接接地、不串限流电阻**(上表), +方向写错一位就是对地短路 —— 不报错、不 NAK,只会发烫。 +⇒ 驱动里 `pca9555_dir_safe()` 的白名单从 R1 的「全输入」改成 +**「只有 P0.0~P0.4 可以是输出」**,越界一律 `abort()`。 + +### 5.4 `VBUS_EN` 从 GPIO 变成 I²C —— 这条改了架构,不只是改了接口 + +R1 里 `t_safety` 的 SW-8 反灌判据可以**直接写引脚**(§10.3 原话「不经任何队列或抽象层」)。 +R2 里 `VBUS_EN` 在刀片的 PCA9555 上 ⇒ **断 VBUS 变成一次跨 mux 的 I²C 事务**。 + +| | R1 | R2 | +|---|---|---| +| 断 VBUS 的动作 | `gpio_set_level()`,几百 ns | mux 选通 + 写 OUTPUT0,**约 0.16 ms**,且要持 I²C 锁 | +| 判据延迟 | 一圈(20 ms) | 一圈 + 一次事务 ≈ 20.2 ms —— SW-8 的预算是 200 ms,**仍有 10 倍余量** | +| I²C 失联时 | 照样断得掉 | ⛔ **断不掉** | + +⇒ **`t_safety` 必须在同一圈里已经持有 I²C 锁的那个位置执行断 VBUS**,不要放锁后再拿一次。 +⇒ **新增一条降级行为并写进需求 §10.10**:「PCA9555 失联 ⇒ 该路 VBUS 不可控」。 +兜底是关 `EFUSE_EN`(GPIO 直连,永远可用)—— +**但要说清楚:关 eFuse 并不能阻止手机经 USB 往回灌**,它只是让这一路不再直供电。 +真正的反灌保护是 R1 §15.2 的硬件(U29 泄放 + 母线电容),固件那一层在 I²C 失联时**确实降级了**。 +这是 R2 用一根跨板针换来的代价,**是决定,不是待办**。 + +⚠️ 相应地,`EFUSE_EN` **仍然是 ESP32 GPIO 直连**(金手指 A11 独占一针,R2 §7.3 的框)—— +这是保护链第三层唯一的执行手段,任何时候都不许把它挪到 I²C 后面。 + +### 5.5 `t_safety` 一圈的总线账要重算 + +R1:一条总线上 6 片 INA226(各 2 次事务)+ 1 片 PCA9555(1 次)≈ **1.56 ms / 7.8 %**。 + +R2:五路各要「选通道 + 读 INA226 V、I + 读 PCA9555 两个输入字节」,加主干那片输入侧 INA226: + +| 项 | 次数 | 位周期 | @400 kHz | 小计 | +|---|---:|---:|---:|---:| +| mux 选通道(纯写 1 字节,**不带寄存器号**) | 6 | 20 | 50 µs | 300 µs | +| 刀片 INA226 读 V / I(写 1 + 读 2) | 10 | 48 | 120 µs | 1200 µs | +| 刀片 PCA9555 读两个输入端口(写 1 + 读 2) | 5 | 48 | 120 µs | 600 µs | +| 输入侧 INA226 读 V / I | 2 | 48 | 120 µs | 240 µs | +| **合计(23 次事务)** | | | | **2340 µs ⇒ 117 ‰(11.7 %)** | + +> 这一格不是手算的:`t_safety_cycle_txns()` = 23、`t_safety_budget_us()` = 2340 +> 是跑出来的,位周期来自 `hal_i2c_txn_bits()`(START + 每字节 9 位 + 重复 START + STOP)。 +> R1 是 13 次事务 / 1560 µs / 78 ‰。 + +**仍然装得进 20 ms 的节拍**(INA226 一帧 8.8 ms < 20 ms 的前提没变;占用从 7.8 % 涨到 11.7 %), +但 `t_safety_budget_us()` 与拿它对账的测试必须一起改, +**并且要把「mux 选通可以跳过(通道没变)」算进去** —— 否则实测值永远比预算小, +测试一开始就对不上。⇒ 预算函数改成「按本圈实际发出的事务算」,测试拿它和实测逐圈对账。 + +### 5.6 `t_actuator` 的锁前提被打破了 + +R1:执行器只碰 GPIO 与 595 ⇒ **与 I²C 无交集 ⇒ 两把锁永远不会嵌套**(`t_actuator.h` 开头那段)。 +R2:按键 / EDL / VBUS 全在 PCA9555 上 ⇒ **执行器必须拿 I²C 锁**。 + +好消息是 595 那把锁**没了**,所以现在**全系统只剩一把锁**,嵌套在物理上不可能。 +代价是执行器与巡检争用同一把锁: + +- 执行器的每一次动作都是「一次 `i2cbus_xact`」,**不许在持锁期间等时间** + (按键保持 100 ms 是状态机的事,不是持锁的事); +- `t_safety` 拿不到锁就跳过这一圈并计数(规矩没变),5 ms 超时不变; +- **`ACT_ST_CLEANUP` 不放弃**这条规矩更重要了:I²C 忙时写不进去必须一直重试, + 否则键留在按下状态(`t_actuator.h` 的「最坏的失败」)。 + +### 5.7 五路一张表:换了列,没换规矩 + +``` + INA226 mux_ch DAC KEY 位(PWR/VUP/VDN) EDL位 VBUS位 FLT位 PG位 EFUSE_EN PRSNT# + CH1 0x40 0 U6.OUT2 P0.0 P0.1 P0.2 P0.3 P0.4 P1.0 P1.3 IO9 IO15 + CH2 0x40 1 U6.OUT0 同上 同上 同上 同上 同上 IO10 IO16 + CH3 0x40 2 U7.OUT0 同上 同上 同上 同上 同上 IO11 IO17 + CH4 0x40 3 U6.OUT1 同上 同上 同上 同上 同上 IO12 IO18 + CH5 0x40 4 U7.OUT1 同上 同上 同上 同上 同上 IO13 IO7 +``` + +- **DAC 那两列一个字没改**(D11b:DAC 留在底板,仍是 R1 的 `U6` 0x4A / `U7` 0x4B 与通道映射)。 + ⚠️ 所以「CH3 在 U7 而不是 U6」这个**不顺排**的坑原样还在。 +- `mux_ch = rail − 1` 这一次**确实是顺排的**,`PRSNT#` 那一列**到 CH5 就断了**(IO7)。 + 两列都写进表里查 —— 顺排是当下的事实,不是不变量。 +- `vbus_en` 这一列(GPIO)**删掉**,改成 PCA9555 位(五路相同)。 +- `rail_selftest()` 的判据跟着改: + - `RAIL_SELFTEST_PINS` 现在查五根 `EFUSE_EN` + 五根 `PRSNT#`(不重复、不越界、不撞 strapping / USB / PSRAM / 不存在的脚); + - 新增 **`RAIL_SELFTEST_MUX`**:五个 `mux_ch` 互不重复、都 ≤ 7、都不等于 CH224A 那一路(5)。 + +### 5.8 新增的两条安全判据(R2 §8.1 的 A10) + +| 判据 | 动作 | 为什么 | +|---|---|---| +| **mux 失联**(选通道连续失败 N 次) | **五路全部断电** | TCA9548A 是单点:它一挂,五路的电压电流**全部看不见**,SR-5 的整条监控链断了。这时「继续供电但看不见」比「停机」危险 | +| **`PRSNT#` 为高(刀片不在位)** | 该路**不许上电**,命令一律拒绝并记事件 | 空槽上电 = 金手指裸露带 12V | + +⚠️ mux 失联要**先试一次 `MUX_RST#` 脉冲再判**(低有效,给 1 ms)。 +一次总线毛刺就停五台机器,代价太大。N 的取值见 §11。 + +### 5.9 CH224A(PD 受电):新增的功率上限来源 + +- 挂在 **mux 通道 5**,地址**待实测确认**(§11)。 +- **读 `0x50`** = 当前档位最大电流,单位 50 mA ⇒ 整板功率上限 = 20 V × 该值。 +- ⛔ **禁止写 `0x0A = 5`**(请求 28 V EPR):板上 `D3` 是 SMBJ22A、`U37` 的 EN 分压按 20 V 设计, + 28 V 会把 TVS 打到雪崩。驱动里**不提供写 `0x0A` 的接口**(和 `sr595` 不提供抬 `OE#` 同一条规矩)。 +- 上限拿到之后的动作是 **R1 §8.3 的降载**:输入侧 INA226(0x43)实测功率逼近上限时按优先级关路。 + ⚠️ 那片 INA226 **IN+/IN− 在板上是对调接的**(`U13` pin9=`VIN_SW` 上游、pin10=`+12V` 下游), + 启动时必须调 `ina226_set_reversed(0x43, true)`,否则正常供电会被 SW-8 判成反灌。 + **驱动侧已经做好了,就差启动时那一行。** + +### 5.10 风扇:一路 PWM 两只风扇 + 两路测速 + 一个总开关 + +- `FAN_PWM`(IO47)→ 两个座 `J26`/`J27` 共用; +- `FAN_EN`(IO48)→ Q8/Q7 两级 ⇒ **12V 总开关,复位低 = 不转**,固件要主动开; +- `FAN_TACH_IN`(IO21)/ `FAN_TACH_OUT`(IO14)→ 各经一级开集三极管**反相**后上拉 + ⇒ 数边沿没问题,但**任何「按电平判转没转」的写法都是反的**; +- **没有 LM75 了**:温度只有两路 NTC(IO4/IO5,ADC1,R660/R661 上拉分压)。 + `t_thermal` 里 LM75 那一路整个删掉。 + +--- + +## 6. Track A —— 无硬件即可完成 + +### 6.1 已完成(R1 硬件下写的,逻辑有效) + +| 包 | 状态 | R2 是否受影响 | +|---|---|---| +| A1 工程骨架(CMake 双目标 + ctest) | ✅ | 否 | +| A2 HAL 接口 + sim 实现 | ✅ | 否(假 mux 是 sim 侧加东西,不改接口) | +| A3 INA226 驱动 | ✅ | **是** → R-1 | +| A4 DAC63204 驱动 + `rail_set_mv()` | ✅ | **否**(D11 的全部意义) | +| A5 74HC595 驱动 | ✅ | ⛔ **退役** → R-2 | +| A6 PCA9555 驱动 | ✅ | **是,大改** → R-2 | +| A7 五路配置表 | ✅ | **是** → R-3 | +| A8 任务骨架与调度 | ✅ | 表不变,**总线预算要重算** → R-1 | +| A9 三个执行器状态机 | ✅ | **是**(595 → PCA9555,多一份锁争用)→ R-4 | +| A10 安全巡检 | ✅ | **是**(遍历 mux、断 VBUS 走 I²C、两条新判据)→ R-4 | +| A11 事件日志 | ✅ | 只加事件类型(往末尾加)→ R-5 | + +**✅ R-0~R-6 已于 2026-09-16 全部完成**,上面这一列「R2 是否受影响」的每一项都已落地。 + +### 6.2 R2 迁移包 ✅ **全部完成(2026-09-16)** + +> 七包一次会话并行做完(主代理改生产代码,七个子代理分头补测试)。 +> 验收:`host/test.sh` **19 个测试 / 351 个用例全绿**,`build_idf.ps1` 通过 +> (`phonerail.bin` 0x3fa90,app 分区剩 75 %)。交接见 `firmware/HANDOFF.md`。 +> +> **迁移期间在生产代码里抓到的三个真缺陷**(都是写测试时撞出来的,不是审出来的): +> 1. `check_mux()` 的两段式拿「复位计数器 vs 自己的快照」当状态 ⇒ 永远相等 ⇒ +> `MUX_RST#` 每 20 ms 脉冲一次而 `MUX_LOST` 一次不出; +> 2. `check_present()` 读的是没人刷新的缓存 ⇒ 拔掉刀片永远判不出来; +> 3. 一圈六次锁 × 5 ms = **30 ms > 20 ms 周期** ⇒ 总线繁忙时巡检成片丢拍, +> 而每次 `xact` 都按时返回了。现在有 `T_SAFETY_LOCK_BUDGET_MS` 这条整圈的闸。 +> +> 下面这张表留着,是因为**每一包的验收判据就是它现在的拦网**。 + + 每包 = 一次会话。「入场读」列的是**除 `CLAUDE.md` 与 `HANDOFF.md` 之外**要读的。 | 包 | 目标 | 入场读 | 验收 | |---|---|---|---| -| **A1** | 工程骨架:CMake 双目标(idf + host)、空 `main`、能跑的 `ctest` | 本节 | `ctest` 通过(1 个假测试);`idf.py build` 通过 | -| **A2** | HAL 接口 + `sim` 实现(i2c/gpio/pwm/time) | A1 产物 | 测试里能挂上一个假 I²C 器件并读回预置值 | -| **A3** | INA226 驱动 + 六片地址表 | §10.4;设计文档 §8.2 那张地址表(`sed` 取 20 行) | 单测断言 `0x40/41/44/45/42/43`,**并有一条测试专门断言「通道号 ≠ 地址低位」** | -| **A4** | DAC63204 驱动 + `rail_set_mv()` | §10.5 全节(约 1.5K token) | 断言 ①4020 mV 被拒(SR-13)②写更大的码值输出更低(反向关系)③3900 mV ↔ Vd 0.548 V | -| **A5** | 74HC595 24 位链 + 影子寄存器单一写入点 | §10.6「影子寄存器」那段;设计文档 §17.2 的位表 | 断言 ①改 CH1 按键不动 CH2~5 的位 ②字节顺序 U33→U22→U21 | -| **A6** | PCA9555 驱动 | §10.4 末段 | 断言**方向寄存器永远不会把 Port0[7] / Port1[7:5] 配成输出**(配错直接 abort) | -| **A7** | 通道配置表 + 表驱动遍历 | §10.4 全节 | 启动自检:五路的地址/位号无重复、无越界 | -| **A8** | 任务骨架与调度(5 个任务、两把锁) | §10.3 全节 | sim 时钟下 `t_safety` 周期 20 ms ±1 ms;I²C 占用打印出来与 §10.3 的 8 % 对得上 | -| **A9** | 三个执行器状态机 + 互斥 | §10.6 全节 | 场景测试:硬重启走完 17 ms 上电时序;按键序列不重入;EDL 顺序固定 | -| **A10** | 安全巡检(SR-5 / SW-8) | §10.2 + 需求 SR-5 / SW-8 两行 | 注入过压 → 20 ms 内关路并记事件;注入 −60 mA → 断 VBUS | -| **A11** | 事件日志(环形缓冲 + 落盘) | §10.7 全节 | 写满 4096 条不丢最新;掉电重启后能读回 | -| **A12** | 命令处理器(语义 + 序号判重 + 限幅) | §10.8 命令表 + 三条硬约束 | **同序号发两遍只执行一次**;越界命令被拒且记事件 | -| **A13** | 传输适配器 B:USB CDC 分帧 | §10.8 第 4 条 | 宿主机上用管道灌「命令 + 日志混流」,命令仍能被正确切分 | -| **A14** | 传输适配器 A:WiFi/MQTT 骨架 | §10.8 | 协议层用 sim 传输测通;**真连网留到 B 段** | -| **A15** | 启动顺序 + 降级矩阵 | §10.9 + §10.10 | 逐个「拔掉」器件,行为与 §10.10 的表逐行对上;五路上电错开 100 ms | -| **A16** | 风扇闭环 + TACH 采样窗口 | §10.3 `t_thermal` + 需求 TR-6 | PWM < 100 % 时不会把「读不到脉冲」误判成停转 | +| **R-0** | `board_pins.h` 按 §5.1 重写;`i2cbus` 接口 + sim 假 mux(§4.3);TCA9548A 驱动 | §4.3 + §5.1 + `_esp32_pinmap.json` | ①`i2cbus_xact` 在 sim 上选错通道读到的是另一片的值(有一条测试专门证明这件事**看起来完全正常**)②通道影子在写失败后作废 ③`MUX_RST#` 脉冲后影子作废 | +| **R-1** | INA226 地址层:五路同址 + `(addr, mux_ch)` 对;`ina226_set_reversed(0x43,true)` 进启动;总线预算按 §5.5 重算 | §5.2 + §5.5 | ①断言五路地址**全是 0x40**、输入侧 0x43 ②反接那片读正常供电为**正** ③预算函数与实测事务数逐圈对上 | +| **R-2** | PCA9555 驱动重写:输出支持 + 影子 + §5.3 的初始化顺序;`sr595` 退役 | §5.3 全节(**一个字都不要跳**) | ①`test_config_is_written_last()`:把顺序改反,测试必须变红 ②任何把 P0.5~7 / P1.4~7 配成输出的调用直接 abort ③影子写入点唯一:改 CH1 的键不动别的位 | +| **R-3** | 五路表加 `mux_ch` / `PRSNT#`、删 `vbus_en` GPIO、按键位改 PCA9555;`rail_selftest` 加 `RAIL_SELFTEST_MUX` | §5.7 | ①五路表与驱动逐格对账(老规矩)②喂进重复的 `mux_ch` 要被查出来 ③`EFUSE_EN` 与 `PRSNT#` 十根互不重复、不撞保留脚 | +| **R-4** | `t_safety` 遍历 mux + 断 VBUS 走 I²C(§5.4)+ 两条新判据(§5.8);`t_actuator` 换 PCA9555 并拿 I²C 锁(§5.6) | §5.4 + §5.6 + §5.8 | ①注入过压 → 20 ms 内关路(判据延迟没变)②注入 −60 mA → 该路 VBUS 断(**且是在持锁的那一段里断的**)③mux 连续失败 → 先脉冲复位、再失败才五路全断 ④`PRSNT#` 为高时上电命令被拒 ⑤执行器与巡检并发跑 1000 圈:`skipped` 有界、键一定松开 | +| **R-5** | 事件类型补齐(`MUX_LOST` / `SLOT_EMPTY` / `PD_LIMIT` / `FAN_STALL` …,**只许往末尾加**) | `evlog.h` + HANDOFF 的约定 | `test_flash_format_is_frozen()` 全绿;每个新类型有名字 | +| **R-6** | CH224A 驱动 + 整板功率上限(§5.9);`t_thermal` 删 LM75、加 `FAN_EN`、TACH 反相(§5.10) | §5.9 + §5.10 | ①驱动**不提供**写 `0x0A` 的接口 ②`0x50` 读出的档位换成瓦数 ③PWM < 100 % 时不会把「读不到脉冲」误判成停转 | -**顺序**:A1 → A2 是硬前置;A3~A6 四个驱动包彼此独立(可换顺序); -A7 依赖 A3~A6;A8 依赖 A2;A9~A11 依赖 A7+A8;A12 依赖 A9;A13/A14 依赖 A12; -A15 依赖全部;A16 独立,随时可插。 +**顺序**:R-0 是硬前置(后面每一包都要用 `i2cbus`);R-1 / R-2 独立可换序; +R-3 依赖 R-1+R-2;R-4 依赖 R-3;R-5 / R-6 随时可插。 -**预计 16 次会话**。其中 A4 / A9 / A12 是三个「容易超预算」的包, -如果开场发现读完入场清单已经用掉 12 %,就地拆成两包,别硬撑。 +⚠️ **R-2 和 R-4 是两个「容易超预算」的包**,开场发现入场清单读完已用掉 12 % 就地拆包。 + +### 6.3 剩余的新功能包(R2 口径) + +| 包 | 目标 | 验收 | +|---|---|---| +| **A12** | 命令处理器(语义 + 序号判重 + 限幅) | 同序号发两遍只执行一次;越界命令被拒且记事件;**空槽(`PRSNT#` 高)的命令一律拒** | +| **A13** | 传输适配器 B:**USB-Serial-JTAG CDC** 分帧(D13:烧录 / 控制台 / 数据同一根线) | 宿主机上用管道灌「命令 + 日志混流」,命令仍能被正确切分 | +| **A14** | 传输适配器 A:WiFi/MQTT 骨架 | 协议层用 sim 传输测通;真连网留到 B 段 | +| **A15** | 启动顺序 + 降级矩阵(**加 mux / PRSNT / CH224A 三行**,见 §5.4 / §5.8) | 逐个「拔掉」器件,行为与 §10.10 的表逐行对上;五路上电错开 100 ms;`HUB_RST` 只脉冲一次。
⭐ **先把启动序列从 `app_main.c` 里拆出一个宿主机可编的函数**(`board_bringup()` 之类):现在那一段一行也测不到(`app_main.c` 只进 IDF 目标),于是像 `ina226_set_reversed(INA226_ADDR_INPUT, true)` 这种「漏了就把正常供电判成反灌」的单行,**唯一的拦网是人眼**。拆出来之后第一条用例就是它 | +| **A16** | 风扇闭环 + TACH 采样窗口(§5.10) | 见 R-6 的验收 | --- -## 6. Track B —— 需要真板(板子到之前只能写脚本) +## 7. Track B —— 需要真板 -Track B 的验收判据**不是我定的,是 `需求文档.md` §12.1 那 17 步**。 -这里只做一件事:把那 17 步拆成能一次会话做完的包,并在板子到之前先把脚本写好。 +验收判据**不是我定的,是 `需求文档.md` §12.1 那 17 步**。R2 之后多三步(下表打 ⭐R2)。 -| 包 | 对应 §12.1 | 说明 | +| 包 | 对应 | 说明 | |---|---|---| -| **B0** | 全部 | 把 17 步做成一份**逐步 checklist 脚本**(提示 + 记录 + 存档),板子到之前就能写完 | -| **B1** | 步 1~2 | 上电、3.3 V、I²C 扫到 10 个器件 | +| **B0** | 全部 | 把这些步做成一份**逐步 checklist 脚本**(提示 + 记录 + 存档),板子到之前就能写完 | +| **B1** | 步 1~2 | 上电、3.3 V、**I²C 扫描**:主干应当只看到 `0x70` / `0x43` / `0x4A` / `0x4B`;**逐通道**扫到 0x40 + 0x20;通道 5 扫到 CH224A(**把真地址填回 §5.2 与 §11**)。⭐ 顺带拿逻辑分析仪看一次**选通字节真的让支路通了**——见 §11 那条「写下去不报错但没生效」 | +| ⭐**B1b** | R2 新增 | **PD 取电**:不插刀片,量 20 V 请求成功、`U37` 输出 12.0 V、`0x50` 读数与充电器铭牌对得上 | +| ⭐**B1c** | R2 新增 | **五张刀片逐一单插**(一次只插一片,五个槽各走一遍):`PRSNT#` 变低、mux 通道通、`EFUSE_EN` 直连有效;**然后拔掉**,确认固件判出不在位。
⚠️ **必须五个槽都走一遍,不能只试一片**:R-3 的测试证明不了「mux 通道整体错位」——五路的通道号只要互不重复、都不越界、都不等于 5,`rail_selftest()` 就全过(比如 0→1→2→3→4→0 这样循环挪一格)。现象是**每一路读到的都是隔壁那台手机**,量纲范围全正常。固件侧没有第二个独立来源能和这张表对账(不像 INA226 / DAC 地址有驱动那张表兜着),**唯一的判据就是这一步**:只插槽 N 时,只有第 N 路读得到电压,其余四路必须全部读不到 | | **B2** | 步 3~4 | 单路 Buck 空载 3.90 V;DAC 扫全量程与公式误差 < 1 % | -| **B3** | 步 5 | **人为断开 R2 → eFuse 断开** ⭐ 核心验证项,单独一次会话,不要和别的混 | +| **B3** | 步 5 | **人为断开 R2 → eFuse 断开** ⭐ 核心验证项,单独一次会话 | | **B4** | 步 6 | 0→4 A 阶跃、无 6 kHz 振铃 ⭐ 另一个核心验证项 | | **B5** | 步 7~9 | 满载温升、断电重启时序、五路全装 | -| **B6** | 步 12 | 复位安全:手动复位 ESP32,示波器确认全关、15 路按键无一误触发 | -| **B7** | 步 13~15 | 母线泄放、USB 反灌(**不关充电**注入)、关充电后复测 | +| **B6** | 步 12 | 复位安全:手动复位 ESP32,示波器确认全关、**15 路按键无一误触发**(R2 里由 PCA9555 上电高阻保证,要实测) | +| **B7** | 步 13~15 | 母线泄放、USB 反灌、关充电后复测。⚠️ **顺带验 §5.4 的降级**:断开 mux 供电时该路 VBUS 确实断不掉 | | **B8** | 步 10~11、16 | 接第一台手机、按键与组合键、有线 adb / Fastboot | +| ⭐**B8b** | R2 新增(D13) | **一根线**:一根 USB-C 插 `J23` 后电脑同时看到 5 台手机 + ESP32 的 CDC;`HUB_RST` 误触发的恢复路径(J33 应急口)走一遍 | | **B9** | 步 17 | 五台长稳 24 h + SW-12 的两条链路验收 | -> ⚠️ **B3 与 B4 不要和任何别的事放在同一次会话。** 它们是这块板子的两个核心验证项 -> (需求 §12.1 原话「不要跳过」),而且都要接示波器、都要人在场。 -> 会话里塞了第二件事,出问题时分不清是谁引起的。 +> ⚠️ **B3 与 B4 不要和任何别的事放在同一次会话。** --- -## 7. Track C —— 收尾 +## 8. Track C —— 收尾 | 包 | 内容 | 触发条件 | |---|---|---| | **C1** | WiFi 配网流程(USB 下发凭据写 NVS,IF-19) | A14 之后 | -| **C2** | OTA:双分区 + 失败回滚 | B9 之后(§10.12 说了它是「省事」不是「救命」,别提前) | -| **C3** | 出厂标定流程(§10.5 第 3 条,两点线性标定存 NVS) | B2 之后 | -| **C4** | 文档回填:把实测值填回 `需求文档.md` §12.2,把踩到的坑写进 §10 | 随时 | +| **C2** | OTA:双分区 + 失败回滚 | B9 之后 | +| **C3** | 出厂标定流程(两点线性标定存 NVS) | B2 之后 | +| **C4** | 文档回填:实测值填回 `需求文档.md` §12.2,坑写进 §10 | 随时 | --- -## 8. 里程碑 +## 9. 里程碑 | 里程碑 | 判据 | 依赖硬件 | |---|---|---| -| **M1 逻辑跑通** | A1~A16 全绿;模拟器上能完成一次完整的「崩溃 → 软重启 → 硬重启」 | ❌ 不需要 | -| **M2 板子活了** | §12.1 的 17 步全过,尤其 B3 / B4 | ✅ | +| **M1 逻辑跑通** | A1~A16 + R-0~R-6 全绿;模拟器上能完成一次完整的「崩溃 → 软重启 → 硬重启」 | ❌ 不需要 | +|  └ 进度 | **R-0~R-6 ✅ 完成**(2026-09-16,351 例);A1~A11 ✅;**还欠 A12~A16** | | +| **M2 板子活了** | §12.1 的 17 步 + B1b / B1c / B8b 全过,尤其 B3 / B4 | ✅ | | **M3 交付** | 五台 24 h 无重启无告警;双链路验收通过 | ✅ | -**M1 完全不依赖打样进度**,可以和 PCB 布线并行 —— 这是本计划最主要的排期收益。 +**M1 完全不依赖打样进度** —— 这是本计划最主要的排期收益。 --- -## 9. 明确不做的事 +## 10. 明确不做的事 | # | 不做 | 理由 | |---|---|---| -| 1 | 不为「更好测试」去改硬件设计 | 硬件已定稿(需求 §0.1 的 13 条决策) | +| 1 | 不为「更好测试」去改硬件设计 | 硬件已定稿(R2 §0.1 的 D1~D16) | | 2 | 不在固件里做任何形式的过流保护环路 | SW-N1 | -| 3 | 不做上位机 UI | N7;本项目只交付板侧协议与实现 | +| 3 | 不做上位机 UI | N7 | | 4 | 不追求模拟器的物理精度 | §4.2 的警告 | | 5 | 首版不做 FR-12 电量曲线 | §10.11:只留接口 | | 6 | 不写「跨会话的大重构」 | 违反 §3.3 的 DoD | +| 7 | **不保留 R1 的条件编译分支** | §0 的理由:R1 的板子一片都没打样 | +| 8 | **不让固件在运行期碰 `HUB_RST`** | D13 代价 2:自断控制台,且下一次烧录也进不来 | +| 9 | **不提供写 CH224A `0x0A` 的接口** | §5.9:28 V 会把 `D3` SMBJ22A 打到雪崩 | --- -## 10. 待定项 +## 11. 待定项 | 项 | 说明 | |---|---| -| ~~ESP-IDF 具体版本~~ | **A1 已定:v5.4**(`D:/Users/35138/esp/v5.4/esp-idf`,PATH 上的那个)。机器上另有 v5.3.1 / v5.5 / v5.5.1 / v6.0-rc1,别混。构建走 `firmware/build_idf.ps1`(内含关 ccache 的理由) | +| **CH224A 的 I²C 从机地址** | R2 §36.2 只写了寄存器(`0x09` 状态 / `0x50` 档位电流 / `0x0A` 禁写),没写地址。**驱动做成地址可配**,B1 扫描时定死,然后回填本表与 §5.2 | +| **TCA9548A 失联的判定次数 N** | §5.8 的「连续失败 N 次」。N=1 太敏感(一次毛刺停五台),N=5 就是 100 ms。**B1c 实测总线误码率之后定**,先按 3 写、常量单列 | +| ⭐ **选通道「写下去不报错但没生效」要不要加周期性回读** | R-0 的测试写完之后发现的一个**模拟器造不出来的**失效:上拉太弱、某条支路被某个器件拉死时,`tca9548a_select()` 返回 `HAL_OK` 而通道其实没切过去。假器件的写永远生效,所以宿主机上一条都复现不了。真板上的表现是**「一直读到另一片」,而 `i2cbus_select_fail_streak()` 恒为 0 ⇒ §5.8 的 mux 失联判据完全失灵**。
两条路:① B1 上板时用逻辑分析仪实测一次,确认这种失效在这块板上是否可能(`R602`/`R603` 的上拉值、支路电容);② 给 `i2cbus` 加一条**低频回读校验**(比如每 100 圈回读一次控制字,与影子比对)。回读一次是 50 µs,摊到 100 圈可以忽略。**B1 之前不做**——现在加就是为一个还不知道存不存在的失效付复杂度 | | MQTT broker 与 topic 约定 | 依赖农场既有系统,A14 之前需要对方给出 | -| ~~单元测试框架~~ | **A1 已定:Unity**(从 IDF v5.4 原样复制进 `firmware/third_party/unity/`,与上板测试同一套)+ 宿主机 CMake/CTest,工具链用 cygwin 那套(见 `firmware/HANDOFF.md` 的坑)。测试放 `firmware/test/test_*.c`,被自动收成一条 ctest | | `input_suspend` 节点路径 | 与需求 V-8 是同一件事,首台机上确认 | +| ~~ESP-IDF 版本~~ | **已定 v5.4**(`D:/Users/35138/esp/v5.4/esp-idf`)。构建走 `firmware/build_idf.ps1` | +| ~~单元测试框架~~ | **已定 Unity**(`firmware/third_party/unity/`)+ 宿主机 CMake/CTest,工具链 cygwin |