A10:安全巡检的判据(SR-5 / SW-8)

判据全在 t_safety_step() 里、**放锁之后**、直接写引脚(§10.3 原话:不经任何
队列或抽象层):

  SR-5   |实测 − 该路设定值| > 100 mV **连着两圈** → 先断 VBUS 再关 eFuse
  SW-8   I < −50 mA,或 VBUS 期间电压被顶高 > +100 mV → **一圈就断 VBUS**

七条实现约定(详见需求文档 §10.2):

· 「持续 20 ms」数的是圈,不是墙钟毫秒;
· 反灌不设确认窗口,与过压那 20 ms 的不对称是有意的 —— 误断 VBUS 只丢一次
  USB 数据,漏判会把能量泵回 12V 母线连累另外四台(设计文档 §15.2);
· 判据只在「该路该有电 + 这一圈的值新鲜 + 过了 60 ms 缓启动窗口」时武装
  (对应 §10.10 的「盲跑 → 保持供电,禁用该路判据」);
· 比的是该路 DAC 设定值的影子,不是 3900 这个常数(SW-4 要单路微调);
· SR-5 关路关干净(不留「只靠 USB 供着电」的窗口),SW-8 只断 VBUS 不碰 eFuse;
· 断电之前先 t_actuator_abort(),断 VBUS 时只打断 EDL(只有它写 VBUS_EN);
· 动作在前、记录在后,事件走回调出去,A11 在那儿接环形缓冲。

判据一条 I²C 事务都不发(设定值、两根 EN、正在跑的序列全部走影子),
§10.3 那 7.8 % 的预算不变。顺带新增两处影子:rail_efuse_is_on()/rail_vbus_is_on()
与 rail_setpoint_mv(),都**只在写成功时更新**,两个方向都倒向安全的那一侧。

29 个用例;25 条注错逐个跑过,24 条变红。第一版把「持续 20 ms」按墙钟算,
测试一半红一半绿 —— 巡检的采样时刻带亚毫秒残差(一圈占 1.56 ms 总线,
而睡到下一拍按整毫秒算),相邻两次判据的时刻是 21/19 ms 交替,19 ms 那一半
要拖到第三圈 = 40 ms,超验收且间歇性。改成数圈之后一次不飘,
并留了一条跑 20 轮、两种相位都踩得到的回归拦网。

