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]>
这个提交包含在:
zhou yang
2026-09-07 08:08:28 +08:00
共同撰写人 Claude Opus 5
父节点 ec31891429
当前提交 b3206005ab
共修改 9 个文件,包含 2572 行新增和 1214 行删除
+78 -73
查看文件
@@ -1,92 +1,97 @@
# 交接
更新:2026-09-07 完成任务包 **A8**(任务骨架与调度:5 个任务、两把锁)
更新:2026-09-07 完成任务包 **A9**(三个执行器状态机 + 互斥)
## 一句话现状
§10.3 的五个任务成了一张 const 表(周期 + 优先级,开机自检不过就 abort),
周期任务走**绝对时刻**的节拍器(不漂、回绕安全、过载跳过不补跑),
两把互斥锁(I²C / 595)**禁止嵌套持有**,`t_safety` 的一圈已经能跑:
拿锁 → 6 片 INA226 的 V+I + PCA9555 两个输入端口 → 放锁 → 记账。
I²C 占用由 HAL 按线上真实字节数算出来:**1560 µs / 20 ms = 7.8 %**,
与 §10.3 手算的「约 1.5 ms / 约 8 %」相符。11 条 ctest(161 个用例)全绿,`idf.py build` 通过。
§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 条注错逐个跑过全部变红**。
## 下一步
**A9**:三个执行器状态机 + 互斥(需求 §10.6 全节)。
验收是「硬重启走完 17 ms 上电时序;按键序列不重入;EDL 顺序固定」。
A8 给它备好的:`HAL_LOCK_SR595`(595 那把锁,sim 里能强制检查)、
虚拟时钟下的 `sched_*`、`sim_gpio_write_seq()`(A7 留的顺序断言)。
⚠️ A9 的状态机同样要写成「一个函数一步」,不要写进 `while(1)` —— 死循环在宿主机上跑不到。
**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/tasks.h` / `tasks.c` — 五任务表(名字/周期/优先级/栈)+ `task_table_ok()`
- `main/sched.h` / `sched.c` — 绝对时刻节拍器(≈60 行),过载计数与最迟到时长
- `main/t_safety.h` / `t_safety.c` — 巡检的**一个周期** + 静态预算 + 一行人话报告
- `components/phonerail_hal/include/hal_lock.h` + `esp/hal_lock_esp.c` + `sim/hal_lock_sim.c`
— 两把锁(HAL 的第五样东西,理由写在 hal_lock.h 开头)
- `components/phonerail_hal/include/hal_i2c_acct.h` + `hal_i2c_acct.c`
— I²C 占用核算,**两个目标编同一份**,所以宿主机跑出来的数就是上板会打印的数
- `sim/hal_i2c_sim.c` — 每次事务记账 + **把虚拟时钟真的花掉** + 查加锁纪律
- `sim/sim_sr595.c` / `sim/sim_hal.c` / `sim/sim_hal.h` — 595 那四根线也查加锁纪律;
新增 `sim_lock_*()`(扮演第二个任务、强制加锁纪律、违规计数)与 `sim_i2c_set_bus_time()`
- `esp/hal_i2c_esp.c` — 四个事务入口各加一行记账
- `components/drv/include/ina226.h` — 新增 `INA226_CONV_US`(8.8 ms,给任务表自检对账)
- `main/app_main.c` — 建五个任务;`selftest_or_die()` 多查一项任务表
- `test/test_sched.c`(17 个用例)/ `test/test_t_safety.c`(13 个用例)
- 三处 CMakeLists + `main/phonerail.h`(版本 `0.1.0-a8`)
- `../需求文档.md` §10.3 — 记 A8 落地 + 五条实现约定
- `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 11 # +test_sched(17) +test_t_safety(13),共 161 例
$ ./host/build/test_t_safety.exe
t_safety: cycles=1 skipped=0 i2c_err=0 bus=1560 us/20 ms load=7.8%(§10.3 手算:约 1.5 ms / 约 8 %)
100% tests passed, 0 tests failed out of 12 # +test_t_actuator(23),共 184 例
$ .\build_idf.ps1 # PowerShell
phonerail.bin binary size 0x3cc40 bytes. ... 0xc33c0 bytes (76%) free.
phonerail.bin binary size 0x3d240 bytes. ... 0xc2dc0 bytes (76%) free.
Project build complete.
# 16 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库):
# 节拍:next 从「现在」起算(漂移)/ 用裸比较判先后(回绕)/ 过载后补跑
# 任务表:t_log 抬到最高 / 巡检周期缩到 5 ms / thermal 与 actuator 不同级
# 巡检:拿锁改成死等 / 一片读失败就中断整圈 / 读失败把值清零 / 忘了放锁 /
# 地址改成 0x40+ch / 多读一次 PCA9555 / 占用游标不对齐到「现在」
# 记账:漏掉 STOP 位
# sim:两把锁允许嵌套 / 假总线上的事务不花时间
# → 16 条全部变红。
# 21 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库):
# 硬重启:不等那 17 ms / 断电窗口缩到 1 s / 断电后忘了开回来 / 只置状态不断电 /
# 等不到 PG 就关路 / PG 只在进入那一刻看一眼 / 等 PG 时一觉睡满 500 ms /
# 开始前不松键
# 按键: 组合键分两次写 / 位号改成用通道号算 / 不查「正在跑别的序列」/
# 时长上限没了 / 按下失败就直接返回不收尾
# EDL: 顺序反过来(先插 USB 再短接)/ 收尾时顺手把 VBUS 也断了 / 位号用算的
# 收尾: 松键写成「整条链的键位全清掉」/ 写不进去就放弃 / 结束直接回 IDLE /
# 换状态后不当场再走一步
# abort: 顺手把电开回来
# → 21 条全部变红。
```
## 踩到的坑(只写会再犯的)
- ⚠️ **sim 里「干活不花时间」会让时序测试变成一句空话。** 巡检在 sim 中耗时为零时,
「周期 20 ms」测的只是「delay 参数传对了没」,任何实现都能踩准。
所以假 I²C 现在**真的把虚拟时钟花掉**(按位周期折算),
而且测试第一句就断言「这一圈确实花掉了 `t_safety_budget_us()`」——
这条断言本身就是防着有人把 `sim_i2c_set_bus_time` 关掉。
- ⚠️ **注错实验里 `old in src` 会命中你没想到的那一行。** 这次
`" s_bus_time = true;"` 先匹配到了声明行(`static bool s_bus_time = true;`
里正好含这段),改的是初值、而它每次 reset 又被覆盖回来 —— 于是那条注错「绿了」,
看上去像「测试没覆盖到」。**注错的锚点要连着上一行一起写**,
跑完还要确认它真的改到了那一处。
- **周期任务的相位要用绝对时刻,而且比较必须走 `int32_t` 差。**
裸写 `now >= next` 在 49.7 天回绕那一刻会把「还早」判成「迟到了 49 天」,
然后一口气空转两百万个周期。农场是长稳跑的,这一定会发生。
- **加锁纪律要能被强制检查,否则「都加了锁」是靠人读代码保证的。**
sim 侧 `sim_lock_require()` 打开后,动 I²C / 595 却没持锁就记一次违规;
配套必须有一条**反向测试**(不拿锁直接读一片,违规必须 +1),
不然检查失灵时那些「违规为 0」的断言会永远绿。
- **两把锁不定加锁顺序,直接禁止嵌套。** 这块板上 595 是纯 GPIO、I²C 是纯总线,
没有任何一处需要同时持有;把「不可能」编码进 `hal_lock_take()`,
比定一套顺序然后指望后面每个人都记得可靠。
- ⚠️ **「测试自己掌握节奏」会把「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。
早醒本身没关系:每个状态都是拿**绝对时刻**算的,早来就再返回一个剩余时间。
## 这一层的几条约定(写 A9/A10 之前看一眼)
- **任务体里不许有判据和时序。** 写进 `while(1)` 的东西宿主机一行也跑不到 ——
`app_main.c` 里的五个任务体各自只有三行(拿节拍 / 调一个函数 / 睡到下一拍),
真正的活在 `main/` 的其它 .c 里。A9 的状态机照这个形状写。
- **A10 的判据写在 `t_safety_step()` 里,而且要在放锁之后。**
§10.3 原话:越界直接操作 `EFUSE_EN` / `VBUS_EN`,**不经任何队列或抽象层**。
发消息给 t_actuator 是错的;拿着总线锁去写引脚也是错的(那两根线和 I²C 无关)。
- **判据先看 `ok` 再看值。** 读失败时 `snap.rail[]` 里留的是上一圈的值。
- **动了巡检里读的东西,就要同步改 `t_safety_budget_us()` 的那一行**
(测试会拦:静态预算与实测必须相等)。
- **器件初始化还没做**(INA226 的 Config/Calibration、DAC、PCA9555 方向、595 安全值),
那是 A15。在那之前上板跑起来电流恒读 0,是预期的,不是故障。
## 这一层的几条约定(写 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,再改这里。
+1
查看文件
@@ -27,6 +27,7 @@ set(PHONERAIL_CORE_SRCS
${FW_ROOT}/main/sched.c
${FW_ROOT}/main/tasks.c
${FW_ROOT}/main/t_safety.c
${FW_ROOT}/main/t_actuator.c
)
# HAL:接口在 include/,宿主机链 sim/ 的实现(esp/ 那份只有 IDF 目标编)。
+1
查看文件
@@ -9,5 +9,6 @@ idf_component_register(
"sched.c"
"tasks.c"
"t_safety.c"
"t_actuator.c"
INCLUDE_DIRS "."
REQUIRES phonerail_hal drv)
+37 -7
查看文件
@@ -11,6 +11,7 @@
#include "phonerail.h"
#include "rail_cfg.h"
#include "sched.h"
#include "t_actuator.h"
#include "t_safety.h"
#include "tasks.h"
@@ -50,23 +51,42 @@ static void selftest_or_die(void)
那些在宿主机上编得过、测得到。**任务体里不许出现判据与时序**:写进死循环里的
东西,宿主机一行也跑不到。 */
/* 巡检快照。**文件作用域**(A8 时它还在 task_safety 里):t_actuator 的硬重启
序列要等 PG,而 PG 挂在 PCA9555 上、已经被巡检每 20 ms 读一遍了(§10.3)。
执行器再去读一次等于凭空多一份总线占用和一份两边不一致的可能。
⚠️ 这是跨任务读,但读的只有两个 `bool`(`io_ok` 与一个位),
字节级读写在 Xtensa 上是原子的,不值得为它加第三把锁(hal_lock.h:只有两把)。 */
static safety_snap_t s_snap;
/* 喂给 t_actuator 的 PG 取值。返回 false = **这一刻读不到**
(PCA9555 失联,或者巡检还没跑过一圈)—— 与「读到了,是低」是两回事。 */
static bool act_pg_from_safety(void *ctx, int rail, bool *pg)
{
(void)ctx;
if (!s_snap.io_ok) {
return false;
}
*pg = pca9555_rail_pg(&s_snap.io, rail); /* 极性只在驱动里出现一次 */
return true;
}
static void task_safety(void *arg)
{
(void)arg;
static safety_snap_t snap; /* static:不占任务栈,也方便挂调试器看 */
t_safety_init(&snap);
safety_snap_t *const snapp = &s_snap;
t_safety_init(snapp);
sched_periodic_t tick;
sched_periodic_init(&tick, task_desc(TASK_SAFETY)->period_ms);
char line[128];
for (;;) {
(void)t_safety_step(&snap); /* 器件读失败不在这里处理:§10.2 失联 ≠ 断电 */
(void)t_safety_step(snapp); /* 器件读失败不在这里处理:§10.2 失联 ≠ 断电 */
/* 每 5 s 报一次总线占用。§10.3 手算是「约 1.5 ms / 约 8 %」,
首板上电时要拿逻辑分析仪复核这个数(§10.3 末尾那句)。 */
if ((snap.cycles % 250u) == 0u) {
t_safety_format(&snap, line, sizeof(line));
if ((snapp->cycles % 250u) == 0u) {
t_safety_format(snapp, line, sizeof(line));
ESP_LOGI(TAG, "%s (预算 %u us, 过载 %u, 最迟 %u ms)", line,
(unsigned)t_safety_budget_us(), (unsigned)tick.overruns,
(unsigned)tick.worst_late_ms);
@@ -89,12 +109,19 @@ static void task_thermal(void *arg)
}
}
/* A9 的三个状态机。任务体还是三行:推一步 / 拿它算出来的下一个截止时刻 / 睡到那时。
判据与时序全在 `t_actuator_step()` 里,那份在宿主机上跑得到。 */
static void task_actuator(void *arg)
{
(void)arg;
for (;;) {
/* A9:三个状态机 + 互斥 + 超时兜底。队列在那一包里建。 */
vTaskDelay(pdMS_TO_TICKS(100));
/* A12 会把这里换成「带超时地等命令队列」:超时值就是这个返回值。 */
const TickType_t t = pdMS_TO_TICKS(t_actuator_step());
/* ⚠️ `CONFIG_FREERTOS_HZ=100` ⇒ 一个 tick 是 10 ms,`pdMS_TO_TICKS(5)`
是 **0**,而 `vTaskDelay(0)` 只是让一次 CPU —— 那会让这个中优先级任务
在收尾重试时空转,把 t_link / t_log 饿死。至少睡一个 tick。
早醒没关系:每个状态都是拿绝对时刻算的,早来就再返回一个剩余时间。 */
vTaskDelay(t == 0 ? 1 : t);
}
}
@@ -160,6 +187,9 @@ void app_main(void)
与**降级矩阵**是 A15 的启动顺序那一包。在那之前 t_safety 读回来的电流恒为 0
(Calibration 还没写),这是预期的,不是故障。 */
/* 执行器:PG 从巡检的快照里取,本任务自己不碰 I²C(t_actuator.h 开头那一段)。 */
t_actuator_init(act_pg_from_safety, NULL);
spawn(TASK_SAFETY, task_safety);
spawn(TASK_THERMAL, task_thermal);
spawn(TASK_ACTUATOR, task_actuator);
+1 -1
查看文件
@@ -3,7 +3,7 @@
#define PHONERAIL_H
/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */
#define PHONERAIL_FW_VERSION "0.1.0-a8"
#define PHONERAIL_FW_VERSION "0.1.0-a9"
/* 通道数。五路直供,见设计文档 §2。 */
#define PHONERAIL_RAIL_COUNT 5
+418
查看文件
@@ -0,0 +1,418 @@
/* 三个执行器状态机 + 互斥。契约、时间常量的出处、三条规矩都在 t_actuator.h。 */
#include "t_actuator.h"
#include <assert.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "hal_lock.h"
#include "hal_time.h"
#include "rail_cfg.h"
#include "sr595.h"
typedef struct {
act_state_t state;
act_seq_t seq;
uint32_t t0_ms; /* 进入当前状态的时刻(绝对,回绕安全) */
uint32_t keys; /* KEY 序列:逻辑键位(ACT_KEYS_*) */
uint32_t hold_ms;
uint32_t release_mask; /* 收尾时要在链上清掉的位。0 = 这个序列没按下任何东西 */
act_result_t result;
uint32_t done;
uint32_t cleanup_retries;
} act_rail_t;
static act_rail_t s_rail[PHONERAIL_RAIL_COUNT];
static act_pg_fn s_pg;
static void *s_pg_ctx;
static bool s_inited;
/* ---- 小工具 -------------------------------------------------------------- */
static bool rail_valid(int rail) { return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT; }
static act_rail_t *at(int rail)
{
assert(rail_valid(rail));
return &s_rail[rail - 1];
}
/* 逻辑键位 → 链上位号。查 `sr595_key_bit()`,**不算**(sr595.h 的禁令)。 */
static uint32_t key_chain_mask(int rail, uint32_t keys)
{
uint32_t m = 0;
for (int k = 0; k < SR595_KEY_COUNT; k++) {
if ((keys & ((uint32_t)1u << k)) != 0) {
m |= (uint32_t)1u << sr595_key_bit(rail, (sr595_key_t)k);
}
}
return m;
}
/* 动 24 位链的**唯一**入口:拿 595 那把锁 → 改影子 + 整帧发下去 → 放锁。
一帧原子(RCLK 之前输出不变)⇒ 组合键的两位天然落在同一帧里。
⚠️ 这里绝不会去拿 I²C 那把锁,两把锁因此永远不会嵌套(hal_lock.h)。 */
static hal_err_t chain_write(uint32_t mask, uint32_t value)
{
hal_err_t err = hal_lock_take(HAL_LOCK_SR595, ACT_LOCK_TIMEOUT_MS);
if (err != HAL_OK) {
return err; /* 影子没动过,下一拍重来 */
}
err = sr595_write(mask, value);
hal_lock_give(HAL_LOCK_SR595);
return err;
}
static bool pg_now(int rail, bool *pg)
{
if (s_pg == NULL) {
return false; /* 「读不到」,不是「没好」 */
}
return s_pg(s_pg_ctx, rail, pg);
}
static void enter(act_rail_t *r, act_state_t st, uint32_t now)
{
r->state = st;
r->t0_ms = now;
}
/* 序列结束(成功、超时、出错、被打断走的是同一条路)。
按下去过东西就必须经 CLEANUP,那是「键一定会松开」的唯一保证。 */
static void finish(act_rail_t *r, act_result_t res, uint32_t now)
{
r->result = res;
r->done++;
if (r->release_mask != 0) {
enter(r, ACT_ST_CLEANUP, now);
} else {
r->seq = ACT_SEQ_NONE;
enter(r, ACT_ST_IDLE, now);
}
}
/* ---- 状态机本体:一个状态一段,返回「还要多久再来」 ---------------------- */
static uint32_t state_step(act_rail_t *r, int rail, uint32_t now)
{
const uint32_t el = now - r->t0_ms; /* 无符号减法自然回绕 */
switch (r->state) {
case ACT_ST_IDLE:
return ACT_IDLE_WAIT_MS;
case ACT_ST_HR_OFF:
if (el < ACT_HR_OFF_MS) {
return ACT_HR_OFF_MS - el;
}
/* 放电这时早就由硬件做完了(τ 22 ms,5τ ≈ 110 ms),固件什么也不用管。 */
if (rail_efuse_enable(rail, true) != HAL_OK) {
/* 开不回来是安全关键的失败:这一路现在是关着的。记 IO,让上层看见。 */
finish(r, ACT_RES_IO, now);
return ACT_CLEANUP_RETRY_MS;
}
enter(r, ACT_ST_HR_RAMP, now);
return ACT_HR_RAMP_MS;
case ACT_ST_HR_RAMP:
/* tON 5.1 ms + 缓启动斜坡 11.5 ms。这段时间里 PG 本来就不该置位,
所以**根本不看它** —— 提前去看只会看到「还没好」。 */
if (el < ACT_HR_RAMP_MS) {
return ACT_HR_RAMP_MS - el;
}
enter(r, ACT_ST_HR_PG, now);
return ACT_HR_PG_POLL_MS;
case ACT_ST_HR_PG: {
bool pg = false;
if (pg_now(rail, &pg) && pg) {
finish(r, ACT_RES_OK, now);
return ACT_IDLE_WAIT_MS;
}
if (el >= ACT_HR_PG_TIMEOUT_MS) {
/* ⚠️ 电**继续开着**。见 ACT_RES_NO_PG 的注释:把「看不见」当成
「坏了」去断电,等于一片 PCA9555 失联就让五台手机一起停机。 */
finish(r, ACT_RES_NO_PG, now);
return ACT_IDLE_WAIT_MS;
}
const uint32_t left = ACT_HR_PG_TIMEOUT_MS - el;
return left < ACT_HR_PG_POLL_MS ? left : ACT_HR_PG_POLL_MS;
}
case ACT_ST_KEY_HOLD:
if (el < r->hold_ms) {
return r->hold_ms - el;
}
finish(r, ACT_RES_OK, now); /* release_mask 非 0 ⇒ 一定进 CLEANUP 松键 */
return ACT_CLEANUP_RETRY_MS;
case ACT_ST_EDL_SHORT:
/* SW-6:先短接、再插 USB。这两句的先后就是这条需求的全部内容。 */
if (el < ACT_EDL_SHORT_MS) {
return ACT_EDL_SHORT_MS - el;
}
if (rail_vbus_enable(rail, true) != HAL_OK) {
finish(r, ACT_RES_IO, now); /* CLEANUP 会把 EDL_EN 清掉 */
return ACT_CLEANUP_RETRY_MS;
}
enter(r, ACT_ST_EDL_ENUM, now);
return ACT_EDL_ENUM_MS;
case ACT_ST_EDL_ENUM:
if (el < ACT_EDL_ENUM_MS) {
return ACT_EDL_ENUM_MS - el;
}
/* `EDL_EN=0`(收尾那一步)。**VBUS 留着不动** —— 手机现在正挂在 9008 上。 */
finish(r, ACT_RES_OK, now);
return ACT_CLEANUP_RETRY_MS;
case ACT_ST_CLEANUP:
if (chain_write(r->release_mask, 0) == HAL_OK) {
r->release_mask = 0;
r->seq = ACT_SEQ_NONE;
enter(r, ACT_ST_IDLE, now);
return ACT_IDLE_WAIT_MS;
}
/* ⚠️ **不放弃。** 放弃 = 电源键留在按下状态,手机再也起不来,
而五路电压电流看上去一切正常。这一路一直算 busy,重试次数报出去。 */
r->cleanup_retries++;
return ACT_CLEANUP_RETRY_MS;
}
assert(0 && "unknown state");
return ACT_IDLE_WAIT_MS;
}
/* 换了状态就当场再走一步:CLEANUP 该立刻试一次,不该先睡 5 ms。
上限 8 是防呆 —— 状态机里任何一个环都会在这里被截住,而不是把任务钉死。 */
static uint32_t rail_step(int rail, uint32_t now)
{
act_rail_t *r = at(rail);
for (int guard = 0; guard < 8; guard++) {
const act_state_t before = r->state;
const uint32_t wait = state_step(r, rail, now);
if (r->state == before) {
return wait;
}
}
return ACT_CLEANUP_RETRY_MS;
}
/* ---- 接口 ---------------------------------------------------------------- */
void t_actuator_init(act_pg_fn pg, void *ctx)
{
memset(s_rail, 0, sizeof(s_rail));
s_pg = pg;
s_pg_ctx = ctx;
s_inited = true;
}
hal_err_t t_actuator_hard_reset(int rail)
{
if (!s_inited) {
return HAL_ERR_STATE;
}
if (!rail_valid(rail)) {
return HAL_ERR_ARG;
}
act_rail_t *r = at(rail);
if (r->state != ACT_ST_IDLE) {
return HAL_ERR_STATE; /* §10.6:不可重入。要抢占先 abort,写成显式的 */
}
/* 防御性松开这一路的三个键:断电重启期间有键被按着,手机会开进 Recovery /
Fastboot 而不是正常起来。正常情况下这里一定已经是 0(有序列在跑的话
上面那一句就把请求挡了),所以影子不变 ⇒ 不脏 ⇒ 一个时钟都不会发。
只有上一次收尾没落地时才真的发一帧。 */
const uint32_t km = sr595_key_mask(rail);
if ((sr595_shadow() & km) != 0) {
(void)chain_write(km, 0); /* 失败也照断电:断电永远比不断电安全 */
}
const hal_err_t err = rail_efuse_enable(rail, false);
if (err != HAL_OK) {
return err; /* 序列没开始 */
}
r->seq = ACT_SEQ_HARD_RESET;
r->release_mask = 0; /* 硬重启在链上没按下任何东西 */
r->result = ACT_RES_NONE;
enter(r, ACT_ST_HR_OFF, hal_time_now_ms());
return HAL_OK;
}
hal_err_t t_actuator_key(int rail, uint32_t keys, uint32_t hold_ms)
{
if (!s_inited) {
return HAL_ERR_STATE;
}
if (!rail_valid(rail) || keys == 0 || (keys & ~(uint32_t)ACT_KEYS_ALL) != 0) {
return HAL_ERR_ARG;
}
if (hold_ms == 0 || hold_ms > ACT_KEY_MAX_MS) {
return HAL_ERR_ARG; /* 拦手误,不是保护 */
}
act_rail_t *r = at(rail);
if (r->state != ACT_ST_IDLE) {
return HAL_ERR_STATE;
}
const uint32_t mask = key_chain_mask(rail, keys);
/* 组合键的两位在**同一次 `chain_write()`** 里 ⇒ 同一帧 ⇒ 同一个 RCLK 沿。
分两次写会先单按一个键(§10.6)。 */
const hal_err_t err = chain_write(mask, mask);
r->seq = ACT_SEQ_KEY;
r->keys = keys;
r->hold_ms = hold_ms;
r->result = ACT_RES_NONE;
r->release_mask = mask;
if (err != HAL_OK) {
/* 影子可能已经改成「按下」了(拿到锁但整帧没发出去)。直接返回错误
就等于把一个「按下」的脏影子留在那儿,谁下一次 flush 谁就替我们把键
按下去 —— 而没有人会来松开它。所以照样进 CLEANUP 把它撤回来。 */
finish(r, ACT_RES_IO, hal_time_now_ms());
return err;
}
enter(r, ACT_ST_KEY_HOLD, hal_time_now_ms());
return HAL_OK;
}
hal_err_t t_actuator_edl(int rail)
{
if (!s_inited) {
return HAL_ERR_STATE;
}
if (!rail_valid(rail)) {
return HAL_ERR_ARG;
}
act_rail_t *r = at(rail);
if (r->state != ACT_ST_IDLE) {
return HAL_ERR_STATE;
}
const uint32_t mask = (uint32_t)1u << sr595_edl_bit(rail); /* 查表,不算 */
const hal_err_t err = chain_write(mask, mask);
r->seq = ACT_SEQ_EDL;
r->result = ACT_RES_NONE;
r->release_mask = mask;
if (err != HAL_OK) {
finish(r, ACT_RES_IO, hal_time_now_ms());
return err;
}
enter(r, ACT_ST_EDL_SHORT, hal_time_now_ms());
return HAL_OK;
}
void t_actuator_abort(int rail)
{
if (!s_inited || !rail_valid(rail)) {
return;
}
act_rail_t *r = at(rail);
if (r->state == ACT_ST_IDLE || r->state == ACT_ST_CLEANUP) {
return; /* CLEANUP 已经在收尾了,别把它的结果覆盖掉 */
}
finish(r, ACT_RES_ABORTED, hal_time_now_ms());
(void)rail_step(rail, hal_time_now_ms()); /* 立刻试一次收尾,别等下一拍 */
}
uint32_t t_actuator_step(void)
{
if (!s_inited) {
return ACT_IDLE_WAIT_MS;
}
const uint32_t now = hal_time_now_ms();
uint32_t wait = ACT_IDLE_WAIT_MS;
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
const uint32_t w = rail_step(rail, now);
if (w < wait) {
wait = w;
}
}
return wait;
}
/* ---- 观测 ---------------------------------------------------------------- */
bool t_actuator_busy(int rail)
{
return rail_valid(rail) && at(rail)->state != ACT_ST_IDLE;
}
act_state_t t_actuator_state(int rail) { return at(rail)->state; }
act_seq_t t_actuator_seq(int rail) { return at(rail)->seq; }
act_result_t t_actuator_last_result(int rail) { return at(rail)->result; }
uint32_t t_actuator_done(int rail) { return at(rail)->done; }
uint32_t t_actuator_cleanup_retries(int rail) { return at(rail)->cleanup_retries; }
const char *t_actuator_state_str(act_state_t st)
{
switch (st) {
case ACT_ST_IDLE: return "idle";
case ACT_ST_HR_OFF: return "hr-off";
case ACT_ST_HR_RAMP: return "hr-ramp";
case ACT_ST_HR_PG: return "hr-pg";
case ACT_ST_KEY_HOLD: return "key-hold";
case ACT_ST_EDL_SHORT: return "edl-short";
case ACT_ST_EDL_ENUM: return "edl-enum";
case ACT_ST_CLEANUP: return "cleanup";
}
return "?";
}
const char *t_actuator_result_str(act_result_t r)
{
switch (r) {
case ACT_RES_NONE: return "-";
case ACT_RES_OK: return "ok";
case ACT_RES_NO_PG: return "no-pg";
case ACT_RES_IO: return "io";
case ACT_RES_ABORTED: return "aborted";
}
return "?";
}
static void app_fmt(char *buf, size_t len, size_t *off, const char *fmt, ...)
{
if (*off + 1 >= len) {
return;
}
va_list ap;
va_start(ap, fmt);
const int k = vsnprintf(buf + *off, len - *off, fmt, ap);
va_end(ap);
if (k < 0) {
return;
}
*off += (size_t)k;
if (*off >= len) {
*off = len - 1; /* 截断了 */
}
}
int t_actuator_format(char *buf, size_t len)
{
assert(buf != NULL && len > 0);
size_t off = 0;
uint32_t done = 0;
uint32_t retries = 0;
app_fmt(buf, len, &off, "t_actuator:");
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
const act_rail_t *r = at(rail);
app_fmt(buf, len, &off, " ch%d=%s", rail, t_actuator_state_str(r->state));
done += r->done;
retries += r->cleanup_retries;
}
app_fmt(buf, len, &off, " done=%u retry=%u", (unsigned)done, (unsigned)retries);
return (int)off;
}
+203
查看文件
@@ -0,0 +1,203 @@
/* `t_actuator` —— 每路三个序列 + 互斥(需求 §10.6,§10.3 的第三行任务)。
三个序列,**同一路上同时只能跑一个**(§10.6 原话「彼此互斥、不可重入」):
| 序列 | 时序 | 出处 |
|---|---|---|
| 硬重启 | `EFUSE_EN↓` → 等 3~5 s → `EFUSE_EN↑` → 等 **17 ms** → 等 PG 置位 | 设计文档 §7.4 |
| 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | SW-5 / §13.3 |
| EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | SW-6 / §22.4 |
---- 三条不能改的规矩 ----------------------------------------------------
1. **一个函数一步,不许写 `while(1)`。** 死循环在宿主机上一行也跑不到,
而这一包的全部价值就是那几个时间常量与它们的先后 —— 那正是要被测的东西。
`t_actuator_step()` 只看时钟、推状态、返回「下次最晚多久再来」,
任务体在 app_main.c 里,还是三行。
2. **放电由硬件联锁自动完成(SR-7),固件一个字都不要写。** 放电 MOS 的栅极
经一级反相器接在 `EFUSE_EN` 上(设计文档 §7.3),物理上不可能与 eFuse 同时导通。
固件里再补一根「放电控制」= 10 Ω 上持续 1.76 W,电阻烧掉。
所以本文件里**没有任何放电接口**,硬重启只有一根 `EFUSE_EN`。
3. **按下去的东西一定要松开。** 这一层最坏的失败不是「序列没走完」,
而是**键留在按下状态** —— 电源键被一直按着,手机再也起不来,而且遥测上
五路电压电流全都正常。所以每个序列都以 `ACT_ST_CLEANUP` 收尾(超时、
出错、正常结束走的是同一条路),而且 **CLEANUP 不放弃**:写不进去就一直重试,
这一路一直算 busy。放弃比重试危险得多。
---- PG 为什么是别人喂进来的 ----------------------------------------------
`PGOOD` 挂在 PCA9555 的 Port1 上,而 PCA9555 在 I²C 上,`t_safety` 已经每 20 ms
无条件读一次(§10.3)。执行器再去读一遍等于凭空多一份总线占用和一份两边不一致的
可能,所以 PG 从外面**喂**进来(`act_pg_fn`)。这条回调也是「不许在这里拿 I²C 锁」
的物理保证:本文件只碰 GPIO 与 595,与 I²C 无交集 ⇒ 两把锁永远不会嵌套。 */
#ifndef T_ACTUATOR_H
#define T_ACTUATOR_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "hal_err.h"
#include "phonerail.h"
#include "sr595.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- 时间常量:每一个都有出处,不许拍脑袋改 ----------------------------- */
/* 硬重启断电时长。§7.4:放到 <0.5 V 只要 ~110 ms(τ = 2200 µF × 10 Ω = 22 ms),
其余是等手机彻底断电,窗口 3~5 s,取中值。
⚠️ 缩到 1 s 的现象**不是**「重启快了」,而是「有的机型压根没重启」
(某些机型要 VBAT 掉到 2.5 V 以下才算真断电,见 §7.1)。 */
#define ACT_HR_OFF_MS 4000
#define ACT_HR_OFF_MIN_MS 3000 /* §7.4 的窗口,自检与测试拿它对账 */
#define ACT_HR_OFF_MAX_MS 5000
/* `EFUSE_EN↑` 之后到输出稳定:tON 延迟 5.1 ms(C = 33 nF)+ 缓启动斜坡 11.5 ms
(0.36 V/ms)≈ **17 ms**(设计文档 §5「缓启动实际值」与 §7.4)。
这段时间之内 PG 本来就不该置位,去看它只会看到「还没好」。 */
#define ACT_HR_RAMP_MS 17
/* 等 PG 的上限。TPS25940A 的 PGOOD 在输出过 2.97 V 时释放,也就是斜坡中段就该来了;
500 ms 是 ~30 倍余量。等不到**不代表要再断一次电**,见 `ACT_RES_NO_PG`。 */
#define ACT_HR_PG_TIMEOUT_MS 500
/* 等 PG 期间多久回来看一眼。PG 是 `t_safety` 每 20 ms 刷新的快照,
比这个再密也没有新值。⚠️ 不能直接睡满 500 ms —— 那样「等到 PG 就结束」
会退化成「一律等满 500 ms」,而测试与遥测都看不出区别。 */
#define ACT_HR_PG_POLL_MS 5
/* SW-5 的三档按键时长。 */
#define ACT_KEY_MS_TAP 100 /* 短按:唤醒 / 息屏 */
#define ACT_KEY_MS_ON 2000 /* 长按:开机 */
#define ACT_KEY_MS_FORCE 10000 /* 长按:强制重启(两级重启的「一级(软)」) */
/* 按键时长上限。上层传进来一个 10 分钟,等于把电源键按死 ——
这不是保护,是**拦调用方的手误**(SR-13 拦 4.2 V 是同一个道理)。 */
#define ACT_KEY_MAX_MS 15000
/* EDL:先短接、再插 USB(SW-6 的顺序是硬要求)。
模拟开关 TS5A3157 的 Ron ~1 Ω、导通时间 µs 级,10 ms 是给它和 1.8 kΩ 上的建立留的。 */
#define ACT_EDL_SHORT_MS 10
/* 「等枚举」。⚠️ **固件看不见枚举** —— 手机挂在板载 Hub 的下行口上,ESP32 这一侧
没有任何信号能告诉它 9008 有没有起来。所以这里只能定时等,3 s 是按 USB 枚举
典型 <1 s 给的余量。真要确认枚举成功,只能由上位机 `lsusb` 看 05c6:9008
(那是 §10.8 上位机那一侧的事)。 */
#define ACT_EDL_ENUM_MS 3000
/* 拿 595 那把锁最多等多久。这把锁的争用只可能来自 A15 的初始化与 A10 的收尾,
都是短事务;等不到就下一拍再来(CLEANUP 会一直重试)。 */
#define ACT_LOCK_TIMEOUT_MS 20
/* CLEANUP 重试间隔,以及空闲时建议任务睡多久(A12 会换成「带超时地等队列」)。 */
#define ACT_CLEANUP_RETRY_MS 5
#define ACT_IDLE_WAIT_MS 100
/* ---- 按键:逻辑键位,不是链上位号 ---------------------------------------
上层一律用这三个位(和它们的组合)表达「按哪几个键」,链上的 24 位位号
由本文件经 `sr595_key_bit()` 查表换算。**任何地方都不许出现 `1 << (rail-1)`
这类算出来的位号**(sr595.h 的那条禁令)。 */
typedef enum {
ACT_KEYS_PWR = 1u << SR595_KEY_PWR,
ACT_KEYS_VUP = 1u << SR595_KEY_VUP,
ACT_KEYS_VDN = 1u << SR595_KEY_VDN,
/* SW-5 的两个组合键。**必须同一帧写进去**(§10.6)——
分两次写会先单按一个键,而「先单按 VOL−」在有的机型上就是音量调节。 */
ACT_KEYS_FASTBOOT = ACT_KEYS_VDN | ACT_KEYS_PWR,
ACT_KEYS_RECOVERY = ACT_KEYS_VUP | ACT_KEYS_PWR,
ACT_KEYS_ALL = ACT_KEYS_PWR | ACT_KEYS_VUP | ACT_KEYS_VDN,
} act_keys_t;
/* ---- 状态与结果 ---------------------------------------------------------- */
typedef enum {
ACT_SEQ_NONE = 0,
ACT_SEQ_HARD_RESET,
ACT_SEQ_KEY,
ACT_SEQ_EDL,
} act_seq_t;
typedef enum {
ACT_ST_IDLE = 0,
ACT_ST_HR_OFF, /* `EFUSE_EN` 已拉低,等 3~5 s(放电由硬件自己做) */
ACT_ST_HR_RAMP, /* `EFUSE_EN` 已拉高,等 17 ms 走完 tON + 斜坡 */
ACT_ST_HR_PG, /* 等 PG 置位 */
ACT_ST_KEY_HOLD, /* 键已按下,保持 T */
ACT_ST_EDL_SHORT, /* `EDL_EN=1`,等短接建立 */
ACT_ST_EDL_ENUM, /* `VBUS_EN=1`,等枚举 */
ACT_ST_CLEANUP, /* 把本序列按下去的东西松开,落不下去就重试(不放弃) */
} act_state_t;
typedef enum {
ACT_RES_NONE = 0,
ACT_RES_OK,
/* 硬重启专用:电**已经开着**,只是 500 ms 内没等到 PG(也可能是 PCA9555
读不到)。⚠️ 这时**不许**再把这一路关掉:§10.10 里「PCA9555 不应答 =
失去 FAULT/PG 可见性,**不关输出**」。把「看不见」当成「坏了」去断电,
等于一片 I/O 扩展器失联就让五台手机一起停机。 */
ACT_RES_NO_PG,
/* 写 595 或写 EN 引脚失败。序列中止,但收尾照做(键一定松开)。 */
ACT_RES_IO,
/* 被 `t_actuator_abort()` 打断。 */
ACT_RES_ABORTED,
} act_result_t;
/* PG 取值回调。返回 false = **这一刻读不到**(PCA9555 失联 / 还没读过一圈),
与「读到了,是低」是两回事 —— 后者是「电源还没好」,前者是「不知道」。
传 NULL 给 `t_actuator_init()` 就是「永远读不到」,硬重启会走完并记 ACT_RES_NO_PG。 */
typedef bool (*act_pg_fn)(void *ctx, int rail, bool *pg);
/* ---- 接口 ---------------------------------------------------------------- */
/* 幂等,五路全部回到 IDLE。**不碰硬件**:SW-11 的「上电全关」是
`rail_en_init()` + `sr595_init()` 的安全影子干的(A15),不是这里。 */
void t_actuator_init(act_pg_fn pg, void *ctx);
/* 三个请求。rail 是 **1~5**。
HAL_ERR_ARG —— 路号 / 键位 / 时长越界;
HAL_ERR_STATE —— 没 init,或者**这一路正在跑另一个序列**(§10.6 不可重入)。
要抢占的话调用方得先 `t_actuator_abort()`,写成显式的。
⚠️ 返回非 HAL_OK 时序列**没有开始**,只有一个例外:按下 / 置位那一步真的
发到链上但失败了 —— 那时影子可能已经改了,所以那一路会进 CLEANUP 把它撤回来,
函数仍然返回错误码,但 `t_actuator_busy()` 会短暂为 true。 */
hal_err_t t_actuator_hard_reset(int rail);
hal_err_t t_actuator_key(int rail, uint32_t keys, uint32_t hold_ms);
hal_err_t t_actuator_edl(int rail);
/* 打断这一路正在跑的序列:立刻收尾(键松开 / `EDL_EN` 清零),结果记 ACT_RES_ABORTED。
**不动 `EFUSE_EN` / `VBUS_EN`** —— 硬重启走到一半被打断时,把电再关一次
或者再开一次都是自作主张;断电是 A10 的判据和上位机命令的事。 */
void t_actuator_abort(int rail);
/* 推一步。返回**下次最晚多久再来**(ms):五路里最近的那个截止时刻。
全空闲返回 ACT_IDLE_WAIT_MS。醒早了没关系 —— 每个状态都是拿绝对时刻算的,
早来就再返回一个剩余时间。(ESP-IDF 默认 `CONFIG_FREERTOS_HZ=100`,
`vTaskDelay(pdMS_TO_TICKS(17))` 只有 1 个 tick,本来就会早醒。) */
uint32_t t_actuator_step(void);
/* ---- 观测 ---------------------------------------------------------------- */
bool t_actuator_busy(int rail);
act_state_t t_actuator_state(int rail);
act_seq_t t_actuator_seq(int rail);
act_result_t t_actuator_last_result(int rail);
uint32_t t_actuator_done(int rail); /* 走完几个序列(含失败的) */
uint32_t t_actuator_cleanup_retries(int rail); /* 收尾重试次数,遥测里报出去 */
const char *t_actuator_state_str(act_state_t st);
const char *t_actuator_result_str(act_result_t r);
/* 一行人话,给启动日志和遥测用。返回写进去的字符数(不含结尾 0)。 */
int t_actuator_format(char *buf, size_t len);
#ifdef __cplusplus
}
#endif
#endif /* T_ACTUATOR_H */
+670
查看文件
@@ -0,0 +1,670 @@
/* A9:三个执行器状态机 + 互斥(需求 §10.6)。计划里这一包的验收原话是
「场景测试:硬重启走完 17 ms 上电时序;按键序列不重入;EDL 顺序固定」。
⚠️ 这三条都**不是**「函数返回了 HAL_OK」能证明的东西,所以本文件里的断言
一律打在**线上真实发生的事**上:
· 24 位链看 `sim_sr595_*`(RCLK 沿的个数、栅极上的电平),不看驱动自己的影子
—— 对着影子断言等于自己证明自己,「分两次写把组合键拆开了」影子看不出来;
· 两根 EN 线看 `sim_gpio_level()` 与**全局写序号** `sim_gpio_write_seq()`
—— 只看终值的话,「先插 USB 再短接」和「先短接再插 USB」长得一模一样。
虚拟时钟下这些场景是零耗时的:`run_ms()` 干的就是任务体干的事
—— 调一次 `t_actuator_step()`,按它返回的时间往前拨表。 */
#include <stdio.h>
#include <string.h>
#include "hal_gpio.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "phonerail.h"
#include "rail_cfg.h"
#include "sim_hal.h"
#include "sr595.h"
#include "t_actuator.h"
#include "unity.h"
#define N PHONERAIL_RAIL_COUNT
/* ---- 假 PG ---------------------------------------------------------------
真板上这个值来自 `t_safety` 每 20 ms 读回来的 PCA9555 快照(app_main.c 里接的)。
`readable = false` 模拟「PCA9555 失联 / 巡检还没跑过一圈」—— 那是**读不到**,
和「读到了,是低」(电源还没好)是两回事,两条路径下面都要测到。 */
static bool s_pg_readable;
static bool s_pg_val[N];
static bool fake_pg(void *ctx, int rail, bool *pg)
{
(void)ctx;
if (!s_pg_readable) {
return false;
}
*pg = s_pg_val[rail - 1];
return true;
}
static void board_up(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init());
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); /* 十根 EN 线,全部拉低 */
sim_sr595_attach(SR595_PIN_SER, SR595_PIN_SRCLK, SR595_PIN_RCLK, SR595_PIN_OE_N);
TEST_ASSERT_EQUAL_INT(HAL_OK, sr595_init()); /* 安全影子 + 使能输出(A5) */
/* 强制加锁纪律 —— 但要在 `sr595_init()` **之后**打开:初始化是启动期
独占跑的,本来就不该被要求先拿锁。打开之后,动那四根线却没持锁就记违规。 */
sim_lock_require(HAL_LOCK_SR595, true);
s_pg_readable = true;
memset(s_pg_val, 0, sizeof(s_pg_val));
t_actuator_init(fake_pg, NULL);
}
void setUp(void)
{
sim_hal_reset();
board_up();
}
void tearDown(void) {}
/* 把虚拟时钟往前推 ms,期间按 `t_actuator_step()` 自己要的节奏叫它 ——
这正是 app_main.c 里那个任务体在干的事。
⚠️ 用**绝对目标时刻**而不是「累减 left」:等锁超时会在 step 内部把虚拟时钟
花掉(hal_lock_sim.c),累减的写法会因此推过头。 */
static void run_ms(uint32_t ms)
{
const uint32_t target = hal_time_now_ms() + ms;
for (;;) {
uint32_t w = t_actuator_step();
const uint32_t now = hal_time_now_ms();
if ((int32_t)(now - target) >= 0) {
return; /* 到点了,而且最后这一步已经推过 */
}
const uint32_t left = target - now;
if (w == 0 || w > left) {
w = left;
}
sim_time_advance_ms(w);
}
}
static uint32_t keys_on_chain(void) { return sim_sr595_outputs() & sr595_all_keys_mask(); }
/* ==== 验收主项一:硬重启走完 17 ms 上电时序 =============================== */
/* 用 CH3 —— 它的 `EFUSE_EN` 是 IO6、`VBUS_EN` 是 IO11、DAC 在**另一片**上,
全是「按通道号算就一定算错」的那一路。 */
static void test_hard_reset_walks_the_full_power_up_sequence(void)
{
const int rail = 3;
const int en = rail_cfg(rail)->efuse_en;
/* 先让这一路开着 —— 硬重启是给**正在跑的**手机用的。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, true));
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(en));
s_pg_val[rail - 1] = true; /* PG 一直是好的 */
const uint32_t latches0 = sim_sr595_latches();
const uint32_t t_req = hal_time_now_ms();
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail));
TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(en)); /* 请求那一刻就断电,不等下一拍 */
TEST_ASSERT_TRUE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_INT(ACT_SEQ_HARD_RESET, t_actuator_seq(rail));
/* 断电窗口是 §7.4 的 3~5 s(放电由硬件联锁做,τ 22 ms)。差一毫秒都不许上电:
缩短它的现象**不是**「重启快了」,而是「有的机型压根没重启」。 */
run_ms(ACT_HR_OFF_MIN_MS - 1);
TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(en));
TEST_ASSERT_EQUAL_INT(ACT_ST_HR_OFF, t_actuator_state(rail));
run_ms(ACT_HR_OFF_MS - (ACT_HR_OFF_MIN_MS - 1));
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(en)); /* EN↑ */
TEST_ASSERT_EQUAL_INT(ACT_ST_HR_RAMP, t_actuator_state(rail));
const uint32_t t_on = hal_time_now_ms();
const uint32_t off_ms = t_on - t_req;
TEST_ASSERT_TRUE(off_ms >= ACT_HR_OFF_MIN_MS && off_ms <= ACT_HR_OFF_MAX_MS);
/* ⭐ 这一条就是「17 ms 上电时序」:**PG 早就是 1 了**,序列也一步都不许提前完成。
tON 5.1 ms + 缓启动斜坡 11.5 ms 之内,输出还在爬。
少等这 17 ms 的实现在这里当场变红。 */
run_ms(ACT_HR_RAMP_MS - 1);
TEST_ASSERT_EQUAL_INT(ACT_ST_HR_RAMP, t_actuator_state(rail));
TEST_ASSERT_TRUE(t_actuator_busy(rail));
run_ms(1);
TEST_ASSERT_EQUAL_UINT32(ACT_HR_RAMP_MS, hal_time_now_ms() - t_on);
TEST_ASSERT_FALSE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail));
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(en));
TEST_ASSERT_EQUAL_UINT32(1, t_actuator_done(rail));
/* 硬重启在 24 位链上什么都不该发(键本来就没按着 ⇒ 影子不变 ⇒ 不脏 ⇒ 不发帧)。 */
TEST_ASSERT_EQUAL_UINT32(latches0, sim_sr595_latches());
/* 也不该碰 I²C 那把锁 —— 两把锁因此永远不会嵌套。 */
TEST_ASSERT_EQUAL_UINT32(0, sim_lock_takes(HAL_LOCK_I2C));
TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_SR595));
}
/* 别的四路的两根 EN 线,从头到尾一次都不该被写。 */
static void test_hard_reset_touches_only_its_own_two_pins(void)
{
const int rail = 2;
uint32_t before[N][2];
for (int r = 1; r <= N; r++) {
before[r - 1][0] = sim_gpio_writes(rail_cfg(r)->efuse_en);
before[r - 1][1] = sim_gpio_writes(rail_cfg(r)->vbus_en);
}
s_pg_val[rail - 1] = true;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail));
run_ms(ACT_HR_OFF_MS + ACT_HR_RAMP_MS);
TEST_ASSERT_FALSE(t_actuator_busy(rail));
for (int r = 1; r <= N; r++) {
if (r == rail) {
continue;
}
TEST_ASSERT_EQUAL_UINT32(before[r - 1][0], sim_gpio_writes(rail_cfg(r)->efuse_en));
TEST_ASSERT_EQUAL_UINT32(before[r - 1][1], sim_gpio_writes(rail_cfg(r)->vbus_en));
}
/* VBUS 是 EDL 的事,硬重启不碰。 */
TEST_ASSERT_EQUAL_UINT32(before[rail - 1][1], sim_gpio_writes(rail_cfg(rail)->vbus_en));
}
/* PG 等不到(PCA9555 失联)⇒ 记 NO_PG,但**电继续开着**。
§10.10:这片不应答 = 失去 PG 可见性,**不关输出**。把「看不见」当「坏了」去断电,
等于一片 I/O 扩展器失联就让五台手机一起停机。 */
static void test_no_pg_leaves_the_rail_powered(void)
{
const int rail = 4;
const int en = rail_cfg(rail)->efuse_en;
s_pg_readable = false;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail));
run_ms(ACT_HR_OFF_MS + ACT_HR_RAMP_MS + ACT_HR_PG_TIMEOUT_MS);
TEST_ASSERT_FALSE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_INT(ACT_RES_NO_PG, t_actuator_last_result(rail));
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(en)); /* ⭐ 不许再关掉 */
}
/* 读得到、但一直是低(电源确实没好)走的是同一条路:照样不自作主张断电。 */
static void test_pg_stuck_low_also_leaves_the_rail_powered(void)
{
const int rail = 1;
s_pg_readable = true;
s_pg_val[rail - 1] = false;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail));
run_ms(ACT_HR_OFF_MS + ACT_HR_RAMP_MS + ACT_HR_PG_TIMEOUT_MS);
TEST_ASSERT_EQUAL_INT(ACT_RES_NO_PG, t_actuator_last_result(rail));
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(rail_cfg(rail)->efuse_en));
}
/* PG 迟到(斜坡走完之后 200 ms 才置位)也要认,不能只在进 HR_PG 那一刻看一眼。 */
static void test_late_pg_is_still_accepted(void)
{
const int rail = 5;
s_pg_readable = true;
s_pg_val[rail - 1] = false;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail));
run_ms(ACT_HR_OFF_MS + ACT_HR_RAMP_MS + 200);
TEST_ASSERT_EQUAL_INT(ACT_ST_HR_PG, t_actuator_state(rail));
s_pg_val[rail - 1] = true;
run_ms(ACT_HR_PG_POLL_MS);
TEST_ASSERT_FALSE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail));
}
/* ⚠️ 上面那些用的都是 `run_ms()` —— 它按**测试自己的**节奏叫 step,
所以测不出「step 返回的睡眠时长本身对不对」。真板上的任务体是**完全听 step 的**:
它说睡多久就睡多久。这个测试照那样跑,PG 在斜坡走完之后才置位,
序列必须在一个轮询间隔之内收工 —— 「等 PG 时一觉睡满 500 ms 超时」的写法在这里变红
(那种写法把「等到 PG 就结束」悄悄变成了「一律等满 500 ms」,功能测试看不出来)。 */
static void test_the_task_loop_only_sleeps_what_step_asks_for(void)
{
const int rail = 2;
const uint32_t pg_at = ACT_HR_OFF_MS + ACT_HR_RAMP_MS + 1;
s_pg_readable = true;
s_pg_val[rail - 1] = false;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail));
const uint32_t t0 = hal_time_now_ms();
bool pg_raised = false;
uint32_t total = 0;
for (int i = 0; i < 10000; i++) {
const uint32_t w = t_actuator_step(); /* 任务体:它说睡多久就睡多久 */
if (!t_actuator_busy(rail)) {
total = hal_time_now_ms() - t0; /* 收工的**那一刻**,不是下一次醒来 */
break;
}
sim_time_advance_ms(w == 0 ? 1 : w);
if (!pg_raised && hal_time_now_ms() - t0 >= pg_at) {
s_pg_val[rail - 1] = true;
pg_raised = true;
}
}
TEST_ASSERT_TRUE(pg_raised);
TEST_ASSERT_FALSE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail));
TEST_ASSERT_TRUE(total <= pg_at + 2 * ACT_HR_PG_POLL_MS);
}
/* 硬重启之前先把这一路的三个键松开:断电重启时有键按着,手机会开进
Recovery / Fastboot 而不是正常起来。这里手工造一个「上一次收尾没落地」的脏位。 */
static void test_hard_reset_releases_a_stuck_key_before_cutting_power(void)
{
const int rail = 1;
const int bit = sr595_key_bit(rail, SR595_KEY_PWR);
const int other = sr595_key_bit(4, SR595_KEY_VUP);
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_SR595, 10));
TEST_ASSERT_EQUAL_INT(HAL_OK,
sr595_write(((uint32_t)1u << bit) | ((uint32_t)1u << other),
((uint32_t)1u << bit) | ((uint32_t)1u << other)));
hal_lock_give(HAL_LOCK_SR595);
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(bit));
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail));
TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit)); /* 松开了 */
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(other)); /* 别路的键一动没动 */
/* 顺序也要对:先松键,再断电。 */
TEST_ASSERT_TRUE(sim_gpio_write_seq(SR595_PIN_RCLK) <
sim_gpio_write_seq(rail_cfg(rail)->efuse_en));
TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(rail_cfg(rail)->efuse_en));
}
/* ==== 验收主项二:按键序列不重入 ========================================= */
/* 短按 100 ms(SW-5 的第一档)。位号一律查 `sr595_key_bit()`:CH5 的电源键
在 U22.QH(bit15),按通道号算怎么算都算不出来。 */
static void test_key_tap_presses_the_table_bit_and_releases_it(void)
{
const int rail = 5;
const int bit = sr595_key_bit(rail, SR595_KEY_PWR);
const uint32_t latches0 = sim_sr595_latches();
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_TAP));
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(bit)); /* 栅极真的被拉起来了 */
TEST_ASSERT_EQUAL_UINT32(latches0 + 1, sim_sr595_latches());
TEST_ASSERT_EQUAL_UINT32((uint32_t)1u << bit, keys_on_chain());
run_ms(ACT_KEY_MS_TAP - 1);
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(bit));
TEST_ASSERT_TRUE(t_actuator_busy(rail));
run_ms(1);
TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit));
TEST_ASSERT_EQUAL_UINT32(latches0 + 2, sim_sr595_latches()); /* 按下一帧、松开一帧 */
TEST_ASSERT_EQUAL_UINT32(0, keys_on_chain());
TEST_ASSERT_FALSE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail));
TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_SR595));
}
/* ⭐ §10.6 的硬约束:组合键必须**同一帧**写进去。
分两次写的实现在这里 `latches` 会 +2,而且中间那一帧只按下了一个键
——「先单按了一下 VOL−」在有的机型上就是调音量。 */
static void test_combo_keys_land_in_exactly_one_frame(void)
{
const int rail = 2;
const uint32_t want = ((uint32_t)1u << sr595_key_bit(rail, SR595_KEY_VDN)) |
((uint32_t)1u << sr595_key_bit(rail, SR595_KEY_PWR));
const uint32_t latches0 = sim_sr595_latches();
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_FASTBOOT, ACT_KEY_MS_ON));
TEST_ASSERT_EQUAL_UINT32(latches0 + 1, sim_sr595_latches());
TEST_ASSERT_EQUAL_UINT32(want, keys_on_chain());
run_ms(ACT_KEY_MS_ON);
TEST_ASSERT_EQUAL_UINT32(0, keys_on_chain());
TEST_ASSERT_EQUAL_UINT32(latches0 + 2, sim_sr595_latches());
}
/* Recovery 是 VOL+ + PWR,和 Fastboot 差在哪一个音量键上 —— 两者不能相等。 */
static void test_recovery_and_fastboot_are_different_key_sets(void)
{
const int rail = 3;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_RECOVERY, ACT_KEY_MS_ON));
const uint32_t recovery = keys_on_chain();
run_ms(ACT_KEY_MS_ON);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_FASTBOOT, ACT_KEY_MS_ON));
const uint32_t fastboot = keys_on_chain();
run_ms(ACT_KEY_MS_ON);
TEST_ASSERT_NOT_EQUAL(recovery, fastboot);
TEST_ASSERT_EQUAL_UINT32((uint32_t)1u << sr595_key_bit(rail, SR595_KEY_PWR),
recovery & fastboot); /* 共有的只有电源键 */
}
/* ⭐ 不重入:一路上正在跑序列时,三个请求全部被拒,而且**一个字节都不发**。 */
static void test_a_running_sequence_rejects_every_new_request(void)
{
const int rail = 1;
const int en = rail_cfg(rail)->efuse_en;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_FORCE));
const uint32_t latches1 = sim_sr595_latches();
const uint32_t shadow1 = sr595_shadow();
const uint32_t en_writes1 = sim_gpio_writes(en);
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, t_actuator_key(rail, ACT_KEYS_VUP, ACT_KEY_MS_TAP));
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, t_actuator_hard_reset(rail));
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, t_actuator_edl(rail));
TEST_ASSERT_EQUAL_UINT32(latches1, sim_sr595_latches());
TEST_ASSERT_EQUAL_UINT32(shadow1, sr595_shadow());
TEST_ASSERT_EQUAL_UINT32(en_writes1, sim_gpio_writes(en)); /* 被拒的硬重启没断电 */
/* 被拒的请求也不许改掉正在跑的那一个:仍然按 10 s 松开,不是 100 ms。 */
run_ms(ACT_KEY_MS_TAP + 1);
TEST_ASSERT_TRUE(t_actuator_busy(rail));
run_ms(ACT_KEY_MS_FORCE - ACT_KEY_MS_TAP - 1);
TEST_ASSERT_FALSE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_UINT32(1, t_actuator_done(rail)); /* 只跑过一个序列 */
}
/* 互斥是**每路一个**:别的路照样能同时跑。这也是影子寄存器存在的理由 ——
§10.6 那句「另一台手机的按键被顺手清掉」说的就是这里。 */
static void test_two_rails_run_at_the_same_time_without_clobbering(void)
{
const int b1 = sr595_key_bit(1, SR595_KEY_PWR);
const int b4 = sr595_key_bit(4, SR595_KEY_VUP);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON)); /* 2 s */
run_ms(50);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(4, ACT_KEYS_VUP, ACT_KEY_MS_TAP)); /* 100 ms */
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(b1));
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(b4));
run_ms(ACT_KEY_MS_TAP); /* CH4 到点 */
TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(b4));
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(b1)); /* ⭐ CH1 没被顺手清掉 */
TEST_ASSERT_TRUE(t_actuator_busy(1));
TEST_ASSERT_FALSE(t_actuator_busy(4));
run_ms(ACT_KEY_MS_ON); /* CH1 到点 */
TEST_ASSERT_EQUAL_UINT32(0, keys_on_chain());
TEST_ASSERT_FALSE(t_actuator_busy(1));
}
/* ==== 验收主项三:EDL 顺序固定 =========================================== */
/* SW-6:`EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0`。**先短接再插 USB**。
只看终值是分不出顺序的(终值都是「EDL 松开、VBUS 开着」),所以这里比的是
全局写序号。 */
static void test_edl_order_is_short_then_vbus_then_release(void)
{
const int rail = 3;
const int edl = sr595_edl_bit(rail);
const int vb = rail_cfg(rail)->vbus_en;
TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(vb));
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_edl(rail));
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(edl));
TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(vb)); /* USB 还没插 */
const uint32_t seq_short = sim_gpio_write_seq(SR595_PIN_RCLK);
run_ms(ACT_EDL_SHORT_MS - 1);
TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(vb));
run_ms(1);
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(vb));
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(edl)); /* 还短接着 */
const uint32_t seq_vbus = sim_gpio_write_seq(vb);
run_ms(ACT_EDL_ENUM_MS - 1);
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(edl));
run_ms(1);
TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(edl)); /* 枚举完才松短接 */
const uint32_t seq_release = sim_gpio_write_seq(SR595_PIN_RCLK);
TEST_ASSERT_TRUE(seq_short < seq_vbus);
TEST_ASSERT_TRUE(seq_vbus < seq_release);
/* VBUS **留着** —— 手机现在正挂在 9008 上,把它断了等于白跑一趟。 */
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(vb));
TEST_ASSERT_FALSE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail));
}
/* EDL 用的是 `sr595_edl_bit()` 那张表,一路都不能串。CH1 在 U33.QA(16),
CH2~5 在 U21 上而且不是顺序的 —— 算出来的位号必错。 */
static void test_edl_uses_the_lookup_table_not_arithmetic(void)
{
for (int rail = 1; rail <= N; rail++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_edl(rail));
TEST_ASSERT_EQUAL_UINT32((uint32_t)1u << sr595_edl_bit(rail),
sim_sr595_outputs() & ~sr595_all_keys_mask() &
~((uint32_t)1u << SR595_BIT_HUB_RST));
run_ms(ACT_EDL_SHORT_MS + ACT_EDL_ENUM_MS);
TEST_ASSERT_FALSE(t_actuator_busy(rail));
}
}
/* ==== 收尾:按下去的东西一定要松开 ======================================= */
/* 595 那把锁被别人占着时,松键落不下去。**不许放弃** ——
放弃 = 电源键留在按下状态,而遥测上五路电压电流一切正常。 */
static void test_cleanup_retries_until_the_key_is_really_released(void)
{
const int rail = 1;
const int bit = sr595_key_bit(rail, SR595_KEY_PWR);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_TAP));
/* 扮演第二个任务,把 595 那把锁占住(A15 的初始化、A10 的收尾都会拿它)。 */
sim_lock_set_task(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_SR595, 10));
sim_lock_set_task(0);
run_ms(ACT_KEY_MS_TAP);
TEST_ASSERT_EQUAL_INT(ACT_ST_CLEANUP, t_actuator_state(rail));
TEST_ASSERT_TRUE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_INT(1, sim_sr595_pin(bit)); /* 键还按着 */
for (int i = 0; i < 5; i++) {
(void)t_actuator_step();
}
TEST_ASSERT_TRUE(t_actuator_cleanup_retries(rail) > 0);
/* 收尾期间这一路照样算 busy,新请求进不来。 */
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_TAP));
sim_lock_set_task(1);
hal_lock_give(HAL_LOCK_SR595);
sim_lock_set_task(0);
(void)t_actuator_step();
TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit)); /* 锁一放开就落地 */
TEST_ASSERT_FALSE(t_actuator_busy(rail));
}
/* 按下那一步就失败(595 那把锁被别人占着)。请求返回错误码,但**不能就这么算了** ——
影子有可能已经被改成「按下」了(拿到锁而整帧没发出去的那种失败),直接回 IDLE
等于把一个脏的「按下」留在那儿:谁下一次 flush 谁就替我们把键按下去,
而没有任何人会来松开它。所以失败的按下照样要走收尾。 */
static void test_a_failed_press_still_goes_through_cleanup(void)
{
const int rail = 4;
const int bit = sr595_key_bit(rail, SR595_KEY_PWR);
sim_lock_set_task(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_SR595, 10));
sim_lock_set_task(0);
TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT,
t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_TAP));
TEST_ASSERT_EQUAL_INT(ACT_ST_CLEANUP, t_actuator_state(rail));
TEST_ASSERT_EQUAL_INT(ACT_RES_IO, t_actuator_last_result(rail));
TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit)); /* 这一次键根本没按下去 */
sim_lock_set_task(1);
hal_lock_give(HAL_LOCK_SR595);
sim_lock_set_task(0);
(void)t_actuator_step();
TEST_ASSERT_FALSE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit));
}
/* `t_actuator_abort()`:立刻收尾,键当场松开,但**不动两根 EN 线** ——
把电再关一次或再开一次都是自作主张(那是 A10 的判据和上位机命令的事)。 */
static void test_abort_releases_the_key_and_leaves_the_pins_alone(void)
{
const int rail = 2;
const int bit = sr595_key_bit(rail, SR595_KEY_PWR);
const int en = rail_cfg(rail)->efuse_en;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_FORCE));
run_ms(500);
const uint32_t en_writes = sim_gpio_writes(en);
t_actuator_abort(rail);
TEST_ASSERT_EQUAL_INT(0, sim_sr595_pin(bit));
TEST_ASSERT_FALSE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, t_actuator_last_result(rail));
TEST_ASSERT_EQUAL_UINT32(en_writes, sim_gpio_writes(en));
}
/* 硬重启走到一半被打断:序列停下,但已经断掉的电**不会**被 abort 悄悄开回来。 */
static void test_abort_during_hard_reset_does_not_restore_power(void)
{
const int rail = 5;
const int en = rail_cfg(rail)->efuse_en;
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, true));
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail));
run_ms(1000);
t_actuator_abort(rail);
TEST_ASSERT_FALSE(t_actuator_busy(rail));
TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, t_actuator_last_result(rail));
TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(en)); /* 还是关着的,等上层拿主意 */
}
/* ==== 参数与常量 ========================================================== */
static void test_bad_arguments_are_rejected(void)
{
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_actuator_hard_reset(0));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_actuator_hard_reset(N + 1));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_actuator_edl(0));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_actuator_key(0, ACT_KEYS_PWR, 100));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_actuator_key(1, 0, 100)); /* 一个键都没选 */
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_actuator_key(1, 1u << 5, 100)); /* 不存在的键 */
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_actuator_key(1, ACT_KEYS_PWR, 0)); /* 按 0 ms */
/* 上限拦的是调用方的手误:传一个 10 分钟进来等于把电源键按死。 */
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG,
t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MAX_MS + 1));
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MAX_MS));
/* 被拒的请求一律不留痕迹。 */
TEST_ASSERT_FALSE(t_actuator_busy(2));
TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_SR595));
}
/* 时间常量与文档逐格对账。改了这里就是改了需求,得先去改文档。 */
static void test_timings_match_the_documents(void)
{
TEST_ASSERT_EQUAL_UINT32(17, ACT_HR_RAMP_MS); /* tON 5.1 + 斜坡 11.5,§7.4 */
TEST_ASSERT_EQUAL_UINT32(3000, ACT_HR_OFF_MIN_MS); /* §7.4 的 3~5 s 窗口 */
TEST_ASSERT_EQUAL_UINT32(5000, ACT_HR_OFF_MAX_MS);
TEST_ASSERT_TRUE(ACT_HR_OFF_MS >= ACT_HR_OFF_MIN_MS && ACT_HR_OFF_MS <= ACT_HR_OFF_MAX_MS);
TEST_ASSERT_EQUAL_UINT32(100, ACT_KEY_MS_TAP); /* SW-5 三档 */
TEST_ASSERT_EQUAL_UINT32(2000, ACT_KEY_MS_ON);
TEST_ASSERT_EQUAL_UINT32(10000, ACT_KEY_MS_FORCE);
TEST_ASSERT_TRUE(ACT_KEY_MS_FORCE <= ACT_KEY_MAX_MS); /* 最长那一档不能被自己的上限拦掉 */
/* 逻辑键位就是 sr595 的那三个下标,组合键是它们的并。 */
TEST_ASSERT_EQUAL_UINT32(ACT_KEYS_VDN | ACT_KEYS_PWR, (uint32_t)ACT_KEYS_FASTBOOT);
TEST_ASSERT_EQUAL_UINT32(ACT_KEYS_VUP | ACT_KEYS_PWR, (uint32_t)ACT_KEYS_RECOVERY);
}
/* 加锁纪律那条检查本身不能是句空话:不拿锁直接写一帧,违规必须 +1。
(没有这条反向测试,上面那些「violations == 0」在检查失灵时会永远绿。) */
static void test_the_lock_discipline_check_actually_fires(void)
{
TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_SR595));
(void)sr595_write((uint32_t)1u << sr595_key_bit(1, SR595_KEY_PWR), 0xFFFFFFFFu &
SR595_MASK_VALID);
TEST_ASSERT_TRUE(sim_lock_violations(HAL_LOCK_SR595) > 0);
}
/* 空闲时 `t_actuator_step()` 什么也不做,也不该去动锁或引脚。 */
static void test_idle_step_is_free(void)
{
const uint32_t takes = sim_lock_takes(HAL_LOCK_SR595);
const uint32_t latches = sim_sr595_latches();
for (int i = 0; i < 100; i++) {
TEST_ASSERT_EQUAL_UINT32(ACT_IDLE_WAIT_MS, t_actuator_step());
sim_time_advance_ms(ACT_IDLE_WAIT_MS);
}
TEST_ASSERT_EQUAL_UINT32(takes, sim_lock_takes(HAL_LOCK_SR595));
TEST_ASSERT_EQUAL_UINT32(latches, sim_sr595_latches());
}
static void test_format_prints_every_rail(void)
{
char buf[160];
TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(2, ACT_KEYS_PWR, ACT_KEY_MS_ON));
const int n = t_actuator_format(buf, sizeof(buf));
TEST_ASSERT_TRUE(n > 0 && n < (int)sizeof(buf));
TEST_ASSERT_NOT_NULL(strstr(buf, "ch2=key-hold"));
TEST_ASSERT_NOT_NULL(strstr(buf, "ch5=idle"));
printf("%s\n", buf);
/* 缓冲太小也不许越界。 */
char small[12];
memset(small, 0x7F, sizeof(small));
const int m = t_actuator_format(small, sizeof(small));
TEST_ASSERT_TRUE(m < (int)sizeof(small));
TEST_ASSERT_EQUAL_CHAR('\0', small[sizeof(small) - 1]);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_hard_reset_walks_the_full_power_up_sequence);
RUN_TEST(test_hard_reset_touches_only_its_own_two_pins);
RUN_TEST(test_no_pg_leaves_the_rail_powered);
RUN_TEST(test_pg_stuck_low_also_leaves_the_rail_powered);
RUN_TEST(test_late_pg_is_still_accepted);
RUN_TEST(test_the_task_loop_only_sleeps_what_step_asks_for);
RUN_TEST(test_hard_reset_releases_a_stuck_key_before_cutting_power);
RUN_TEST(test_key_tap_presses_the_table_bit_and_releases_it);
RUN_TEST(test_combo_keys_land_in_exactly_one_frame);
RUN_TEST(test_recovery_and_fastboot_are_different_key_sets);
RUN_TEST(test_a_running_sequence_rejects_every_new_request);
RUN_TEST(test_two_rails_run_at_the_same_time_without_clobbering);
RUN_TEST(test_edl_order_is_short_then_vbus_then_release);
RUN_TEST(test_edl_uses_the_lookup_table_not_arithmetic);
RUN_TEST(test_cleanup_retries_until_the_key_is_really_released);
RUN_TEST(test_a_failed_press_still_goes_through_cleanup);
RUN_TEST(test_abort_releases_the_key_and_leaves_the_pins_alone);
RUN_TEST(test_abort_during_hard_reset_does_not_restore_power);
RUN_TEST(test_bad_arguments_are_rejected);
RUN_TEST(test_timings_match_the_documents);
RUN_TEST(test_the_lock_discipline_check_actually_fires);
RUN_TEST(test_idle_step_is_free);
RUN_TEST(test_format_prints_every_rail);
return UNITY_END();
}
+30
查看文件
@@ -577,6 +577,36 @@ ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U
> - **发送失败要留着脏**(`sr595_flush()` 失败不清 dirty,下次重发整帧)。
> 丢了脏标志 = 链上和影子永久对不上,而后面每一次 flush 都会「成功」。
> **2026-09-07 A9 落地**:`firmware/main/t_actuator.{h,c}`(+ `test/test_t_actuator.c` 23 例)。
> 三个序列成了一张**每路一份**的状态机:`HR_OFF → HR_RAMP → HR_PG` /
> `KEY_HOLD` / `EDL_SHORT → EDL_ENUM`,全部以 `CLEANUP` 收尾。时间常量都带出处
> (断电 4000 ms 落在 §7.4 的 3~5 s 窗口里、斜坡 17 ms、等 PG 上限 500 ms、
> 按键 100/2000/10000 ms、EDL 短接 10 ms + 等枚举 3000 ms)。
> 这一层的六条实现约定:
>
> 1. **一个函数一步,不许 `while(1)`。** `t_actuator_step()` 只看时钟、推状态、
> 返回「下次最晚多久再来」;任务体在 `app_main.c` 里还是三行。写进死循环的
> 时序在宿主机上一行也跑不到 —— 而这一包的全部内容就是那些时间常量与它们的先后。
> 2. **按下去的东西一定要松开,`CLEANUP` 不放弃。** 正常结束、超时、出错、被
> `t_actuator_abort()` 打断走的是**同一条**收尾路径;写不进 595 就一直重试,
> 这一路一直算 busy。放弃的现象是电源键留在按下状态、手机再也起不来,
> 而遥测上五路电压电流全都正常。连「按下那一步就失败」也要走收尾 ——
> 否则影子里留着一个脏的「按下」,谁下一次 `flush` 谁就替你把键按下去。
> 3. **等不到 PG 不许再断电。** `ACT_RES_NO_PG` 的语义是「电已经开着,只是没能确认」。
> PCA9555 失联时把「看不见」当成「坏了」去断电,等于一片 I/O 扩展器失联
> 就让五台手机一起停机 —— §10.10 明写着这片不应答**不关输出**。
> 4. **PG 是喂进来的,执行器不碰 I²C。** `PGOOD` 已经被 `t_safety` 每 20 ms 读一遍,
> 再读一次等于凭空多一份总线占用和一份两边不一致的可能。回调(`act_pg_fn`)
> 同时也是「两把锁永远不会嵌套」的物理保证:本文件只碰 GPIO 与 595。
> 5. **不可重入 = 直接拒**(`HAL_ERR_STATE`),要抢占得先显式 `t_actuator_abort()`。
> 被拒的请求**一个字节都不发**,也不改正在跑的那个序列的时长。
> 6. **组合键一次 `chain_write()` 发完**(同一个 RCLK 沿)。分两次写的实现在
> `sim_sr595_latches()` 上会 +2,而且中间那一帧只按下了一个键。
>
> 验收之外还钉了两条容易漏的:硬重启**开始前**先松掉本路三键(否则手机开进
> Recovery/Fastboot),以及 EDL 结束后 **VBUS 留着不动**(手机正挂在 9008 上)。
> 21 条注错逐个跑过,全部变红。
### 10.7 事件记录与存储
事件是 SW-3 的交付物,也是排查排线接触不良的唯一线索,所以**记录路径不能依赖上位机在线**。