文件
phonerail/firmware/test/test_t_safety_rules.c
T
zhou yang和Claude Opus 5.5 1f6862ac38 固件 0.1.1-c5:硬重启后自动开机、MQTT 发布死锁、欠压放宽到 −250 mV
- 硬重启等到 PG 后长按电源键 5 s(新状态 hr-boot):电池端上电的小米8
  停在关机态,原来的 power_cycle 等于关机。ACT_KEY_MS_ON 2000 → 5000
  (实测 2 s 松手即掉电)。NO_PG 时不按。
- 修死锁:执行器持 s_mtx 等 esp-mqtt 的 api_lock,esp-mqtt 任务持 api_lock
  分发 DISCONNECTED 时等 s_mtx。热点一断 USB/WiFi 全失联、按键可能松不开。
  执行器现在只往 s_outbox 拷贝,由 t_link 交给 esp-mqtt。
- SR-5 欠压一侧 −100 → −250 mV(T_SAFETY_UNDER_MV),过压仍 +100 mV:
  带屏开机峰值 2.1 A 时实测 −115 mV 被误关。需求 SR-5 / SW-2 / SW-5 / 验收表同步。

板 B 上板:power_cycle → hr-boot → 手机开机,最低 3778 mV / 2.12 A 不再跳闸。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 01:13:58 +08:00

1240 行
55 KiB
C
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/* 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 <string.h>
#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(250, T_SAFETY_UNDER_MV); /* 欠压一侧,2026-09-28 放宽 */
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(300 > T_SAFETY_UNDER_MV); /* 欠压注入 −300 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 - 300, 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 - 300, 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_UNDER_MV, 1000000);
beats(10);
TEST_ASSERT_EQUAL_INT(1, en_level(1));
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
}
/* ⭐ 上板实测那一幕:手机开机峰值 2.1 A 时读回 −115 mV。放宽之前这里会关路,
把正在开机的手机直接断电。过压那一侧同样的偏差照旧要关。 */
static void test_boot_current_droop_does_not_cut_the_rail(void)
{
power_up(1);
inject(1, NOMINAL_MV - 115, 2136000);
beats(10);
TEST_ASSERT_EQUAL_INT(1, en_level(1));
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
inject(1, NOMINAL_MV + 115, 1000000);
beats(3);
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type);
}
/* 比的是**该路 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:拿设定值比才是「偏高」。
(原来是调到最高档再回 3900 判偏低 110 mV —— 欠压放宽到 −250 mV 之后那已经不越界了。) */
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_set_mv(1, RAIL_MV_MIN));
inject(1, NOMINAL_MV, 1000000);
beats(2);
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_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 - 300, 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 - 300, 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 - 300, 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_boot_current_droop_does_not_cut_the_rail);
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();
}