文件
phonerail/firmware/HANDOFF.md
T
zhou yang和Claude Opus 5 b3206005ab A9:三个执行器状态机 + 互斥
需求 §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]>
2026-09-07 08:08:28 +08:00

7.3 KiB
原始文件 Blame 文件历史

交接

更新: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,再改这里。