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 行删除
+90 -78
查看文件
@@ -1,97 +1,109 @@
# 交接
更新:2026-09-07 完成任务包 **A9**(三个执行器状态机 + 互斥)
更新:2026-09-07 完成任务包 **A10**(安全巡检的判据:SR-5 / SW-8)
## 一句话现状
§10.6 的三个序列成了**每路一份**的状态机,全部以「收尾」收口:
硬重启 `EFUSE_EN↓ → 4 s → EFUSE_EN↑ → 17 ms → 等 PG`(等不到只记 `NO_PG`,**电继续开着**)、
按键 `按下 → 保持 T → 松开`(组合键一帧发完)、EDL `EDL_EN=1 → VBUS_EN=1 → 等枚举 → EDL_EN=0`。
一路上同时只能跑一个,被拒的请求一个字节都不发;**按下去的东西一定会松开**
(收尾写不进 595 就一直重试,绝不放弃)。PG 由 `t_safety` 的快照喂进来,
执行器自己不碰 I²C ⇒ 两把锁永远不会嵌套。
12 条 ctest(184 个用例)全绿,`idf.py build` 通过,**21 条注错逐个跑过全部变红**。
`t_safety_step()` 在**放锁之后**跑两条判据,越界直接写引脚(不发消息给谁):
SR-5「偏离设定 >±100 mV **连着两圈**」→ 先断 VBUS 再关 eFuse;
SW-8「I < −50 mA 或电压被顶高 >+100 mV」→ **一圈看见就断 VBUS**(不碰 eFuse)。
断电之前先 `t_actuator_abort()`(否则硬重启 4 s 后把电又抬回来),
动作做完才记事件;事件走一个回调出去,A11 在那儿接环形缓冲。
判据只在「该路该有电 + 这一圈的值新鲜 + 过了 60 ms 缓启动窗口」时武装。
13 条 ctest(213 个用例)全绿,`idf.py build` 通过,**25 条注错跑过 24 条变红**。
## 下一步
**A10**:安全巡检的判据(SR-5 / SW-8)。验收是「注入过压 → 20 ms 内关路并记事件;
注入 −60 mA → 断 VBUS」。判据写在 `t_safety_step()` 里、**放锁之后**,
直接 `rail_efuse_enable(rail,false)` / `rail_vbus_enable(rail,false)`,
不要发消息给 t_actuator(§10.3 原话:不经任何队列或抽象层)。
A9 给它备好的:`t_actuator_abort(rail)`(关路之前把那一路正在跑的序列停掉),
`safety_snap_t` 里的 `rail_ok[]`(判据先看 `ok` 再看值)。
**A11**:事件日志(环形缓冲 + 落盘)。验收是「写满 4096 条不丢最新;掉电重启后能读回」。
A10 给它备好的:`safety_event_t`(§10.7 的单条形状,16 B)与
`t_safety_set_event_sink(fn, ctx)` —— **只要把入队函数接上去就行,判据那一层不用动**。
`app_main.c` 里现在接的是一个打日志的临时出口(`log_safety_event()`),换掉它。
⚠️ 回调是在断电之后、不持任何锁的情况下被调的,入队函数不许阻塞。
## 本次新增/修改的文件
- `main/t_actuator.h` / `t_actuator.c` — 三个状态机 + 互斥 + 收尾(约 400 行,一半是注释)
· 时间常量都带出处:`ACT_HR_OFF_MS` 4000(§7.4 的 3~5 s 窗口,配 `_MIN_/_MAX_` 两个界)、
`ACT_HR_RAMP_MS` 17(tON 5.1 + 斜坡 11.5)、`ACT_HR_PG_TIMEOUT_MS` 500 + `_POLL_MS` 5、
`ACT_KEY_MS_TAP/ON/FORCE` 100/2000/10000(SW-5)、`ACT_KEY_MAX_MS` 15000(拦手误)、
`ACT_EDL_SHORT_MS` 10 + `ACT_EDL_ENUM_MS` 3000
· `act_keys_t` 是**逻辑键位**(PWR/VUP/VDN + FASTBOOT/RECOVERY 两个组合),
链上位号由 `sr595_key_bit()` 查表换算,本文件里没有一处算出来的位号
- `main/app_main.c` — 巡检快照挪到文件作用域 + `act_pg_from_safety()`;
`task_actuator` 任务体(三行);`t_actuator_init()` 在建任务之前
- `test/test_t_actuator.c` — 23 个用例
- `main/CMakeLists.txt` / `host/CMakeLists.txt` / `main/phonerail.h`(版本 `0.1.0-a9`)
- `../需求文档.md` §10.6 — 记 A9 落地 + 六条实现约定
- `main/t_safety.h` / `t_safety.c` — 两条判据 + 事件(约 200 行,一多半是注释)
· 阈值都带出处:`T_SAFETY_DEV_MV` 100(SR-5)、`T_SAFETY_DEV_HOLD_CYCLES` **2**
与折算值 `_HOLD_MS` 20、`T_SAFETY_REV_UA` −50000(设计文档 §15.2 对策 2)、
`T_SAFETY_REV_BUDGET_MS` 200(SW-8)、`T_SAFETY_SETTLE_MS` 60(tON+斜坡 17 ms 的 3 倍)
· `safety_ev_t` 四种事件 + `safety_event_t`(§10.7 的单条形状)+ 事件出口回调
· 每路判据状态 `safety_rail_t` 放在快照里,`t_safety_init()` 一并清干净
- `main/rail_cfg.h` / `.c` — 新增 `rail_efuse_is_on()` / `rail_vbus_is_on()`
(两根 EN 线的影子,**只在写成功时更新**)
- `main/rail.h` / `.c` — 新增 `rail_setpoint_mv()` / `rail_setpoint_reset()`
(五路设定值的影子,初值就是 DAC 的 NVM 默认 3900)
- `main/app_main.c` — `log_safety_event()` 临时事件出口;`t_safety_set_event_sink()`
接在建任务之前
- `test/test_t_safety_rules.c` — 29 个用例(新文件;A8 那份 `test_t_safety.c` 没动)
- `main/phonerail.h` — 版本 `0.1.0-a10`
- `../需求文档.md` §10.2 — 记 A10 落地 + 七条实现约定
## 验收命令与结果
```
$ bash host/test.sh
100% tests passed, 0 tests failed out of 12 # +test_t_actuator(23),共 184 例
100% tests passed, 0 tests failed out of 13 # +test_t_safety_rules(29),共 213 例
$ .\build_idf.ps1 # PowerShell
phonerail.bin binary size 0x3d240 bytes. ... 0xc2dc0 bytes (76%) free.
phonerail.bin binary size 0x3d650 bytes. ... 0xc29b0 bytes (76%) free.
Project build complete.
# 21 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库):
# 硬重启:不等那 17 ms / 断电窗口缩到 1 s / 断电后忘了开回来 / 只置状态不断电 /
# 等不到 PG 就关路 / PG 只在进入那一刻看一眼 / 等 PG 时一觉睡满 500 ms /
# 开始前不松键
# 按键: 组合键分两次写 / 位号改成用通道号算 / 不查「正在跑别的序列」/
# 时长上限没了 / 按下失败就直接返回不收尾
# EDL: 顺序反过来(先插 USB 再短接)/ 收尾时顺手把 VBUS 也断了 / 位号用算的
# 收尾: 松键写成「整条链的键位全清掉」/ 写不进去就放弃 / 结束直接回 IDLE /
# 换状态后不当场再走一步
# abort: 顺手把电开回来
# → 21 条全部变红。
# 25 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库):
# SR-5: 只判过压方向 / 阈值写成 >= / 看见一圈就关 / 回到范围内不清零 /
# **确认窗口改回按墙钟算 >= 20 ms** / 设定值写死 3900
# 闸门: 盲跑那一路照样判 / 关着的路照样判 / 没有缓启动窗口 / 跳过的那一圈也判
# 关路: 不 abort / 只关 eFuse 不断 VBUS / 顺序反过来 / 事件记在断电之前
# SW-8: 符号写反 / 不看 VBUS 通没通 / 顺手把 eFuse 也关了 /
# 不打断 EDL / 无条件打断这一路的序列 / 挪到 SR-5 后面
# 其它: 判据跑在放锁之前 / 顺手多读一次寄存器 / 事件里记设定值 / …
# → 24 条变红。剩下 1 条见下面「抓不到的那一条」。
```
## 踩到的坑(只写会再犯的)
- ⚠️ **「测试自己掌握节奏」会把「step 返回的睡眠时长」整个漏掉。** 本文件的
`run_ms()` 是按**测试**的节奏叫 `t_actuator_step()` 的,所以「等 PG 时一觉睡满
500 ms 超时」那条注错第一遍**没被抓住** —— 状态机的判据全对,只是任务会睡过头。
补了一个 `test_the_task_loop_only_sleeps_what_step_asks_for()`:完全听 step 的,
它说睡多久就睡多久,PG 在斜坡走完之后才置位,序列必须在一个轮询间隔内收工。
**凡是「返回一个时长给调用方去睡」的函数,都要有一个这样完全听它的测试。**
- ⚠️ **测完成时刻要在「不 busy」的那一步立刻记,不能等循环退出。** 上面那个测试
第一版把总时长量成了 4122 ms(真值 4022):最后一次 `sim_time_advance_ms(w)`
已经先把表拨走了 100 ms。多出来的正好是 `ACT_IDLE_WAIT_MS`,看起来像个真信号。
- **`t_actuator_step()` 返回的是五路的最小值**,空闲那几路各返回 100 ms ⇒
单路要求睡 500 ms 时实际只睡 100 ms。写针对某一路的时序断言时别忘了这个下限。
- **「按下失败」也必须走收尾。** 直接返回错误码就等于把一个脏的「按下」影子留下:
谁下一次 `sr595_flush()` 谁就替你把键按下去,而没有任何人会来松开它。
代价是那一路会短暂 busy 一下(请求仍然返回错误码),值。
- **`CLEANUP` 故意不设放弃上限。** 「重试 N 次就算了」是这里最自然的写法,
而它的含义正好是「595 出问题时把电源键永久按住」。宁可让这一路一直 busy、
把 `cleanup_retries` 报到遥测里,让人看见。
- **ESP-IDF 默认 `CONFIG_FREERTOS_HZ=100`(10 ms 一个 tick)**,`pdMS_TO_TICKS(5)`
是 **0**,而 `vTaskDelay(0)` 只是让一次 CPU。中优先级任务在收尾重试时会因此空转,
把 t_link / t_log 饿死。`app_main.c` 里钳到至少一个 tick。
早醒本身没关系:每个状态都是拿**绝对时刻**算的,早来就再返回一个剩余时间。
- ⚠️⚠️ **「持续 20 ms」按墙钟算是错的,必须数圈。** 第一版写的是
`now − 第一次看见 >= 20 ms`,测试一半红一半绿,看上去像是测试写飘了。
真因:巡检的采样时刻带**亚毫秒残差** —— 一圈自己占 1.56 ms 总线,而
`sched_periodic_wait()` 是按整毫秒睡的,于是相邻两次判据的时刻是
**21 / 19 ms 交替**(实测 1, 22, 41, 62, 81, 102, 121, 142 …)。
19 ms 那一次不满足 `>= 20`,判据要拖到第三圈 = **40 ms**,超了 §12.1 的验收,
而且是**间歇性**的。改成「连着两圈都越界」之后一次都不飘。
**凡是「持续 N 个周期」的判据都数圈,别拿墙钟去凑。**
回归拦网是 `test_the_cut_is_never_late_because_of_sub_millisecond_jitter()`,
它跑 20 轮、两种相位都踩得到。
- ⚠️ **「盲跑不判据」这条,测试的写法决定了它能不能被抓住。** 第一版是
「改坏寄存器 → 立刻让器件失联 → 跑 1 秒」,而那时快照里躺着的是**正常值**,
于是「判据不看 `rail_ok`」这个错一点痕迹都不留,测试永远绿。
正确写法是**先让坏值真的读进快照一次**,再让器件失联。
凡是测「陈旧值不许被采信」的,都要先把陈旧值做成一个**越界**的值。
- **判据顺序:SW-8 在前,SR-5 在后。** 过压且 VBUS 通着时两条都成立,
先撤掉能量来源(VBUS),20 ms 后再由 SR-5 决定要不要连这一路一起关掉。
反过来的话,第一圈就把电关了,日志里只剩一条「过压关路」,
「是 USB 灌回来的」这个信息丢了。
- **`t_actuator_abort()` 要在断电之前**,虽然它最坏会等 595 那把锁 20 ms
(`ACT_LOCK_TIMEOUT_MS`)、让巡检丢一拍。反过来(先断电再 abort)有个真实的洞:
abort 阻塞在锁上的那 20 ms 里,优先级更低的 t_actuator 反而能跑,
`HR_OFF` 到点就把 `EFUSE_EN` 抬回去了。**丢一拍认了** —— SR-5 原文写着它
「仅承担记录与告警,不承担实时保护」。
- **两个影子都只在写成功时更新**(`rail_efuse_is_on` / `rail_setpoint_mv`)。
方向是有讲究的:关的那次写失败 ⇒ 影子留在 on ⇒ 判据继续武装(还会再关一次);
开的那次写失败 ⇒ 影子留在 off ⇒ 不会对着一路没开成的电报偏差。两边都倒向安全侧。
- **判据一条 I²C 事务都不许发**,所以设定值、两根 EN、正在跑的序列全部走影子。
多读一次就把 §10.3 那 7.8 % 的预算顶穿,A8 的
`test_one_cycle_is_exactly_thirteen_transactions()` 会当场变红。
- **抓不到的那一条**:「关路之后把 `dev_n` 清零」注进去仍然全绿 ——
它只在**写引脚失败**时才有区别(影子留在 on ⇒ 下一圈立刻再关一次,
清零的话要再等两圈),而 sim 里造不出「已配置的输出脚写失败」。
所以那一行干脆没写(不写才是 do-nothing 的那一边),理由留在代码注释里。
## 这一层的几条约定(写 A10/A12 之前看一眼)
- **A10 的判据写在 `t_safety_step()` 里,放锁之后,直接写引脚。** 发消息给
t_actuator 是错的(§10.3 原话:不经任何队列或抽象层)。要关的那一路上如果有
序列在跑,先 `t_actuator_abort(rail)` 再关 —— abort 只收尾,**不动两根 EN 线**。
- **A12 的命令处理器只管语义与判重,动作一律转成这三个请求。** 三个请求都是
「立刻返回」的(不阻塞),busy 时返回 `HAL_ERR_STATE`,让上位机自己决定是重试
还是先 abort。**SW-1 的「先软后硬」是上位机的策略**(崩溃判定本来就在那一侧,
§10.8),A9 只提供机制:`t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_FORCE)`
是一级,`t_actuator_hard_reset(rail)` 是二级。
- ⚠️ **`sr595_init()` 由 A15 负责,而且必须跑在任何按键 / EDL 请求之前。**
没 init 时 `sr595_flush()` 返回 `HAL_ERR_STATE`,而收尾**按设计不放弃**,
于是那一路会永久 busy。**目前这条路径够不到**:请求只可能来自 A12 的命令处理器,
而 A15 的启动顺序排在它前面(init 不成功就 abort)。A15 里要保住这个先后 ——
先把链子 init 好,再让 t_link 开始收命令。硬重启不受影响(只碰 GPIO)。
代码里没有为这条加「没 init 就拒」的分支:sim 里没有办法把 595 摆回「没 init」,
加了就是一段测不到的代码,比让 A15 守住这个顺序更容易出事。
- **改了任何一个时间常量就是改了需求**,`test_timings_match_the_documents()` 会拦;
先去改 §7.4 / SW-5 / SW-6,再改这里。
## 这一层的几条约定(写 A11/A12/A15 之前看一眼)
- **A11 只接 `t_safety_set_event_sink()`,不要动判据。** 判据那一层已经保证
「动作在前、记录在后」,入队函数只管入队;阻塞或回头调 `t_safety_*` 都是错的。
§10.7 里其它几类必记事件(eFuse FAULT / PG 变化 / 过温降载 / 越界命令被拒 /
I²C 器件失联)各归各的包,A10 只产生自己这两条判据的四种。
- **A12 的命令处理器改电压时走 `rail_set_mv()`,别绕过它。** 判据比的是那里的影子;
绕过去直接写 DAC 的话,改完那一路会在 20 ms 后被自己的判据关掉。
- **A15 的启动顺序里,五路上电要错开 100 ms(§10.9 第 5 步)——
这和 60 ms 的缓启动窗口是相容的**:每一路的窗口从它自己的 `EFUSE_EN↑` 算起。
另外 `t_actuator_init()` 必须排在巡检任务之前(判据要 abort 它),
`app_main.c` 里已经是这个顺序,别调过来。
- **改了任何一个阈值就是改了需求**,`test_thresholds_match_the_documents()` 会拦;
先去改 SR-5 / SW-8 / 设计文档 §15.2,再改这里。
- ⚠️ **`T_SAFETY_DEV_HOLD_MS` 和 `_HOLD_CYCLES` 是同一件事的两种写法**
(毫秒 = (圈数−1) × 巡检周期),代码用圈数、文档用毫秒,测试拿任务表里的周期
把两者钉在一起。巡检周期哪天改了,那条断言会当场对不上 ——
这正是它存在的理由,别去「修」它。
+17 -1
查看文件
@@ -70,6 +70,17 @@ static bool act_pg_from_safety(void *ctx, int rail, bool *pg)
return true;
}
/* A10 的事件出口。**A11 会把这里换成环形缓冲的入队**(§10.7:`t_safety` 只入队、
不落盘),在那之前先打到日志上 —— 一条也不留才是最坏的结果。
⚠️ 这个回调是在断电之后、不持任何锁的情况下被调的,所以里面不许阻塞。 */
static void log_safety_event(void *ctx, const safety_event_t *ev)
{
(void)ctx;
ESP_LOGW(TAG, "安全事件 t=%u ms CH%u %s V=%d mV I=%d uA", (unsigned)ev->t_ms,
(unsigned)ev->rail, t_safety_ev_str((safety_ev_t)ev->type),
(int)ev->bus_mv, (int)ev->current_ua);
}
static void task_safety(void *arg)
{
(void)arg;
@@ -187,9 +198,14 @@ void app_main(void)
与**降级矩阵**是 A15 的启动顺序那一包。在那之前 t_safety 读回来的电流恒为 0
(Calibration 还没写),这是预期的,不是故障。 */
/* 执行器:PG 从巡检的快照里取,本任务自己不碰 I²C(t_actuator.h 开头那一段)。 */
/* 执行器:PG 从巡检的快照里取,本任务自己不碰 I²C(t_actuator.h 开头那一段)。
⚠️ 必须在 `spawn(TASK_SAFETY, ...)` 之前:判据要关路时会先 `t_actuator_abort()`,
而没 init 的执行器只会直接返回,那时序列还在跑,电关掉几秒又会被抬回来。 */
t_actuator_init(act_pg_from_safety, NULL);
/* 事件出口。同样要在巡检起来之前接上,否则第一批事件没人接。 */
t_safety_set_event_sink(log_safety_event, NULL);
spawn(TASK_SAFETY, task_safety);
spawn(TASK_THERMAL, task_thermal);
spawn(TASK_ACTUATOR, task_actuator);
+1 -1
查看文件
@@ -3,7 +3,7 @@
#define PHONERAIL_H
/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */
#define PHONERAIL_FW_VERSION "0.1.0-a9"
#define PHONERAIL_FW_VERSION "0.1.0-a10"
/* 通道数。五路直供,见设计文档 §2。 */
#define PHONERAIL_RAIL_COUNT 5
+32 -1
查看文件
@@ -65,6 +65,29 @@ hal_err_t rail_volt_init(void)
return HAL_OK;
}
/* 五路设定值的影子(契约见 rail.h)。初值就是 DAC 的 NVM 出厂默认,
所以 `rail_volt_init()` 跑之前它也说的是实话。 */
static int s_set_mv[PHONERAIL_RAIL_COUNT] = {
RAIL_MV_SAFE, RAIL_MV_SAFE, RAIL_MV_SAFE, RAIL_MV_SAFE, RAIL_MV_SAFE,
};
_Static_assert(PHONERAIL_RAIL_COUNT == 5, "s_set_mv 的初值列表要跟着改");
int rail_setpoint_mv(int rail)
{
if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) {
return RAIL_MV_SAFE;
}
return s_set_mv[rail - 1];
}
void rail_setpoint_reset(void)
{
for (int i = 0; i < PHONERAIL_RAIL_COUNT; i++) {
s_set_mv[i] = RAIL_MV_SAFE;
}
}
hal_err_t rail_set_mv(int rail, int mv)
{
if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) {
@@ -73,8 +96,16 @@ hal_err_t rail_set_mv(int rail, int mv)
if (!rail_mv_in_range(mv)) {
return HAL_ERR_ARG; /* SR-13。TODO(A11):这里要记一条事件 */
}
return dac63204_write_code(dac63204_rail_addr(rail), dac63204_rail_ch(rail),
const hal_err_t err = dac63204_write_code(dac63204_rail_addr(rail),
dac63204_rail_ch(rail),
rail_mv_to_code(mv));
if (err == HAL_OK) {
/* 记的是**请求的 mV**,不是 `rail_code_to_mv(码)` —— 两者差不到一个码
(118.75 µV),但 A10 的判据是拿它当「设定值」用的,回读换算会让
「设成 3900」在判据眼里变成 3899,白白吃掉 1 mV 的余量。 */
s_set_mv[rail - 1] = mv;
}
return err;
}
hal_err_t rail_get_mv(int rail, int *mv)
+17
查看文件
@@ -71,6 +71,23 @@ hal_err_t rail_set_mv(int rail, int mv);
实测要走该路的 INA226(A10 的巡检)。 */
hal_err_t rail_get_mv(int rail, int *mv);
/* 最后一次**成功**写进那一路 DAC 的设定值(mV)。不碰总线。
A10 的 SR-5 判据要的就是这个「设定值」(`|实测 − 设定| > 100 mV`)。
不用 `rail_get_mv()` 是因为它是一次 I²C 事务:巡检每 20 ms 跑一圈,
五路各多一次读就是每圈多 5 次事务,把 §10.3 那 7.8 % 的预算顶到 11 %,
而这个数是编译期就知道的。
上电默认是 `RAIL_MV_SAFE`(3900)—— 那不是猜的,是 DAC63204 **NVM 里的
出厂默认值**(rail.h 开头那段),所以在 `rail_volt_init()` 跑之前它也是真的。
写失败时不更新:那一次 DAC 里到底是什么值不知道,留着上一个成功的更可信。 */
int rail_setpoint_mv(int rail);
/* 把五路的设定值影子放回 `RAIL_MV_SAFE`,**不碰 DAC**。
给两处用:宿主机测试的 `setUp()`(`sim_hal_reset()` 够不到应用层的静态变量),
以及 A15 在「DAC 无应答」降级路径上声明「现在生效的是 NVM 默认值」。 */
void rail_setpoint_reset(void);
#ifdef __cplusplus
}
#endif
+27 -2
查看文件
@@ -218,6 +218,21 @@ static hal_err_t en_write(int pin, bool on)
return hal_gpio_write(pin, on ? 1 : 0);
}
/* 两根线的影子(契约见 rail_cfg.h)。写失败时**不更新** —— 那一次的线上电平
是未知的,而「保留上一次成功的意图」在两个方向上都倒向安全的那一侧。 */
static bool s_efuse_on[PHONERAIL_RAIL_COUNT];
static bool s_vbus_on[PHONERAIL_RAIL_COUNT];
bool rail_efuse_is_on(int rail)
{
return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT && s_efuse_on[rail - 1];
}
bool rail_vbus_is_on(int rail)
{
return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT && s_vbus_on[rail - 1];
}
hal_err_t rail_en_init(void)
{
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
@@ -236,6 +251,8 @@ hal_err_t rail_en_init(void)
return err;
}
}
s_efuse_on[rail - 1] = false;
s_vbus_on[rail - 1] = false;
}
return HAL_OK;
}
@@ -245,7 +262,11 @@ hal_err_t rail_efuse_enable(int rail, bool on)
if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) {
return HAL_ERR_ARG;
}
return en_write(k_cfg[rail - 1].efuse_en, on);
const hal_err_t err = en_write(k_cfg[rail - 1].efuse_en, on);
if (err == HAL_OK) {
s_efuse_on[rail - 1] = on;
}
return err;
}
hal_err_t rail_vbus_enable(int rail, bool on)
@@ -253,7 +274,11 @@ hal_err_t rail_vbus_enable(int rail, bool on)
if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) {
return HAL_ERR_ARG;
}
return en_write(k_cfg[rail - 1].vbus_en, on);
const hal_err_t err = en_write(k_cfg[rail - 1].vbus_en, on);
if (err == HAL_OK) {
s_vbus_on[rail - 1] = on;
}
return err;
}
hal_err_t rail_all_off(void)
+14
查看文件
@@ -106,6 +106,20 @@ hal_err_t rail_en_init(void);
hal_err_t rail_efuse_enable(int rail, bool on);
hal_err_t rail_vbus_enable(int rail, bool on);
/* 这两根线**最后一次成功写进去**的值(上电与 `rail_en_init()` 之后都是 false)。
存在的理由是 A10 的判据要知道「这一路现在该不该有电」:关着的路电压本来就是 0,
拿它去和设定值比,每一圈都会得出「偏低 3900 mV」,于是巡检会没完没了地
「再关一次已经关着的路」并刷事件。判据只在这两个影子为 true 时武装。
⚠️ **只在写成功时更新**,而且方向是有讲究的:关的那一次写失败 ⇒ 影子留在 on
⇒ 判据继续武装(还会再关一次);开的那一次写失败 ⇒ 影子留在 off
⇒ 判据不武装(不会对着一路没开成的电报偏差)。两边都倒向安全的那一侧。
—— 不去读回引脚,是因为 `hal_gpio_read()` 读输出脚在两个目标上的语义不一样,
而这里要的本来就是「固件的意图」,不是「线上的电平」。 */
bool rail_efuse_is_on(int rail);
bool rail_vbus_is_on(int rail);
/* 五路全关:**先断 VBUS 再关 eFuse**,中间不留「只靠 USB 供着电」的窗口。
十个脚全部试一遍,不因为某一个失败就提前返回 —— 关电路径上「剩下九个也别关了」
是最坏的选择。返回第一个错误码(全成功就是 HAL_OK)。
+166 -5
查看文件
@@ -6,9 +6,164 @@
#include "hal_i2c_acct.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "rail.h"
#include "rail_cfg.h"
#include "t_actuator.h"
#include "tasks.h"
/* ---- A10:两条判据 --------------------------------------------------------
§10.3 原话:「越界**直接**操作 `EFUSE_EN` / `VBUS_EN`(GPIO 直连,不经任何
队列或抽象层)」。所以下面这几个函数直接调 `rail_*_enable()`,不发消息给
t_actuator —— 发消息就等于把「关电」排在了一个中优先级任务的队列后面。
四条不能改的规矩:
1. **判据只在放锁之后跑。** 两根 EN 线是 GPIO,和 I²C 没有关系;拿着总线锁
去写引脚只会把别的任务多挡一会儿。而且 `t_actuator_abort()` 会去拿 595
那把锁,拿着 I²C 锁进去就是嵌套(`hal_lock_take()` 会直接判 HAL_ERR_STATE)。
2. **先 `t_actuator_abort()`,再断电。** 反过来有个真实的洞:硬重启的
`HR_OFF` 状态会在 4 s 后自己把 `EFUSE_EN` 抬回去,于是「关掉的路」几秒钟
之后又带电了。abort 之后那一路只会走收尾(只碰 595),再也够不到 EN 线。
代价是 abort 最坏会等 595 那把锁 20 ms(`ACT_LOCK_TIMEOUT_MS`),也就是
巡检可能丢一拍 —— 认了:SR-5 原文写着它「仅承担记录与告警,不承担实时保护」,
真正的过压保护是 eFuse 里的硬件 OVP,不靠这条路径的延迟。
3. **判据只在「这一路该有电」而且「这一圈的值是新鲜的」时武装。**
关着的路电压本来就是 0,不设这一条的话每一圈都会得出「偏低 3900 mV」;
而器件失联那一路的值是上一圈的,§10.10 明写着「保持供电,禁用该路的
SR-5 / SW-8 判据并持续告警」。
4. **一条判据也不许发 I²C 事务。** 判据要的三个量(设定值、两根 EN 的状态、
正在跑的序列)全部有影子可查。多读一次就把 §10.3 那 7.8 % 的预算顶穿,
`test_one_cycle_is_exactly_thirteen_transactions()` 会当场变红。 */
static safety_ev_fn s_sink;
static void *s_sink_ctx;
void t_safety_set_event_sink(safety_ev_fn fn, void *ctx)
{
s_sink = fn;
s_sink_ctx = ctx;
}
const char *t_safety_ev_str(safety_ev_t ev)
{
switch (ev) {
case SAFETY_EV_NONE:
return "无";
case SAFETY_EV_OVER_VOLT:
return "过压关路(SR-5)";
case SAFETY_EV_UNDER_VOLT:
return "欠压关路(SR-5)";
case SAFETY_EV_REVERSE:
return "反灌断VBUS(SW-8)";
case SAFETY_EV_PUSHED_UP:
return "被顶高断VBUS(SW-8)";
default:
return "?";
}
}
/* 记一条。**永远在动作之后调** —— 记录慢一步没关系,断电慢一步不行。 */
static void emit(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
{
const ina226_vi_t *vi = &s->rail[rail - 1];
s->last.t_ms = now;
s->last.rail = (uint8_t)rail;
s->last.type = (uint8_t)ev;
s->last.bus_mv = vi->bus_mv;
s->last.current_ua = vi->current_ua;
s->events++;
if (s_sink != NULL) {
s_sink(s_sink_ctx, &s->last); /* TODO(A11):这里接环形缓冲 */
}
}
/* SR-5:关这一路。**先断 VBUS 再关 eFuse** —— 只关 eFuse 的话,插着 USB 的那几路
会停在「手机只靠 VBUS 供着电」,那既不是开也不是关(`rail_all_off()` 里同一条
理由)。两个都要试,不因为第一个失败就不关第二个。 */
static void trip_rail(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
{
t_actuator_abort(rail); /* 规矩 2:必须在断电之前 */
(void)rail_vbus_enable(rail, false);
(void)rail_efuse_enable(rail, false);
emit(s, rail, ev, now);
}
/* SW-8:只断这一路的 VBUS,**不碰 eFuse** —— 反灌的能量是从 USB 那一侧来的,
把直供也一起关掉等于「因为充电器有问题就把手机关机」,而 §10.2 的整节都在讲
「范围可控的时候只动出事的那一处」。 */
static void cut_vbus(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
{
/* 三个序列里只有 EDL 会去写 `VBUS_EN`(`EDL_EN=1 → VBUS_EN=1 → 等枚举`)。
它正跑着的话必须打断,否则下一拍它就把 VBUS 又抬回来了。
硬重启 / 按键不碰 VBUS,没有理由为一次反灌把它们打断。 */
if (t_actuator_seq(rail) == ACT_SEQ_EDL) {
t_actuator_abort(rail);
}
(void)rail_vbus_enable(rail, false);
emit(s, rail, ev, now);
}
static void check_rail(safety_snap_t *s, int rail, uint32_t now)
{
safety_rail_t *st = &s->st[rail - 1];
const bool en = rail_efuse_is_on(rail);
/* `EFUSE_EN` 的上升沿:缓启动窗口从这一刻算起。 */
if (en && !st->en_on) {
st->en_ms = now;
}
st->en_on = en;
/* 规矩 3 的三道闸。任何一道不过就把圈数清零 —— 「关着的那几秒」
不能算进「持续 20 ms」里,不然一开机就跳闸。
(缓启动窗口是真按毫秒算的:它对应的是 tON + 斜坡那段物理时间,
不像「持续 20 ms」那样是在数样本。) */
if (!en || !s->rail_ok[rail - 1] || (now - st->en_ms) < T_SAFETY_SETTLE_MS) {
st->dev_n = 0;
return;
}
const int32_t mv = s->rail[rail - 1].bus_mv;
const int32_t ua = s->rail[rail - 1].current_ua;
const int32_t dev = mv - (int32_t)rail_setpoint_mv(rail);
/* ---- SW-8(先跑):只在 VBUS 通着的时候武装 -------------------------
反灌的能量只可能从 VBUS 那一侧来,断开之后判据自然就解除武装了 ——
这也是「关掉之后不会每 20 ms 再刷一条事件」的机制。
先跑它是因为过压这一种情况两条判据都成立:该先撤掉能量来源(VBUS),
20 ms 后 SR-5 再决定要不要连这一路一起关掉。 */
if (rail_vbus_is_on(rail)) {
if (ua < T_SAFETY_REV_UA) {
cut_vbus(s, rail, SAFETY_EV_REVERSE, now);
} else if (dev > T_SAFETY_DEV_MV) {
cut_vbus(s, rail, SAFETY_EV_PUSHED_UP, now);
}
}
/* ---- SR-5:±100 mV 连着 T_SAFETY_DEV_HOLD_CYCLES 圈(= 持续 20 ms)---- */
if (dev > T_SAFETY_DEV_MV || dev < -T_SAFETY_DEV_MV) {
if (st->dev_n < 0xFF) {
st->dev_n++;
}
if (st->dev_n >= T_SAFETY_DEV_HOLD_CYCLES) {
trip_rail(s, rail, dev > 0 ? SAFETY_EV_OVER_VOLT : SAFETY_EV_UNDER_VOLT, now);
/* ⚠️ 这里**故意不**把 `dev_n` 清零。关掉之后下一圈 `en` 就是 false,
上面那道闸自己会清;而万一那次写引脚失败了(影子留在 on),
下一圈 `dev_n` 已经够数,会**立刻再关一次** —— 清零的话得再等两圈。
关电路径上「重试快一点」永远是对的那一边。 */
}
} else {
st->dev_n = 0; /* 「持续」的意思是连续,中间回到范围内就从头数 */
}
}
void t_safety_init(safety_snap_t *s)
{
assert(s != NULL);
@@ -79,9 +234,14 @@ hal_err_t t_safety_step(safety_snap_t *s)
hal_lock_give(HAL_LOCK_I2C);
/* A10 的判据接在这里:越界**直接**写 EFUSE_EN / VBUS_EN(规矩 3),
而且要在放锁之后 —— 那两根线是 GPIO 直连,和 I²C 没关系,
拿着总线锁去操作引脚只会把别的任务多挡一会儿。 */
/* 判据:放锁之后跑,直接写 EFUSE_EN / VBUS_EN(上面那四条规矩)。
`now` 只取一次 —— 五路要拿同一个时刻去算「持续了多久」。 */
{
const uint32_t now = hal_time_now_ms();
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
check_rail(s, rail, now);
}
}
s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor);
return first;
@@ -111,8 +271,9 @@ int t_safety_format(const safety_snap_t *s, char *buf, size_t len)
const uint32_t period = task_desc(TASK_SAFETY)->period_ms;
const uint32_t pm = t_safety_load_permille(s->bus_us, period);
return snprintf(buf, len,
"t_safety: cycles=%u skipped=%u i2c_err=%u bus=%u us/%u ms load=%u.%u%%",
"t_safety: cycles=%u skipped=%u i2c_err=%u bus=%u us/%u ms load=%u.%u%% "
"events=%u",
(unsigned)s->cycles, (unsigned)s->skipped, (unsigned)s->i2c_errors,
(unsigned)s->bus_us, (unsigned)period, (unsigned)(pm / 10u),
(unsigned)(pm % 10u));
(unsigned)(pm % 10u), (unsigned)s->events);
}
+90 -4
查看文件
@@ -1,7 +1,7 @@
/* `t_safety` —— 20 ms 巡检任务的**一个周期**(需求 §10.3 第一行)。
本包(A8)只做骨架:拿 I²C 锁 → 读 6 片 INA226 的 V+I → 读 PCA9555 两个输入端口
→ 放锁 → 记账。**判据(SW-2 / SW-8 / 过压 / 反灌)是 A10 的事**,这里一条都没有。
一个周期干四件事:拿 I²C 锁 → 读 6 片 INA226 的 V+I 与 PCA9555 两个输入端口
→ 放锁 → **跑判据**(A10:SR-5 / SW-8,见本文件下半段)。
分成「一个周期是个函数」而不是直接写成 `while(1)`,是为了让它在宿主机上跑得起来:
宿主机没有 FreeRTOS,任务体是死循环就一行也测不了。ESP 侧的 `while(1)`
@@ -13,7 +13,7 @@
2. **一个器件读失败不能中断这一圈。** 六片里坏一片,剩下五路的判据照常要出。
失联 ≠ 断电(§10.2),失联更不等于「别的路也不看了」。
3. **越界要断电时直接写 `EFUSE_EN` / `VBUS_EN` 引脚**(§10.3 原话:不经任何队列
或抽象层)。所以 A10 的判据要写在这个函数里,不是发个消息给 t_actuator。 */
或抽象层)。所以判据就写在这个函数里,不是发个消息给 t_actuator。 */
#ifndef T_SAFETY_H
#define T_SAFETY_H
@@ -34,9 +34,91 @@ extern "C" {
而**废一个周期**(20 ms 后重来)比**赖着不走**(把最高优先级任务钉死)好得多。 */
#define T_SAFETY_LOCK_TIMEOUT_MS 5
/* ---- A10:两条判据的阈值。**每一个都有出处,改了就是改需求** ------------
`test_thresholds_match_the_documents()` 会拦。 */
/* SR-5 / SW-2:「电压偏离设定 >±100 mV」。比的是 INA226 读回来的母线电压与
**该路 DAC 的设定值**(`rail_setpoint_mv()`),不是 3900 这个常数 ——
SW-4 允许运维单路微调,拿常数比会把一台调到 4.0 V 的机器当成过压。 */
#define T_SAFETY_DEV_MV 100
/* SR-5 的「且持续 20 ms」——「持续」在这里数的是**圈**,不是墙钟毫秒:
连着两圈新鲜样本都越界才算,而成立的那一刻就是断电的那一刻。
⚠️ **不要改回按墙钟算**(`now - 第一次看见 >= 20 ms`)。巡检的采样时刻带着
亚毫秒残差:一圈自己要占 1.56 ms 总线,而「睡到下一拍」是按整毫秒算的,
于是相邻两次判据的时刻实测是 **21 / 19 ms 交替**。拿 `>= 20 ms` 去判,
有一半的机会 19 ms 那一次不算数、要等到第三圈 —— 也就是 40 ms 才关路,
直接超了 §12.1 的验收,而且是**间歇性**的:同一个故障注两次,一次过一次不过。
数圈没有这个毛病,而「两圈」在 20 ms 的周期下就是 20 ms。
⚠️ SR-5 说「持续 20 ms 才算」,§12.1 的验收说「20 ms 内关路」——
字面上互相顶着,只有「从第一次看见算起、下一圈就断电」同时满足两条。
注意这是从**第一次看见**算的:物理事件最早可能发生在上一圈之后,
所以对着外部世界的最坏延迟是 2 个周期。这不要紧 —— SR-5 原文就写着
「仅承担记录与告警,不承担实时保护」,真正的过压保护是 eFuse 里的硬件 OVP。 */
#define T_SAFETY_DEV_HOLD_CYCLES 2
/* 上面那个圈数折算成毫秒 = (圈数 − 1) × 巡检周期。需求 SR-5 写的就是这个 20 ms,
`test_thresholds_match_the_documents()` 拿它和任务表里的周期对账 ——
哪天巡检周期改了,这两个数会当场对不上,而不是悄悄把判据变成 40 ms。 */
#define T_SAFETY_DEV_HOLD_MS 20
/* SW-8 的反灌判据:设计文档 §15.2 对策 2 原话「if I < −50 mA → 立即拉低 VBUS_EN」。
离噪声很远:INA226 电流 LSB 是 0.5 mA,分流失调 ±10 µV / 5 mΩ ≈ ±2 mA,
−50 mA 是它的 25 倍。§12.1 的验收注入 −60 mA,落在阈值外侧。 */
#define T_SAFETY_REV_UA (-50000)
/* 反灌**不做确认窗口**(一圈看见就断),这和上面 20 ms 的过压判据是有意不对称的:
· 误断 VBUS 的代价是丢一次 USB 数据,手机还由直供电着,没有任何风险;
· 漏判的代价是能量泵回 12V 母线,**连累另外四台**(设计文档 §15.2 末尾)。
一圈就断 ⇒ 最坏 40 ms(事件发生 + 一圈),SW-8 给的预算是 200 ms,5 倍余量。 */
#define T_SAFETY_REV_BUDGET_MS 200
/* `EFUSE_EN` 拉高之后多久才开始判这一路的电压。tON 5.1 ms + 缓启动斜坡 11.5 ms
≈ 17 ms(设计文档 §5 / §7.4),取 3 个巡检周期。
没有这个窗口的现象是**每次上电都自己把自己关掉**:斜坡走到一半时
实测电压离设定值差着好几百 mV,判据不认识「正在爬」和「掉下去了」的区别。 */
#define T_SAFETY_SETTLE_MS 60
/* ---- A10:事件(§10.7 的「单条」形状:时间戳 + 路号 + 类型 + 当时的 V/I)----
A11 会把这条记录塞进环形缓冲,这里只负责**产生**它。故意先把记录类型定下来,
A11 就只剩「缓冲 + 落盘」,不用回头再动判据这一层。 */
typedef enum {
SAFETY_EV_NONE = 0,
SAFETY_EV_OVER_VOLT, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */
SAFETY_EV_UNDER_VOLT, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */
SAFETY_EV_REVERSE, /* SW-8:电流转负(I < −50 mA)→ 断这一路 VBUS */
SAFETY_EV_PUSHED_UP, /* SW-8:VBUS 期间电压被顶高 >+100 mV → 断这一路 VBUS */
} safety_ev_t;
typedef struct {
uint32_t t_ms; /* 上电起算的毫秒(§10.7:上位机连上后下发墙钟做偏移) */
uint8_t rail; /* 1~5 */
uint8_t type; /* safety_ev_t */
int32_t bus_mv; /* 出事那一刻的实测值,不是设定值 */
int32_t current_ua;
} safety_event_t; /* 16 B —— §10.7「单条 16 B 量级」 */
/* 事件出口。A11 在这里接环形缓冲;不接就只有快照里的 `last` 与计数。
⚠️ 回调是在**断电之后**才调的(先保命,再记账),而且此刻两把锁都没拿着 ——
接进来的东西不许阻塞,也不许回头调 t_safety_*。 */
typedef void (*safety_ev_fn)(void *ctx, const safety_event_t *ev);
void t_safety_set_event_sink(safety_ev_fn fn, void *ctx);
const char *t_safety_ev_str(safety_ev_t ev);
/* 每路的判据状态。全在快照里,`t_safety_init()` 一并清干净。 */
typedef struct {
bool en_on; /* 上一圈看到的 `EFUSE_EN` 影子,用来认它的上升沿 */
uint32_t en_ms; /* 上升沿的时刻,缓启动窗口从这里算(这个是真按毫秒的) */
uint8_t dev_n; /* 连着几圈越界了。回到范围内 / 这一路被关掉就清零 */
} safety_rail_t;
typedef struct {
/* 这一圈读回来的原始量。`ok` 为 false 时对应的值是**上一圈的**,不是 0 ——
判据(A10)必须先看 ok 再看值,拿一个没更新的电压去判过压是最难查的那种错。 */
判据必须先看 ok 再看值,拿一个没更新的电压去判过压是最难查的那种错。 */
ina226_vi_t rail[PHONERAIL_RAIL_COUNT];
bool rail_ok[PHONERAIL_RAIL_COUNT];
ina226_vi_t input; /* 输入侧 U13(0x43),整板功率预算 */
@@ -44,6 +126,10 @@ typedef struct {
pca9555_in_t io; /* 五路 FLT# / PGOOD + Buck PG 或 + INA ALERT 或 */
bool io_ok;
safety_rail_t st[PHONERAIL_RAIL_COUNT]; /* 每路的判据状态(内部用) */
safety_event_t last; /* 最近一条事件;`last.type == SAFETY_EV_NONE` = 还没出过 */
uint32_t events; /* 出过几条事件 */
uint32_t cycles; /* 跑过几圈(含被跳过的) */
uint32_t skipped; /* 其中因为拿不到 I²C 锁而整圈跳过的 */
uint32_t i2c_errors; /* 累计器件级失败次数(一圈里可能不止一次) */
+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();
}
+42
查看文件
@@ -349,6 +349,48 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU
**推论**:失联期间固件必须能独立运行全部本地职责 —— 巡检、风扇闭环、事件记录、
按队列执行未完成的序列。上位机回来之后靠事件日志补齐时间线(10.7)。
> **A10 已落地**(2026-09-07,`firmware/main/t_safety.{h,c}`,判据全在
> `t_safety_step()` 里、放锁之后、直接写引脚)。本表第三行「本地判据触发 → 关对应
> 那一路」的两条判据是 SR-5 与 SW-8,实现时定下来的七条:
>
> 1. **「持续 20 ms」数的是圈,不是墙钟毫秒。** 巡检的采样时刻带亚毫秒残差
> (一圈自己占 1.56 ms 总线,而「睡到下一拍」按整毫秒算),实测相邻两次判据的
> 时刻是 **21 / 19 ms 交替**。按 `>= 20 ms` 判,19 ms 那半数情况要拖到第三圈
> ——**40 ms**,超了 §12.1 的验收,而且是间歇性的:同一个故障注两次,一次过
> 一次不过。改成「连着两圈都越界」,在 20 ms 的周期下就是 20 ms。
> (SR-5 说「持续 20 ms 才算」、§12.1 说「20 ms 内关路」,字面互相顶着,
> 只有「第一次看见的下一圈就断」同时满足两条。这 20 ms 是从**第一次看见**算的,
> 对外部世界的最坏延迟是 2 个周期 —— 不要紧,SR-5 原文就写着它
> 「仅承担记录与告警,不承担实时保护」。)
> 2. **反灌不设确认窗口,一圈看见就断**(设计文档 §15.2 对策 2 原话「立即」)。
> 与过压那 20 ms 的不对称是有意的:误断 VBUS 只丢一次 USB 数据,手机还由直供
> 电着;漏判则把能量泵回 12V 母线**连累另外四台**。最坏 2 圈 = 40 ms,
> SW-8 给的预算是 200 ms。
> 3. **判据只在「这一路该有电」且「这一圈的值是新鲜的」时武装**,另加一个
> `EFUSE_EN↑` 之后 **60 ms** 的缓启动窗口(tON 5.1 + 斜坡 11.5 ≈ 17 ms 的 3 倍)。
> 少了第一条,每圈都会「发现」关着的五路各偏低 3900 mV;少了第三条,
> **每次上电都会自己把自己关掉**;第二条就是 §10.10 那行「盲跑 → 保持供电,
> 禁用该路的 SR-5 / SW-8 判据」。
> 4. **比的是该路 DAC 的设定值**(`rail_setpoint_mv()` 的影子,不碰总线),
> 不是 3900 这个常数 —— SW-4 允许运维单路微调,拿常数比会把一台调到 4.0 V 的
> 机器当成过压关掉。
> 5. **SR-5 关路 = 先断 VBUS 再关 eFuse**(`rail_all_off()` 的同一条理由:中间不留
> 「手机只靠 USB 供着电」那个既不是开也不是关的窗口);**SW-8 只断 VBUS,
> 不碰 eFuse** —— 反灌是 USB 那一侧的事,把直供也关掉等于「因为充电器有问题
> 就把手机关机」。过压且 VBUS 通着时两条都成立,于是**先撤能量来源,
> 20 ms 后再决定要不要关这一路**。
> 6. **断电之前先 `t_actuator_abort()`。** 不 abort 的现象最阴:硬重启的 `HR_OFF`
> 会在 4 s 后自己把 `EFUSE_EN` 抬回去,「关掉的那一路」几秒后又带电了,
> 而日志里只有一条「已关路」。断 VBUS 时只打断 **EDL**(三个序列里只有它写
> `VBUS_EN`)—— 无条件打断会把一次正在进行的救砖重启也顺手停掉。
> 7. **动作在前,记录在后**,事件出口是一个回调(`t_safety_set_event_sink()`),
> A11 在那里接环形缓冲。记录用的 `safety_event_t` 已经按 §10.7 的「单条」
> 形状定好(时间戳 + 路号 + 类型 + 当时的 V/I,16 B)。
>
> 29 条 ctest 用例,25 条「一定会被写出来的错」逐个注进去跑过,**24 条变红**;
> 剩下那一条(关路之后把 `dev_n` 清零)在 sim 里造不出「写引脚失败」,抓不到 ——
> 所以那一行干脆没写,理由记在代码里。
### 10.3 任务划分(FreeRTOS / ESP-IDF)
| 任务 | 周期 | 优先级 | 职责 | 失败后果 |