/* 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(); }