需求 §10.6 的三个序列,每路一份状态机,全部以「收尾」收口:
硬重启 EFUSE_EN↓ → 4 s(§7.4 的 3~5 s 窗口)→ EFUSE_EN↑ → 17 ms
(tON 5.1 + 缓启动斜坡 11.5)→ 等 PG
按键 按下 → 保持 T(100 ms / 2 s / 10 s)→ 松开,组合键一帧发完
EDL EDL_EN=1 → VBUS_EN=1 → 等枚举 → EDL_EN=0(SW-6 的顺序)
一个函数一步,不写 while(1):t_actuator_step() 只看时钟、推状态、返回「下次
最晚多久再来」,任务体在 app_main.c 里还是三行。放电由硬件联锁做(SR-7),
本文件里没有任何放电接口。
四条关键判断:
· 按下去的东西一定要松开 —— 正常结束 / 超时 / 出错 / abort 走同一条收尾路径,
写不进 595 就一直重试,不设放弃上限(放弃 = 电源键永久按住,而遥测正常);
· 等不到 PG 不许再断电(§10.10:PCA9555 不应答不关输出);
· PG 由 t_safety 的快照喂进来,执行器不碰 I²C ⇒ 两把锁永远不会嵌套;
· 不可重入直接拒(HAL_ERR_STATE),被拒的请求一个字节都不发。
23 个用例;21 条注错逐个跑过全部变红。其中一条第一遍没抓住 ——「等 PG 时一觉
睡满 500 ms」:测试自己掌握节奏就测不到 step 返回的睡眠时长,补了一个完全听
step 的任务循环测试。
Co-Authored-By: Claude Opus 5 <[email protected]>
7.3 KiB
7.3 KiB
交接
更新: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_MS4000(§7.4 的 3~5 s 窗口,配_MIN_/_MAX_两个界)、ACT_HR_RAMP_MS17(tON 5.1 + 斜坡 11.5)、ACT_HR_PG_TIMEOUT_MS500 +_POLL_MS5、ACT_KEY_MS_TAP/ON/FORCE100/2000/10000(SW-5)、ACT_KEY_MAX_MS15000(拦手误)、ACT_EDL_SHORT_MS10 +ACT_EDL_ENUM_MS3000 ·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,再改这里。