文件
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

686 行
32 KiB
C

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/* 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"
/* ---- 脚手架 -------------------------------------------------------------- */
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 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()); /* 顺带把通道全关 */
}
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)
{
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
sel(i);
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_init(i));
}
}
/* 绕开驱动往当前通道上那片写一个寄存器。用来演示「假器件真的会照出问题」。 */
static hal_err_t raw_write(uint8_t reg, uint8_t val)
{
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));
}
}
/* 上一条的加强版: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 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_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)); /* 只错一位也是对地短路 */
}
/* 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)
{
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()); /* 真板子上这一刻开始冒烟 */
}
/* 同上,但先把锁存器写成 0 再配成输出:不冒烟,可仍然是在驱动一个接地脚
(下一次写 Port0 就会把它拉高)。两个计数分开就是为了不漏掉这一格。 */
static void test_sim_counts_driving_a_grounded_pin_even_when_low(void)
{
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());
}
/* Port1 被配成输出:`gnd_driven` 只看 bit4~7,所以这里同时钉住
「dir_safe 把 bit0~3 也管住了」这件事 —— 它是判据,不是假器件能照出来的。 */
static void test_sim_catches_port1_configured_as_output(void)
{
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());
}
/* ---- 位分配与地址 -------------------------------------------------------- */
static void test_bit_map_is_self_consistent(void)
{
TEST_ASSERT_TRUE(pca9555_map_ok());
/* 五个输出位正好铺满允许配成输出的那五位,一位不多一位不少。 */
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)); /* 没落到接地脚上 */
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));
}
/* 三个键的位号互不重复、都在输出白名单里。`pca9555_key_mask()` 是
`t_actuator` 的「防御性松键」用的那个掩码,少一位就是「有个键松不掉」。 */
static void test_key_bits_and_mask(void)
{
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_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));
}
/* 四个信号**两种极性**,一位一位单独拉低,四个判读函数各测一次。
极性写反的现象是「有故障被读成电源良好」—— 方向正好相反,是最坏的那一种:
系统不但不保护,还会更自信。所以每一格都要:拉低这一位时它变了,
**而另外三个判读一动不动**(端口内串位的现象和极性写反长得一模一样)。 */
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)
{
init_all();
sel(1);
sim_pca9555_drive(1, 1, 0x00);
pca9555_in_t in;
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
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));
}
/* 读的是**当前通道上那一片**。五片同址,读错通道 = 读到另一台手机的故障状态,
而量纲、范围、变化趋势全部正常。这条把「同址靠通道区分」真的跑一遍。 */
static void test_read_inputs_reads_the_blade_on_the_current_channel(void)
{
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));
TEST_ASSERT_EQUAL_INT(i == 3, pca9555_efuse_fault(&in));
}
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* §10.3 的总线预算是按**一次事务**算的(写命令字节 + 重复起始读 2 字节)。
拆成两次照样能跑,但 t_safety 一圈的 I²C 占用会对不上 §5.5 那张表。 */
static void test_read_inputs_uses_one_transaction(void)
{
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;
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
TEST_ASSERT_EQUAL_UINT32(1, d->n_write - w0);
TEST_ASSERT_EQUAL_UINT32(1, d->n_read - r0);
}
/* §10.10:这片不应答只是失去可见性,驱动如实返回错误码、什么动作都不做,
**也不许改写调用方的缓冲** —— 半新半旧的一帧比明着失败危险得多。 */
static void test_read_inputs_propagates_nak_without_touching_the_buffer(void)
{
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(0x55, in.port1);
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_read_inputs(NULL));
}
/* ---- probe ---------------------------------------------------------------- */
/* probe 的语义是「这是一片**刚上电**的 PCA9555 吗」:两个方向寄存器都得是 0xFF。 */
static void test_probe_accepts_a_fresh_blade(void)
{
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
sel(i);
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_probe());
}
}
/* ⚠️ 已经被 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());
}
/* 这地址上蹲着别的东西(应答了,方向寄存器却不是复位值)。 */
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());
}
/* 刀片拔了 / 那条支路没选通 → 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_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_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_read_inputs_reads_the_blade_on_the_current_channel);
RUN_TEST(test_read_inputs_uses_one_transaction);
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_naks_when_the_blade_is_not_reachable);
return UNITY_END();
}