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]>
这个提交包含在:
+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) × 巡检周期),代码用圈数、文档用毫秒,测试拿任务表里的周期
|
||||
把两者钉在一起。巡检周期哪天改了,那条断言会当场对不上 ——
|
||||
这正是它存在的理由,别去「修」它。
|
||||
|
||||
在新工单中引用
屏蔽一个用户