# 交接 更新:2026-09-07 完成任务包 **A9**(三个执行器状态机 + 互斥) ## 一句话现状 §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 条注错逐个跑过全部变红**。 ## 下一步 **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` 再看值)。 ## 本次新增/修改的文件 - `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 落地 + 六条实现约定 ## 验收命令与结果 ``` $ bash host/test.sh 100% tests passed, 0 tests failed out of 12 # +test_t_actuator(23),共 184 例 $ .\build_idf.ps1 # PowerShell phonerail.bin binary size 0x3d240 bytes. ... 0xc2dc0 bytes (76%) free. Project build complete. # 21 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库): # 硬重启:不等那 17 ms / 断电窗口缩到 1 s / 断电后忘了开回来 / 只置状态不断电 / # 等不到 PG 就关路 / PG 只在进入那一刻看一眼 / 等 PG 时一觉睡满 500 ms / # 开始前不松键 # 按键: 组合键分两次写 / 位号改成用通道号算 / 不查「正在跑别的序列」/ # 时长上限没了 / 按下失败就直接返回不收尾 # EDL: 顺序反过来(先插 USB 再短接)/ 收尾时顺手把 VBUS 也断了 / 位号用算的 # 收尾: 松键写成「整条链的键位全清掉」/ 写不进去就放弃 / 结束直接回 IDLE / # 换状态后不当场再走一步 # abort: 顺手把电开回来 # → 21 条全部变红。 ``` ## 踩到的坑(只写会再犯的) - ⚠️ **「测试自己掌握节奏」会把「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。 早醒本身没关系:每个状态都是拿**绝对时刻**算的,早来就再返回一个剩余时间。 ## 这一层的几条约定(写 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,再改这里。