Co-Authored-By: Claude Opus 5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-07 08:41:53 +08:00
共同撰写人 Claude Opus 5
父节点 b3206005ab
当前提交 273c53347e
共修改 11 个文件,包含 1354 行新增和 206 行删除
+744
查看文件
@@ -0,0 +1,744 @@
/* A10:安全巡检的两条判据(SR-5 / SW-8)。计划里这一包的验收原话是
「注入过压 → 20 ms 内关路并记事件;注入 −60 mA → 断 VBUS」。
⚠️ 断言一律打在**线上真实发生的事**上:两根 EN 线看 `sim_gpio_level()` 与
全局写序号 `sim_gpio_write_seq()`(只看终值的话,「先断 VBUS 再关 eFuse」
和反过来长得一模一样),电压电流从**假 INA226 的寄存器**注入 ——
直接改快照里的 `bus_mv` 等于跳过驱动,那样连「读的是哪一片」都证明不了。
本文件里的 `beat()` 就是真板上两个任务合起来干的事:推一步执行器、跑一圈巡检、
睡到下一个 20 ms 格子。判据的「持续 20 ms」只有在真的按 20 ms 打拍子时才有意义。 */
#include <string.h>
#include "hal_gpio.h"
#include "hal_i2c.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "ina226.h"
#include "pca9555.h"
#include "phonerail.h"
#include "rail.h"
#include "rail_cfg.h"
#include "sched.h"
#include "sim_hal.h"
#include "sr595.h"
#include "t_actuator.h"
#include "t_safety.h"
#include "tasks.h"
#include "unity.h"
#define N PHONERAIL_RAIL_COUNT
/* 稳态:五路都设在 3900 mV(`RAIL_MV_SAFE`),电流 1 A 正向。 */
#define NOMINAL_MV 3900
static safety_snap_t g_snap;
static sched_periodic_t g_tick;
/* ---- 事件出口:把 A11 的环形缓冲换成一个能断言的小盒子 ------------------- */
static safety_event_t g_ev[8];
static int g_n_ev;
/* 回调被调的那一刻两把锁都不该被持着(t_safety.c 规矩 1)。 */
static bool g_ev_held_lock;
/* 事件被记下来的那一刻,动作**必须已经做完了**(t_safety.c:先保命,再记账)。
顺序反过来在功能上看不出任何区别 —— 除非像这里一样,在记录的那一刻回头看线。 */
static bool g_ev_before_action;
static void catch_event(void *ctx, const safety_event_t *ev)
{
(void)ctx;
if (hal_lock_held(HAL_LOCK_I2C) || hal_lock_held(HAL_LOCK_SR595)) {
g_ev_held_lock = true;
}
switch ((safety_ev_t)ev->type) {
case SAFETY_EV_OVER_VOLT:
case SAFETY_EV_UNDER_VOLT:
if (rail_efuse_is_on(ev->rail) || rail_vbus_is_on(ev->rail)) {
g_ev_before_action = true; /* SR-5:记这条时电早该断了 */
}
break;
case SAFETY_EV_REVERSE:
case SAFETY_EV_PUSHED_UP:
if (rail_vbus_is_on(ev->rail)) {
g_ev_before_action = true; /* SW-8:记这条时 VBUS 早该断了 */
}
break;
default:
break;
}
if (g_n_ev < (int)(sizeof(g_ev) / sizeof(g_ev[0]))) {
g_ev[g_n_ev] = *ev;
}
g_n_ev++;
}
/* ---- 假板子 -------------------------------------------------------------- */
/* 把某一路的实测值注进假 INA226 的寄存器(mV 必须是 5 的倍数:1.25 mV/LSB)。 */
static void inject(int rail, int mv, int ua)
{
TEST_ASSERT_EQUAL_INT(0, mv % 5);
sim_i2c_set_reg(rail_cfg(rail)->ina_addr, INA226_REG_BUS_V, (uint16_t)(mv * 4 / 5));
sim_i2c_set_reg(rail_cfg(rail)->ina_addr, INA226_REG_CURRENT,
(uint16_t)(int16_t)(ua / INA226_CURRENT_LSB_UA));
}
static void board_up(void)
{
const hal_i2c_cfg_t cfg = { .sda_gpio = 14, .scl_gpio = 15, .freq_hz = 400000 };
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init());
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); /* 十根 EN 线,全部拉低 */
rail_setpoint_reset(); /* 五路设定值回到 NVM 默认 */
for (int rail = 1; rail <= N; rail++) {
sim_i2c_add(rail_cfg(rail)->ina_addr, 2);
inject(rail, NOMINAL_MV, 1000000); /* 3.900 V / 1 A,稳稳当当 */
}
sim_i2c_add(INA226_ADDR_INPUT, 2);
sim_pca9555_attach(PCA9555_ADDR);
sim_sr595_attach(SR595_PIN_SER, SR595_PIN_SRCLK, SR595_PIN_RCLK, SR595_PIN_OE_N);
TEST_ASSERT_EQUAL_INT(HAL_OK, sr595_init());
t_actuator_init(NULL, NULL); /* PG「永远读不到」—— 硬重启会走完并记 NO_PG */
g_n_ev = 0;
g_ev_held_lock = false;
g_ev_before_action = false;
memset(g_ev, 0, sizeof(g_ev));
t_safety_set_event_sink(catch_event, NULL);
t_safety_init(&g_snap);
sched_periodic_init(&g_tick, task_desc(TASK_SAFETY)->period_ms);
}
void setUp(void)
{
sim_hal_reset();
board_up();
}
void tearDown(void) {}
/* 一拍 = 真板上两个任务在这 20 ms 里干的事。执行器要先推,因为判据打断的正是它。 */
static void beat(void)
{
(void)t_actuator_step();
(void)t_safety_step(&g_snap);
sched_periodic_wait(&g_tick);
}
static void beats(int n)
{
for (int i = 0; i < n; i++) {
beat();
}
}
/* 开一路电并熬过缓启动窗口。判据在这个窗口里是**不武装**的(斜坡还没走完,
这时候比电压等于每次上电都自己把自己关掉)。 */
static void power_up(int rail)
{
const int before = g_n_ev;
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, true));
beats(1 + (T_SAFETY_SETTLE_MS / (int)task_desc(TASK_SAFETY)->period_ms) + 1);
TEST_ASSERT_TRUE(rail_efuse_is_on(rail));
TEST_ASSERT_EQUAL_INT(before, g_n_ev); /* 上电加稳态几拍,不该冒出新事件 */
}
static int en_level(int rail)
{
return sim_gpio_level(rail_cfg(rail)->efuse_en);
}
static int vbus_level(int rail)
{
return sim_gpio_level(rail_cfg(rail)->vbus_en);
}
/* ---- 常量与文档对账(改了任何一个就是改了需求) -------------------------- */
static void test_thresholds_match_the_documents(void)
{
/* SR-5 / SW-2 的两个数,逐字来自需求 §9 那两行。 */
TEST_ASSERT_EQUAL_INT(100, T_SAFETY_DEV_MV);
TEST_ASSERT_EQUAL_INT(20, T_SAFETY_DEV_HOLD_MS);
/* 判据数的是**圈**,SR-5 写的是**毫秒** —— 这一条把两者钉在一起。
巡检周期哪天改了,它会当场对不上,而不是悄悄把「持续 20 ms」变成 40 ms。 */
TEST_ASSERT_EQUAL_INT(2, T_SAFETY_DEV_HOLD_CYCLES);
TEST_ASSERT_EQUAL_UINT32((T_SAFETY_DEV_HOLD_CYCLES - 1) * task_desc(TASK_SAFETY)->period_ms,
(uint32_t)T_SAFETY_DEV_HOLD_MS);
/* SW-8 的两个数:设计文档 §15.2 对策 2 的 −50 mA,需求 SW-8 的 200 ms 预算。 */
TEST_ASSERT_EQUAL_INT32(-50000, T_SAFETY_REV_UA);
TEST_ASSERT_EQUAL_INT(200, T_SAFETY_REV_BUDGET_MS);
/* 反灌不设确认窗口 ⇒ 最坏 2 个周期,必须离 200 ms 的预算远得看得出来。 */
TEST_ASSERT_TRUE(2 * (int)task_desc(TASK_SAFETY)->period_ms < T_SAFETY_REV_BUDGET_MS / 2);
/* 缓启动窗口必须盖得住 tON + 斜坡那 17 ms(§7.4),而且要是周期的整数倍
以上 —— 比一个周期还短的窗口等于没有。 */
TEST_ASSERT_TRUE(T_SAFETY_SETTLE_MS > (int)ACT_HR_RAMP_MS);
TEST_ASSERT_TRUE(T_SAFETY_SETTLE_MS >= 2 * (int)task_desc(TASK_SAFETY)->period_ms);
/* §12.1 注入的是 ±150 mV / −60 mA,两个都必须落在阈值**外侧**,
否则验收测的是「阈值刚好卡在注入值上」。 */
TEST_ASSERT_TRUE(150 > T_SAFETY_DEV_MV);
TEST_ASSERT_TRUE(-60000 < T_SAFETY_REV_UA);
}
/* ---- 验收主项一:注入过压 → 20 ms 内关路并记事件 ------------------------- */
static void test_overvoltage_cuts_the_rail_within_one_period_and_logs_it(void)
{
power_up(1);
inject(1, NOMINAL_MV + 150, 1000000); /* §12.1 的 +150 mV */
beat();
TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 第一次看见:只起表,不动电 */
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
beat();
TEST_ASSERT_EQUAL_INT(0, en_level(1)); /* 满 20 ms:这一拍断电 */
TEST_ASSERT_EQUAL_INT(1, g_n_ev);
TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type);
TEST_ASSERT_EQUAL_UINT8(1, g_ev[0].rail);
TEST_ASSERT_EQUAL_INT32(NOMINAL_MV + 150, g_ev[0].bus_mv); /* 记的是实测值 */
TEST_ASSERT_EQUAL_INT32(1000000, g_ev[0].current_ua);
TEST_ASSERT_FALSE(g_ev_held_lock);
TEST_ASSERT_FALSE(g_ev_before_action);
TEST_ASSERT_EQUAL_UINT32(1, g_snap.events);
}
/* 从**第一次看见**到断电正好一个周期。
⚠️ 这一条把假总线的耗时关掉(sim_hal.h 允许的那个正当理由:拿虚拟时钟
当纯计数器)—— 开着的话每一拍还要加上巡检自己占的 1.56 ms 总线时间,
量出来是 19/21 ms 交替,那 1 ms 是巡检花的,不是判据晚了。 */
static void test_the_cut_lands_exactly_one_hold_window_after_first_sight(void)
{
sim_i2c_set_bus_time(false);
power_up(2);
inject(2, NOMINAL_MV - 150, 1000000);
const uint32_t t_first_sight = hal_time_now_ms();
beat();
beat();
TEST_ASSERT_EQUAL_INT(1, g_n_ev);
TEST_ASSERT_EQUAL_UINT32(T_SAFETY_DEV_HOLD_MS, g_ev[0].t_ms - t_first_sight);
}
/* ⭐ 同一件事,把总线耗时打开(也就是真板上的样子):判据必须**照样在第二圈**
断电,量出来的墙钟是 19 或 21 ms。
这一条是本包最值钱的回归拦网:判据一旦改回按墙钟算 `>= 20 ms`,19 ms 那半数
情况就要等到第三圈 —— 40 ms,超了 §12.1 的验收,而且是**间歇性**的
(同一个故障注两次,一次过一次不过)。它跑 20 次,两种相位都会踩到。 */
static void test_the_cut_is_never_late_because_of_sub_millisecond_jitter(void)
{
power_up(2);
for (int i = 0; i < 20; i++) {
/* 每一轮都从「刚开电」重来,让第一次看见落在不同的亚毫秒相位上。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(2, true));
inject(2, NOMINAL_MV, 1000000);
beats(6); /* 熬过缓启动窗口 */
g_n_ev = 0;
inject(2, NOMINAL_MV + 150, 1000000);
const uint32_t t0 = hal_time_now_ms();
beat();
TEST_ASSERT_EQUAL_INT(1, en_level(2)); /* 第一圈只起表 */
beat();
TEST_ASSERT_EQUAL_INT(0, en_level(2)); /* 第二圈必须断,一次都不许拖 */
/* 墙钟量出来是 19~22 ms:一圈自己占 1.56 ms 总线,加上毫秒截断。
那几毫秒是巡检花的,不是判据晚的 —— 判据数的是圈,上面两条断言才是主判据。 */
const uint32_t late = g_ev[0].t_ms - t0;
TEST_ASSERT_TRUE(late >= T_SAFETY_DEV_HOLD_MS - 2 && late <= T_SAFETY_DEV_HOLD_MS + 2);
}
}
/* 偏低同样关路(SR-5 是 ±,不是「过压」)。掉压的现因多半是排线接触不良,
而那正是这块板子最常见的故障(§10.7 开头那句)。 */
static void test_undervoltage_cuts_the_rail_too(void)
{
power_up(3);
inject(3, NOMINAL_MV - 150, 1000000);
beats(2);
TEST_ASSERT_EQUAL_INT(0, en_level(3));
TEST_ASSERT_EQUAL_INT(1, g_n_ev);
TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type);
}
/* 「持续」的意思是连续。只闪了一圈就回到范围内的偏差不许关路 ——
不然一次负载阶跃(0→4 A)就能把五台手机全关掉。 */
static void test_a_one_cycle_glitch_does_not_cut_anything(void)
{
power_up(1);
for (int i = 0; i < 20; i++) {
inject(1, NOMINAL_MV + 150, 1000000);
beat();
inject(1, NOMINAL_MV, 1000000);
beat();
}
TEST_ASSERT_EQUAL_INT(1, en_level(1));
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
}
/* 阈值是「**超过** 100 mV」:正好 100 mV 不算。
写成 >= 的话,SW-4 允许运维把某一路微调 100 mV 之后那一路就再也开不起来。 */
static void test_exactly_one_hundred_millivolts_is_still_in_range(void)
{
power_up(1);
inject(1, NOMINAL_MV + T_SAFETY_DEV_MV, 1000000);
beats(10);
TEST_ASSERT_EQUAL_INT(1, en_level(1));
inject(1, NOMINAL_MV - T_SAFETY_DEV_MV, 1000000);
beats(10);
TEST_ASSERT_EQUAL_INT(1, en_level(1));
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
}
/* 比的是**该路 DAC 的设定值**,不是 3900 这个常数。SW-4 允许单路微调,
拿常数比的话,一台被调到 4.0 V 的手机会被当成过压关掉 —— 而且只有那一路。 */
static void test_the_reference_is_the_setpoint_not_a_constant(void)
{
/* 假两片 DAC,把 CH1 调到 4010 mV(SR-13 的上限)。 */
sim_i2c_add(0x4A, 2);
sim_i2c_add(0x4B, 2);
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_set_mv(1, RAIL_MV_MAX));
TEST_ASSERT_EQUAL_INT(RAIL_MV_MAX, rail_setpoint_mv(1));
inject(1, RAIL_MV_MAX, 1000000); /* 实测跟着设定走 */
power_up(1);
beats(10);
TEST_ASSERT_EQUAL_INT(1, en_level(1));
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
/* 回到 3900:现在这才是「偏低 110 mV」。 */
inject(1, NOMINAL_MV, 1000000);
beats(2);
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type);
}
/* ---- 判据什么时候**不**武装(三道闸,每一道都是一次真事故的预防) -------- */
/* 关着的路不判据。不设这一条的现象是:巡检每 20 ms 就「发现」五路各偏低
3900 mV,然后没完没了地去关已经关着的路、刷满事件日志。 */
static void test_a_rail_that_is_off_is_never_judged(void)
{
inject(1, 0, 0); /* 关着的路就是 0 V —— 离设定值差着 3900 mV */
beats(50);
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
TEST_ASSERT_EQUAL_UINT32(0, sim_gpio_writes(rail_cfg(1)->efuse_en) - 1u); /* 只有 init 那一次 */
}
/* 刚开电的那几十毫秒不判据:tON 5.1 ms + 斜坡 11.5 ms 期间电压本来就在爬。
没有这个窗口的现象是**每次上电都自己把自己关掉**,而且五路一起。 */
static void test_the_soft_start_window_is_not_judged(void)
{
inject(1, 1200, 100000); /* 斜坡爬到一半 */
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(1, true));
const int in_window = T_SAFETY_SETTLE_MS / (int)task_desc(TASK_SAFETY)->period_ms;
beats(in_window); /* 窗口之内:不管电压多离谱都不判 */
TEST_ASSERT_EQUAL_INT(1, en_level(1));
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
/* 窗口一过,判据武装,还是这么低就该关了。 */
beats(3);
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type);
}
/* 器件失联的那一路不判据(§10.10:「保持供电,标记为盲跑,**禁用该路的
SR-5 / SW-8 判据**」)。快照里留着的是上一圈的值,拿它去关电是最难查的那种错。
两条判据共用同一道闸,所以这里拿 SR-5 验它就够。 */
static void test_a_blind_rail_keeps_its_power(void)
{
power_up(4);
/* ⚠️ 顺序是有讲究的:**先让坏值真的读进快照一次**,再让器件失联。
反过来写(先失联再改寄存器)的话快照里躺着的是个正常值,
「盲跑那一路照样判据」这个错就一点痕迹都不会留下 —— 测试永远绿。 */
inject(4, NOMINAL_MV - 150, 1000000);
beat();
TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* 第一个样本只起表 */
sim_i2c_set_present(rail_cfg(4)->ina_addr, false);
beats(50); /* 1 秒,远超两条判据各自的窗口 */
TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* §10.10:盲跑 = 保持供电 */
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
TEST_ASSERT_FALSE(g_snap.rail_ok[3]);
/* 快照里躺着的确实是那个越界的旧值 —— 这一条是上面那句「顺序有讲究」的凭据。 */
TEST_ASSERT_EQUAL_INT32(NOMINAL_MV - 150, g_snap.rail[3].bus_mv);
/* 器件回来了,值还是坏的 —— 这时才该关,而且要重新数满 20 ms。 */
sim_i2c_set_present(rail_cfg(4)->ina_addr, true);
beat();
TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* 第一个新鲜样本只起表 */
beat();
TEST_ASSERT_EQUAL_INT(0, en_level(4));
}
/* ---- 关路这件事本身 ------------------------------------------------------ */
/* 只关出事的那一路,另外四路一根线都不许动。 */
static void test_only_the_offending_rail_is_touched(void)
{
for (int rail = 1; rail <= N; rail++) {
power_up(rail);
}
const uint32_t before[N] = {
sim_gpio_writes(rail_cfg(1)->efuse_en), sim_gpio_writes(rail_cfg(2)->efuse_en),
sim_gpio_writes(rail_cfg(3)->efuse_en), sim_gpio_writes(rail_cfg(4)->efuse_en),
sim_gpio_writes(rail_cfg(5)->efuse_en),
};
inject(3, NOMINAL_MV + 150, 1000000);
beats(2);
TEST_ASSERT_EQUAL_INT(0, en_level(3));
for (int rail = 1; rail <= N; rail++) {
if (rail == 3) {
continue;
}
TEST_ASSERT_EQUAL_INT(1, en_level(rail));
TEST_ASSERT_EQUAL_UINT32(before[rail - 1], sim_gpio_writes(rail_cfg(rail)->efuse_en));
TEST_ASSERT_EQUAL_UINT32(0, sim_gpio_writes(rail_cfg(rail)->vbus_en) - 1u);
}
TEST_ASSERT_EQUAL_INT(1, g_n_ev);
}
/* 关路 = **先断 VBUS 再关 eFuse**(`rail_all_off()` 的同一条理由:中间不留
「手机只靠 USB 供着电」那个既不是开也不是关的窗口)。
只看两个脚的终值是看不出顺序的,所以看全局写序号。 */
static void test_the_cut_drops_vbus_before_the_efuse(void)
{
power_up(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true));
inject(1, NOMINAL_MV - 150, 1000000); /* 欠压,不是反灌 */
beats(2);
TEST_ASSERT_EQUAL_INT(0, vbus_level(1));
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_TRUE(sim_gpio_write_seq(rail_cfg(1)->vbus_en) <
sim_gpio_write_seq(rail_cfg(1)->efuse_en));
}
/* 关掉之后判据自己解除武装:不再刷第二条事件,也不再去写那两个脚。 */
static void test_a_cut_rail_does_not_keep_firing(void)
{
power_up(1);
inject(1, NOMINAL_MV + 150, 1000000);
beats(2);
TEST_ASSERT_EQUAL_INT(1, g_n_ev);
const uint32_t writes = sim_gpio_writes(rail_cfg(1)->efuse_en);
beats(100); /* 2 秒 */
TEST_ASSERT_EQUAL_INT(1, g_n_ev);
TEST_ASSERT_EQUAL_UINT32(writes, sim_gpio_writes(rail_cfg(1)->efuse_en));
}
/* 被关掉的那一路重新上电之后,确认窗口要**从头数**。
`dev_n` 关路时不清零的话,这一路下次开电只要看见一个越界样本就立刻跳闸 ——
少了整个确认窗口,而现象只是「这一路特别容易掉」,查不到根上。 */
static void test_a_rail_that_comes_back_starts_its_hold_window_over(void)
{
power_up(1);
inject(1, NOMINAL_MV + 150, 1000000);
beats(2);
TEST_ASSERT_EQUAL_INT(0, en_level(1));
inject(1, NOMINAL_MV, 1000000); /* 修好了,重新上电 */
power_up(1);
inject(1, NOMINAL_MV + 150, 1000000); /* 又坏了 */
beat();
TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 第一圈还是只起表,不是立刻跳 */
beat();
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_EQUAL_INT(2, g_n_ev);
}
/* ⭐ 关路之前必须先 `t_actuator_abort()`。
不 abort 的现象最阴:硬重启的 `HR_OFF` 会在 4 s 之后自己把 `EFUSE_EN` 抬回去,
于是「过压关掉的那一路」几秒钟后又带电了,而事件日志里只有一条「已关路」。 */
static void test_a_running_hard_reset_cannot_bring_the_rail_back(void)
{
power_up(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(1));
/* 断电窗口 4 s 之后 `EFUSE_EN` 被抬回来,序列进入等 PG(PG 永远读不到)。 */
beats((ACT_HR_OFF_MS + T_SAFETY_SETTLE_MS) / 20 + 2);
TEST_ASSERT_EQUAL_INT(1, en_level(1));
TEST_ASSERT_EQUAL_INT(ACT_ST_HR_PG, (int)t_actuator_state(1));
inject(1, NOMINAL_MV + 150, 1000000);
beats(2);
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1));
/* 再跑 10 秒 —— 电不许自己回来。 */
beats(500);
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_EQUAL_INT(1, g_n_ev);
}
/* abort 顺带把按下去的键松开了。按键序列跑到一半被过压打断,
键要是留在按下状态,手机再也起不来,而遥测上五路电压电流全都正常。 */
static void test_a_cut_releases_whatever_key_was_held(void)
{
power_up(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON));
beat();
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(rail_cfg(1)->key_bit[SR595_KEY_PWR]));
inject(1, NOMINAL_MV + 150, 1000000);
beats(3);
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(rail_cfg(1)->key_bit[SR595_KEY_PWR]));
TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1));
}
/* ---- 验收主项二:注入 −60 mA → 断 VBUS(SW-8) --------------------------- */
static void test_reverse_current_drops_vbus(void)
{
power_up(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true));
inject(1, NOMINAL_MV, -60000); /* §12.1 的 −60 mA */
const uint32_t t0 = hal_time_now_ms();
beat();
TEST_ASSERT_EQUAL_INT(0, vbus_level(1));
TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* **只断 VBUS**,直供不动 */
TEST_ASSERT_EQUAL_INT(1, g_n_ev);
TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_REVERSE, g_ev[0].type);
TEST_ASSERT_EQUAL_INT32(-60000, g_ev[0].current_ua);
TEST_ASSERT_FALSE(g_ev_before_action);
/* SW-8 的预算是 200 ms,实际是一圈。 */
TEST_ASSERT_TRUE(hal_time_elapsed_ms(t0) < (uint32_t)T_SAFETY_REV_BUDGET_MS);
}
/* 反灌**不等确认窗口**(与过压判据有意不对称):一圈看见就断。
误断 VBUS 只丢 USB 数据,漏判会把能量泵回 12V 母线连累另外四台。 */
static void test_reverse_current_is_not_debounced(void)
{
power_up(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true));
inject(1, NOMINAL_MV, -60000);
beat(); /* 就一圈 */
TEST_ASSERT_EQUAL_INT(0, vbus_level(1));
}
/* 没到 −50 mA 不算。手机在直供下本来就会有小幅的双向电流。 */
static void test_a_small_negative_current_is_not_a_backfeed(void)
{
power_up(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true));
inject(1, NOMINAL_MV, -40000);
beats(50);
TEST_ASSERT_EQUAL_INT(1, vbus_level(1));
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
}
/* VBUS 本来就断着的时候不判反灌:这条判据只在「USB 那一侧通着」时武装,
也正是它让「断完之后不再每 20 ms 刷一条事件」成立。 */
static void test_backfeed_is_only_judged_while_vbus_is_on(void)
{
power_up(1); /* VBUS 没开 */
inject(1, NOMINAL_MV, -200000);
beats(50);
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
TEST_ASSERT_EQUAL_UINT32(0, sim_gpio_writes(rail_cfg(1)->vbus_en) - 1u);
}
/* 顶高:VBUS 期间电压被抬过设定值 +100 mV,同样立刻断 VBUS(设计文档 §15.2)。
然后 20 ms 后 SR-5 才决定要不要把这一路也关掉 —— 两条判据是分层的:
先撤掉能量来源,再决定要不要关电。 */
static void test_being_pushed_up_drops_vbus_first_then_sr5_cuts_the_rail(void)
{
power_up(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true));
inject(1, NOMINAL_MV + 150, 1000000);
beat();
TEST_ASSERT_EQUAL_INT(0, vbus_level(1)); /* 第一圈:VBUS 没了 */
TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 电还开着 */
TEST_ASSERT_EQUAL_INT(1, g_n_ev);
TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_PUSHED_UP, g_ev[0].type);
beat();
TEST_ASSERT_EQUAL_INT(0, en_level(1)); /* 第二圈:还是高,SR-5 关路 */
TEST_ASSERT_EQUAL_INT(2, g_n_ev);
TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[1].type);
}
/* ⭐ 断 VBUS 之前要打断 EDL 序列 —— 三个序列里只有它会写 `VBUS_EN`,
不打断的话下一拍它就把 VBUS 又抬回来了(`EDL_EN=1 → VBUS_EN=1 → 等枚举`)。 */
static void test_a_running_edl_sequence_cannot_bring_vbus_back(void)
{
power_up(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_edl(1));
beats(2); /* 短接 10 ms 之后 VBUS_EN 被抬起来,进入等枚举 */
TEST_ASSERT_EQUAL_INT(1, vbus_level(1));
TEST_ASSERT_EQUAL_INT(ACT_ST_EDL_ENUM, (int)t_actuator_state(1));
inject(1, NOMINAL_MV, -60000);
beat();
TEST_ASSERT_EQUAL_INT(0, vbus_level(1));
TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1));
beats(200); /* 4 秒 —— 枚举窗口 3 s 早过了,VBUS 不许自己回来 */
TEST_ASSERT_EQUAL_INT(0, vbus_level(1));
TEST_ASSERT_EQUAL_INT(1, g_n_ev);
}
/* 反灌不去打断跟 VBUS 无关的序列(硬重启只碰 `EFUSE_EN`)——
「出了事就把这一路上所有事都停掉」比看上去危险:那会把一次正在进行的
救砖重启打断,而重启和反灌是两码事。 */
static void test_a_backfeed_does_not_abort_an_unrelated_sequence(void)
{
power_up(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(1, true));
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_VUP, ACT_KEY_MS_ON));
inject(1, NOMINAL_MV, -60000);
beat();
TEST_ASSERT_EQUAL_INT(0, vbus_level(1));
TEST_ASSERT_TRUE(t_actuator_busy(1));
TEST_ASSERT_EQUAL_INT(ACT_ST_KEY_HOLD, (int)t_actuator_state(1));
}
/* 五路各判各的:CH5 反灌不许动 CH2 的 VBUS。 */
static void test_backfeed_is_per_rail(void)
{
power_up(2);
power_up(5);
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(2, true));
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_vbus_enable(5, true));
inject(5, NOMINAL_MV, -60000);
beats(5);
TEST_ASSERT_EQUAL_INT(0, vbus_level(5));
TEST_ASSERT_EQUAL_INT(1, vbus_level(2));
TEST_ASSERT_EQUAL_INT(1, en_level(5));
TEST_ASSERT_EQUAL_UINT8(5, g_ev[0].rail);
}
/* ---- 判据不许改变巡检的形状 ---------------------------------------------- */
/* 一条判据也不许发 I²C 事务:设定值、两根 EN、正在跑的序列全部有影子可查。
多读一次就把 §10.3 那 7.8 % 的预算顶穿。 */
static void test_the_rules_do_not_add_any_i2c_traffic(void)
{
power_up(1);
inject(1, NOMINAL_MV + 150, 1000000);
const uint32_t before = hal_i2c_acct_txns();
beat(); /* 起表那一圈 */
beat(); /* 断电那一圈 —— 判据在这一圈干了活 */
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_EQUAL_UINT32(2u * (INA226_COUNT * 2u + 1u), hal_i2c_acct_txns() - before);
TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us);
}
/* 判据跑在**放锁之后**:整圈的加锁违规为 0,出门锁已经放掉。
拿着 I²C 锁去 `t_actuator_abort()` 会撞上「两把锁不许嵌套」(hal_lock.h)。 */
static void test_the_rules_run_after_the_bus_lock_is_released(void)
{
power_up(1);
sim_lock_require(HAL_LOCK_I2C, true);
sim_lock_require(HAL_LOCK_SR595, true);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON));
inject(1, NOMINAL_MV + 150, 1000000);
beats(3);
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C));
TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_SR595));
TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C));
TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_SR595));
TEST_ASSERT_FALSE(g_ev_held_lock);
}
/* 拿不到总线锁的那一圈整圈作废,判据一条都不许跑 —— 那一圈的值是上一圈的。
跑的话,「总线忙」就会被当成「电压没变」,而实际上根本没读。 */
static void test_a_skipped_cycle_runs_no_rules(void)
{
power_up(1);
inject(1, NOMINAL_MV + 150, 1000000);
beat(); /* 起表 */
sim_lock_set_task(1); /* 假装 t_link 占着总线 */
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10));
sim_lock_set_task(0);
TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&g_snap));
TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 满 20 ms 了,但这一圈没数 */
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped);
sim_lock_set_task(1);
hal_lock_give(HAL_LOCK_I2C);
sim_lock_set_task(0);
beat(); /* 总线回来了,这一圈才断电 */
TEST_ASSERT_EQUAL_INT(0, en_level(1));
}
/* 没接事件出口也照样断电(A11 还没来,日志接不上不是不断电的理由)。 */
static void test_the_cut_happens_even_with_no_event_sink(void)
{
t_safety_set_event_sink(NULL, NULL);
power_up(1);
inject(1, NOMINAL_MV + 150, 1000000);
beats(2);
TEST_ASSERT_EQUAL_INT(0, en_level(1));
TEST_ASSERT_EQUAL_INT(0, g_n_ev);
TEST_ASSERT_EQUAL_UINT32(1, g_snap.events); /* 快照里仍然记着 */
TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_snap.last.type);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_thresholds_match_the_documents);
RUN_TEST(test_overvoltage_cuts_the_rail_within_one_period_and_logs_it);
RUN_TEST(test_the_cut_lands_exactly_one_hold_window_after_first_sight);
RUN_TEST(test_the_cut_is_never_late_because_of_sub_millisecond_jitter);
RUN_TEST(test_undervoltage_cuts_the_rail_too);
RUN_TEST(test_a_one_cycle_glitch_does_not_cut_anything);
RUN_TEST(test_exactly_one_hundred_millivolts_is_still_in_range);
RUN_TEST(test_the_reference_is_the_setpoint_not_a_constant);
RUN_TEST(test_a_rail_that_is_off_is_never_judged);
RUN_TEST(test_the_soft_start_window_is_not_judged);
RUN_TEST(test_a_blind_rail_keeps_its_power);
RUN_TEST(test_only_the_offending_rail_is_touched);
RUN_TEST(test_the_cut_drops_vbus_before_the_efuse);
RUN_TEST(test_a_cut_rail_does_not_keep_firing);
RUN_TEST(test_a_rail_that_comes_back_starts_its_hold_window_over);
RUN_TEST(test_a_running_hard_reset_cannot_bring_the_rail_back);
RUN_TEST(test_a_cut_releases_whatever_key_was_held);
RUN_TEST(test_reverse_current_drops_vbus);
RUN_TEST(test_reverse_current_is_not_debounced);
RUN_TEST(test_a_small_negative_current_is_not_a_backfeed);
RUN_TEST(test_backfeed_is_only_judged_while_vbus_is_on);
RUN_TEST(test_being_pushed_up_drops_vbus_first_then_sr5_cuts_the_rail);
RUN_TEST(test_a_running_edl_sequence_cannot_bring_vbus_back);
RUN_TEST(test_a_backfeed_does_not_abort_an_unrelated_sequence);
RUN_TEST(test_backfeed_is_per_rail);
RUN_TEST(test_the_rules_do_not_add_any_i2c_traffic);
RUN_TEST(test_the_rules_run_after_the_bus_lock_is_released);
RUN_TEST(test_a_skipped_cycle_runs_no_rules);
RUN_TEST(test_the_cut_happens_even_with_no_event_sink);
return UNITY_END();
}