文件
phonerail/firmware/test/test_rail_cfg.c
T
zhou yang和Claude Opus 5.5 b8ad69681f 固件:R2 迁移 R-0 ~ R-6(底板 + 5 片同构刀片)
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 <[email protected]>
2026-09-27 10:26:24 +08:00

765 行
35 KiB
C
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/* 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 <string.h>
#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 "slot.h"
#include "tca9548a.h"
#include "unity.h"
#define N PHONERAIL_RAIL_COUNT
void setUp(void)
{
sim_hal_reset();
/* 驱动里的静态影子不归 sim 管,不清会串到下一条用例。 */
pca9555_shadow_reset();
}
void tearDown(void) {}
/* ---- 验收主项一:自检 ----------------------------------------------------- */
static void test_selftest_passes(void)
{
TEST_ASSERT_EQUAL_HEX32(0, rail_selftest());
}
/* 自检背后的每一个谓词都要真的被算过。`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(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_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 的,测试改不动它。所以复制一份出来逐样改坏。 */
static void copy_real_table(rail_cfg_t *dst)
{
for (int rail = 1; rail <= N; rail++) {
dst[rail - 1] = *rail_cfg(rail);
}
}
static void test_a_faithful_copy_of_the_real_table_passes(void)
{
rail_cfg_t t[N];
copy_real_table(t);
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);
}
/* 表和驱动分家:只改一格,而且改成一个**合法**的值。
这一手光看「地址是不是合法、有没有重复」是查不出来的 ——
板子照样跑,只是「读 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 = INA226_ADDR_INPUT; /* 0x43 合法、板上真有这片 */
TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_TABLE, rail_selftest_table(t));
copy_real_table(t);
t[0].dac_ch = 0; /* CH1 从 U6.OUT2 挪到 OUT0 —— 那是 CH2 的通道 */
TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_TABLE, rail_selftest_table(t));
copy_real_table(t);
t[2].dac_addr = DAC63204_ADDR_U6; /* 「CH3 当然在 U6」—— R1 就踩过的那一脚 */
TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_TABLE, rail_selftest_table(t));
}
/* ⭐ 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].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[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[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[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].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));
}
/* 三样一起坏,三位一起报 —— 别在第一个错上就 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 = INA226_ADDR_INPUT;
t[0].efuse_en = 45;
t[1].mux_ch = TCA9548A_CH_PD;
TEST_ASSERT_EQUAL_HEX32(RAIL_SELFTEST_TABLE | RAIL_SELFTEST_PINS | RAIL_SELFTEST_MUX,
rail_selftest_table(t));
}
/* ---- 验收主项三:顺排也得查表 --------------------------------------------- */
/* 「起点 + 步长 × ch」形式的算式一次拦掉,不去猜别人会写哪一个。 */
static bool is_arithmetic(const int *v, int n)
{
const int step = v[1] - v[0];
for (int i = 2; i < n; i++) {
if (v[i] - v[i - 1] != step) {
return false;
}
}
return true;
}
/* ⭐⭐ 这条是本文件的核心。
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 mux[N], efuse[N], prsnt[N];
for (int rail = 1; rail <= N; rail++) {
const rail_cfg_t *c = rail_cfg(rail);
mux[rail - 1] = (int)c->mux_ch;
efuse[rail - 1] = c->efuse_en;
prsnt[rail - 1] = c->prsnt_n;
}
/* 两列「现在是顺排」的事实。改了先来改这条,别默默改表。 */
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));
/* ⭐ 反例:`PRSNT#` 不是等差数列,任何 `起点 + 步长 × rail` 都描述不了它。 */
TEST_ASSERT_FALSE_MESSAGE(is_arithmetic(prsnt, N), "PRSNT# 到第五路就断了,不许算");
/* 而且那个算式**只有一路错** —— 「拿 CH1 试一下没问题」正是它会被放过的原因。 */
int wrong = 0;
for (int rail = 1; rail <= N; rail++) {
if (prsnt[rail - 1] != 14 + rail) {
wrong++;
}
}
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 那两列 **R2 一个字没改**,坑原样还在:「前三路在 U6、通道号 = rail−1」
这句话五路里有四路不成立。接错了板子照样正常工作,只是调 CH1 的命令调了 CH2。 */
static void test_dac_mapping_is_not_the_obvious_one(void)
{
int wrong = 0;
for (int rail = 1; rail <= N; rail++) {
const rail_cfg_t *c = rail_cfg(rail);
if (c->dac_addr != ((rail <= 3) ? DAC63204_ADDR_U6 : DAC63204_ADDR_U7)
|| c->dac_ch != (uint8_t)((rail - 1) % 3)) {
wrong++;
}
}
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");
}
/* ⭐ 五路的 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_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);
}
}
}
/* 抽几个最容易被「顺手改顺」的格子,直接钉死实测值
(出处:`.claude/r2/_base_pcb_nets.json` 的 `U35`/`U20` 与 §5.7,2026-09-16 逐格核过)。 */
static void test_the_most_surprising_cells(void)
{
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()` 内部也比一遍,但那是**被测代码自己**在比。
这一条是外面独立比的:内部那段哪天被删了,这里照样红。 */
static void test_table_matches_every_driver_table(void)
{
for (int rail = 1; rail <= N; rail++) {
const rail_cfg_t *c = rail_cfg(rail);
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);
/* 路号 → 通道的唯一换算点在 `slot_mux_ch()`,它必须查的是同一张表。 */
TEST_ASSERT_EQUAL_INT(slot_mux_ch(rail), (int)c->mux_ch);
}
}
/* 两路共用一个东西的现象:共用 (DAC 片, 通道) = 调这路那路也动;
共用 mux 通道 = 两路读到同一台手机;共用 GPIO = 关这路那路也关。 */
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_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;
}
}
return false;
}
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 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++) {
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]);
}
}
}
/* 十根脚是**全固件唯一一处**出处 —— 没有第二张表能和它对账
(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 PRSNT# (模组脚号写在注释里) */
static const int want[N][2] = {
{ 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)->prsnt_n);
}
}
/* 两路 NTC 必须落在 ADC1(IO1~IO10):ADC2 在 WiFi 开着的时候读不了,
而本板 WiFi 常开(§10.8 传输 A)。
⚠️ 挪到 ADC2 **不会报错**,只会在连上 WiFi 之后开始读失败 ——
台架上不连 WiFi 一切正常,很难复现。 */
static void test_ntc_pins_are_on_adc1(void)
{
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);
}
/* ⚠️ 回归拦网: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, 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));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, hal_gpio_config(49, HAL_GPIO_OUTPUT)); /* 49 才是越界 */
}
/* ---- `EFUSE_EN`:表驱动遍历 ----------------------------------------------- */
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_FALSE(rail_efuse_is_on(rail));
}
}
/* 上电第一件事:五个脚都配成输出、都是低。「五路全关」不许靠某个默认值 ——
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_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);
}
}
/* 开一路只动那一路的那一个脚。拦的是「rail−1 当下标」那类错位:
错位之后打开的是别人,而功能上「确实有一路开了」。 */
static void test_enabling_one_rail_touches_exactly_one_pin(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init());
for (int on = 1; on <= N; on++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(on, true));
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));
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));
}
assert_all_efuse_off();
}
/* 高有效:true = 导通。写反了「命令开电结果全关」一眼看得出来,
但**关电命令会变成开电**,那才是危险的一半。 */
static void test_enable_is_active_high(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init());
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(1, true));
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(rail_cfg(1)->efuse_en));
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(1, false));
TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(1)->efuse_en));
}
/* 影子**只在写成功时更新**。A10 的判据靠它决定「这一路现在该不该有电」:
影子说 on、线上其实没写进去,判据就会武装在一条关着的路上,
于是每一圈都得出「偏低 3900 mV」并报过压/欠压故障。 */
static void test_shadow_only_moves_when_the_write_succeeded(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init());
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(3, true));
TEST_ASSERT_TRUE(rail_efuse_is_on(3));
/* 把那个脚改回输入 —— 之后写它必然失败(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 ⇒ 判据继续武装");
/* 越界的路号既不许崩,也不许把别人的影子改掉。 */
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_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_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_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_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_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_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_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);
return UNITY_END();
}