需求 §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]>
98 行
7.3 KiB
Markdown
98 行
7.3 KiB
Markdown
# 交接
|
||
更新: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,再改这里。
|