diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index d2a8276..74e18ed 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -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,再改这里。 diff --git a/firmware/host/CMakeLists.txt b/firmware/host/CMakeLists.txt index 015a371..ec35747 100644 --- a/firmware/host/CMakeLists.txt +++ b/firmware/host/CMakeLists.txt @@ -1,80 +1,81 @@ -# PhoneRail 宿主机构建 —— 不依赖 ESP-IDF,用来跑单元测试(CTest)。 -# -# bash host/test.sh 配置 + 编译 + ctest -# -# 与 IDF 目标共享的源码只有 ../main 里的与硬件无关部分。 -# 新增一个既要上板又要被测的 .c,必须同时加进: -# ../main/CMakeLists.txt 的 SRCS 和 下面的 PHONERAIL_CORE_SRCS -# HAL 里新增一路实现则要同时加进: -# ../components/phonerail_hal/CMakeLists.txt 的 SRCS(esp 那份) -# 和下面的 PHONERAIL_HAL_SIM_SRCS(sim 那份) -# 新增一个器件驱动则是: -# ../components/drv/CMakeLists.txt 的 SRCS 和 下面的 PHONERAIL_DRV_SRCS -cmake_minimum_required(VERSION 3.16) -project(phonerail_host C) - -set(CMAKE_C_STANDARD 11) -set(CMAKE_C_STANDARD_REQUIRED ON) -add_compile_options(-Wall -Wextra -Werror) - -set(FW_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..) - -# ---- 被测代码 ------------------------------------------------------------- -set(PHONERAIL_CORE_SRCS - ${FW_ROOT}/main/version.c - ${FW_ROOT}/main/rail.c - ${FW_ROOT}/main/rail_cfg.c - ${FW_ROOT}/main/sched.c - ${FW_ROOT}/main/tasks.c - ${FW_ROOT}/main/t_safety.c -) - -# HAL:接口在 include/,宿主机链 sim/ 的实现(esp/ 那份只有 IDF 目标编)。 -set(PHONERAIL_HAL_DIR ${FW_ROOT}/components/phonerail_hal) -set(PHONERAIL_HAL_SIM_SRCS - ${PHONERAIL_HAL_DIR}/hal_err.c - ${PHONERAIL_HAL_DIR}/hal_i2c_acct.c - ${PHONERAIL_HAL_DIR}/sim/hal_lock_sim.c - ${PHONERAIL_HAL_DIR}/sim/sim_hal.c - ${PHONERAIL_HAL_DIR}/sim/hal_i2c_sim.c - ${PHONERAIL_HAL_DIR}/sim/hal_gpio_sim.c - ${PHONERAIL_HAL_DIR}/sim/sim_sr595.c - ${PHONERAIL_HAL_DIR}/sim/sim_pca9555.c - ${PHONERAIL_HAL_DIR}/sim/hal_pwm_sim.c - ${PHONERAIL_HAL_DIR}/sim/hal_time_sim.c -) - -# 器件驱动:与平台无关(只经 hal_i2c_*),两个目标编的是同一份源码。 -set(PHONERAIL_DRV_DIR ${FW_ROOT}/components/drv) -set(PHONERAIL_DRV_SRCS - ${PHONERAIL_DRV_DIR}/ina226.c - ${PHONERAIL_DRV_DIR}/dac63204.c - ${PHONERAIL_DRV_DIR}/sr595.c - ${PHONERAIL_DRV_DIR}/pca9555.c -) - -add_library(phonerail_core STATIC - ${PHONERAIL_CORE_SRCS} ${PHONERAIL_HAL_SIM_SRCS} ${PHONERAIL_DRV_SRCS}) -target_include_directories(phonerail_core PUBLIC - ${FW_ROOT}/main - ${PHONERAIL_HAL_DIR}/include - ${PHONERAIL_HAL_DIR}/sim - ${PHONERAIL_DRV_DIR}/include) - -# ---- 测试框架 Unity(从 ESP-IDF v5.4 原样复制到 third_party/,与上板测试同一套) -add_library(unity STATIC ${FW_ROOT}/third_party/unity/unity.c) -target_include_directories(unity PUBLIC ${FW_ROOT}/third_party/unity) -target_compile_definitions(unity PUBLIC UNITY_OUTPUT_COLOR) -# 第三方源码不吃本工程的 -Werror -target_compile_options(unity PRIVATE -w) - -# ---- 测试用例:test/test_*.c 一个文件一个可执行 + 一条 ctest --------------- -enable_testing() - -file(GLOB PHONERAIL_TESTS ${FW_ROOT}/test/test_*.c) -foreach(test_src ${PHONERAIL_TESTS}) - get_filename_component(test_name ${test_src} NAME_WE) - add_executable(${test_name} ${test_src}) - target_link_libraries(${test_name} PRIVATE phonerail_core unity) - add_test(NAME ${test_name} COMMAND ${test_name}) -endforeach() +# PhoneRail 宿主机构建 —— 不依赖 ESP-IDF,用来跑单元测试(CTest)。 +# +# bash host/test.sh 配置 + 编译 + ctest +# +# 与 IDF 目标共享的源码只有 ../main 里的与硬件无关部分。 +# 新增一个既要上板又要被测的 .c,必须同时加进: +# ../main/CMakeLists.txt 的 SRCS 和 下面的 PHONERAIL_CORE_SRCS +# HAL 里新增一路实现则要同时加进: +# ../components/phonerail_hal/CMakeLists.txt 的 SRCS(esp 那份) +# 和下面的 PHONERAIL_HAL_SIM_SRCS(sim 那份) +# 新增一个器件驱动则是: +# ../components/drv/CMakeLists.txt 的 SRCS 和 下面的 PHONERAIL_DRV_SRCS +cmake_minimum_required(VERSION 3.16) +project(phonerail_host C) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +add_compile_options(-Wall -Wextra -Werror) + +set(FW_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..) + +# ---- 被测代码 ------------------------------------------------------------- +set(PHONERAIL_CORE_SRCS + ${FW_ROOT}/main/version.c + ${FW_ROOT}/main/rail.c + ${FW_ROOT}/main/rail_cfg.c + ${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 目标编)。 +set(PHONERAIL_HAL_DIR ${FW_ROOT}/components/phonerail_hal) +set(PHONERAIL_HAL_SIM_SRCS + ${PHONERAIL_HAL_DIR}/hal_err.c + ${PHONERAIL_HAL_DIR}/hal_i2c_acct.c + ${PHONERAIL_HAL_DIR}/sim/hal_lock_sim.c + ${PHONERAIL_HAL_DIR}/sim/sim_hal.c + ${PHONERAIL_HAL_DIR}/sim/hal_i2c_sim.c + ${PHONERAIL_HAL_DIR}/sim/hal_gpio_sim.c + ${PHONERAIL_HAL_DIR}/sim/sim_sr595.c + ${PHONERAIL_HAL_DIR}/sim/sim_pca9555.c + ${PHONERAIL_HAL_DIR}/sim/hal_pwm_sim.c + ${PHONERAIL_HAL_DIR}/sim/hal_time_sim.c +) + +# 器件驱动:与平台无关(只经 hal_i2c_*),两个目标编的是同一份源码。 +set(PHONERAIL_DRV_DIR ${FW_ROOT}/components/drv) +set(PHONERAIL_DRV_SRCS + ${PHONERAIL_DRV_DIR}/ina226.c + ${PHONERAIL_DRV_DIR}/dac63204.c + ${PHONERAIL_DRV_DIR}/sr595.c + ${PHONERAIL_DRV_DIR}/pca9555.c +) + +add_library(phonerail_core STATIC + ${PHONERAIL_CORE_SRCS} ${PHONERAIL_HAL_SIM_SRCS} ${PHONERAIL_DRV_SRCS}) +target_include_directories(phonerail_core PUBLIC + ${FW_ROOT}/main + ${PHONERAIL_HAL_DIR}/include + ${PHONERAIL_HAL_DIR}/sim + ${PHONERAIL_DRV_DIR}/include) + +# ---- 测试框架 Unity(从 ESP-IDF v5.4 原样复制到 third_party/,与上板测试同一套) +add_library(unity STATIC ${FW_ROOT}/third_party/unity/unity.c) +target_include_directories(unity PUBLIC ${FW_ROOT}/third_party/unity) +target_compile_definitions(unity PUBLIC UNITY_OUTPUT_COLOR) +# 第三方源码不吃本工程的 -Werror +target_compile_options(unity PRIVATE -w) + +# ---- 测试用例:test/test_*.c 一个文件一个可执行 + 一条 ctest --------------- +enable_testing() + +file(GLOB PHONERAIL_TESTS ${FW_ROOT}/test/test_*.c) +foreach(test_src ${PHONERAIL_TESTS}) + get_filename_component(test_name ${test_src} NAME_WE) + add_executable(${test_name} ${test_src}) + target_link_libraries(${test_name} PRIVATE phonerail_core unity) + add_test(NAME ${test_name} COMMAND ${test_name}) +endforeach() diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 9a69712..12ce980 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,13 +1,14 @@ -# ESP-IDF 侧的 main 组件。 -# app_main.c 是 ESP 专用入口;其余 .c 必须同时在宿主机上编得过 -# (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。 -idf_component_register( - SRCS "app_main.c" - "version.c" - "rail.c" - "rail_cfg.c" - "sched.c" - "tasks.c" - "t_safety.c" - INCLUDE_DIRS "." - REQUIRES phonerail_hal drv) +# ESP-IDF 侧的 main 组件。 +# app_main.c 是 ESP 专用入口;其余 .c 必须同时在宿主机上编得过 +# (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。 +idf_component_register( + SRCS "app_main.c" + "version.c" + "rail.c" + "rail_cfg.c" + "sched.c" + "tasks.c" + "t_safety.c" + "t_actuator.c" + INCLUDE_DIRS "." + REQUIRES phonerail_hal drv) diff --git a/firmware/main/app_main.c b/firmware/main/app_main.c index 9dc9e92..933003d 100644 --- a/firmware/main/app_main.c +++ b/firmware/main/app_main.c @@ -1,174 +1,204 @@ -/* ESP32-S3 入口。只在 ESP-IDF 目标里编译,宿主机构建不包含本文件。 */ -#include - -#include "board_pins.h" -#include "esp_log.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#include "hal_i2c.h" -#include "hal_lock.h" -#include "hal_time.h" -#include "phonerail.h" -#include "rail_cfg.h" -#include "sched.h" -#include "t_safety.h" -#include "tasks.h" - -static const char *TAG = "phonerail"; - -/* 启动自检(需求 §10.4):五路的地址 / 位号 / 引脚无重复、无越界, - 而且那张五路表和四个驱动自己的表没有分家。 - - ⚠️ **不过就 abort,不降级。** 这里查的全是编译进固件的常量表, - 查不过 = 固件本身写错了,不是硬件坏了 —— §10.10 的降级矩阵管的是后者。 - 带着一张错表跑起来的样子是「命令发给了另一路」,五台手机全在跑、 - 一个错误码都不报,谁也不会去怀疑它。 */ -static void selftest_or_die(void) -{ - /* 五个任务那张表(§10.3):周期与优先级的相对顺序。同样是编译进去的常量, - 同样不过就 abort —— 优先级写反了板子照跑,只是巡检会在总线忙时被挤后。 */ - if (!task_table_ok()) { - ESP_LOGE(TAG, "任务表自检失败:周期或优先级与 §10.3 对不上"); - abort(); - } - - const uint32_t bad = rail_selftest(); - if (bad == 0) { - ESP_LOGI(TAG, "五路配置表自检通过"); - return; - } - for (uint32_t bit = 1; bit != 0; bit <<= 1) { - if ((bad & bit) != 0) { - ESP_LOGE(TAG, "自检失败:%s (0x%02x)", rail_selftest_str(bit), (unsigned)bit); - } - } - abort(); -} - -/* ---- 五个任务(需求 §10.3) --------------------------------------------- - 任务体只在这个文件里,而且都短得能一眼看完 —— 真正的活在 main/ 的其它 .c 里, - 那些在宿主机上编得过、测得到。**任务体里不许出现判据与时序**:写进死循环里的 - 东西,宿主机一行也跑不到。 */ - -static void task_safety(void *arg) -{ - (void)arg; - static safety_snap_t snap; /* static:不占任务栈,也方便挂调试器看 */ - t_safety_init(&snap); - - 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 失联 ≠ 断电 */ - - /* 每 5 s 报一次总线占用。§10.3 手算是「约 1.5 ms / 约 8 %」, - 首板上电时要拿逻辑分析仪复核这个数(§10.3 末尾那句)。 */ - if ((snap.cycles % 250u) == 0u) { - t_safety_format(&snap, 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); - } - sched_periodic_wait(&tick); - } -} - -/* 下面四个是骨架:周期与优先级已经按 §10.3 定好,任务体等各自的包。 - 现在就把它们建起来,是为了让 A9~A16 只需要往里填,不用再动启动流程。 */ - -static void task_thermal(void *arg) -{ - (void)arg; - sched_periodic_t tick; - sched_periodic_init(&tick, task_desc(TASK_THERMAL)->period_ms); - for (;;) { - /* A16:两路 NTC + 载板 LM75 → 风扇 PWM 闭环(TR-6 的 TACH 采样窗口)。 */ - sched_periodic_wait(&tick); - } -} - -static void task_actuator(void *arg) -{ - (void)arg; - for (;;) { - /* A9:三个状态机 + 互斥 + 超时兜底。队列在那一包里建。 */ - vTaskDelay(pdMS_TO_TICKS(100)); - } -} - -static void task_link(void *arg) -{ - (void)arg; - for (;;) { - /* A12~A14:命令处理器 + 两条传输链路。 */ - vTaskDelay(pdMS_TO_TICKS(100)); - } -} - -static void task_log(void *arg) -{ - (void)arg; - for (;;) { - /* A11:事件环形缓冲落盘。 */ - vTaskDelay(pdMS_TO_TICKS(1000)); - } -} - -static void spawn(task_id_t id, TaskFunction_t fn) -{ - const task_desc_t *d = task_desc(id); - /* ⚠️ ESP-IDF 的 xTaskCreate 收的 stack_depth 是**字节**(原版 FreeRTOS 是字)。 */ - if (xTaskCreate(fn, d->name, d->stack_bytes, NULL, d->prio, NULL) != pdPASS) { - ESP_LOGE(TAG, "建任务失败:%s", d->name); - abort(); - } -} - -void app_main(void) -{ - ESP_LOGI(TAG, "PhoneRail %s boot, %d rails, t=%u ms", phonerail_version(), - PHONERAIL_RAIL_COUNT, (unsigned)hal_time_now_ms()); - - selftest_or_die(); - - /* 十根 EN 线配成输出并全部拉低。**先于一切外设**:DAC / eFuse 一上电就有 - 输出,这两根线这时必须已经确定是低的。 */ - hal_err_t err = rail_en_init(); - if (err != HAL_OK) { - ESP_LOGE(TAG, "EN 引脚初始化失败:%s", hal_err_str(err)); - abort(); - } - ESP_LOGI(TAG, "五路输出与 VBUS 全部关闭"); - - /* 两把锁必须早于任何一个用总线的任务。 */ - (void)hal_lock_init(); - - const hal_i2c_cfg_t i2c = { - .sda_gpio = BOARD_PIN_I2C_SDA, - .scl_gpio = BOARD_PIN_I2C_SCL, - .freq_hz = 400000, - }; - err = hal_i2c_init(&i2c); - if (err != HAL_OK) { - ESP_LOGE(TAG, "I²C 初始化失败:%s", hal_err_str(err)); - abort(); - } - - /* ⚠️ 器件初始化(INA226 的 Config/Calibration、DAC、PCA9555 方向、595 安全值) - 与**降级矩阵**是 A15 的启动顺序那一包。在那之前 t_safety 读回来的电流恒为 0 - (Calibration 还没写),这是预期的,不是故障。 */ - - spawn(TASK_SAFETY, task_safety); - spawn(TASK_THERMAL, task_thermal); - spawn(TASK_ACTUATOR, task_actuator); - spawn(TASK_LINK, task_link); - spawn(TASK_LOG, task_log); - - ESP_LOGI(TAG, "五个任务已启动(t_safety %u ms / t_thermal %u ms)," - "I²C 预算 %u us 每周期", - (unsigned)task_desc(TASK_SAFETY)->period_ms, - (unsigned)task_desc(TASK_THERMAL)->period_ms, - (unsigned)t_safety_budget_us()); -} +/* ESP32-S3 入口。只在 ESP-IDF 目标里编译,宿主机构建不包含本文件。 */ +#include + +#include "board_pins.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "hal_i2c.h" +#include "hal_lock.h" +#include "hal_time.h" +#include "phonerail.h" +#include "rail_cfg.h" +#include "sched.h" +#include "t_actuator.h" +#include "t_safety.h" +#include "tasks.h" + +static const char *TAG = "phonerail"; + +/* 启动自检(需求 §10.4):五路的地址 / 位号 / 引脚无重复、无越界, + 而且那张五路表和四个驱动自己的表没有分家。 + + ⚠️ **不过就 abort,不降级。** 这里查的全是编译进固件的常量表, + 查不过 = 固件本身写错了,不是硬件坏了 —— §10.10 的降级矩阵管的是后者。 + 带着一张错表跑起来的样子是「命令发给了另一路」,五台手机全在跑、 + 一个错误码都不报,谁也不会去怀疑它。 */ +static void selftest_or_die(void) +{ + /* 五个任务那张表(§10.3):周期与优先级的相对顺序。同样是编译进去的常量, + 同样不过就 abort —— 优先级写反了板子照跑,只是巡检会在总线忙时被挤后。 */ + if (!task_table_ok()) { + ESP_LOGE(TAG, "任务表自检失败:周期或优先级与 §10.3 对不上"); + abort(); + } + + const uint32_t bad = rail_selftest(); + if (bad == 0) { + ESP_LOGI(TAG, "五路配置表自检通过"); + return; + } + for (uint32_t bit = 1; bit != 0; bit <<= 1) { + if ((bad & bit) != 0) { + ESP_LOGE(TAG, "自检失败:%s (0x%02x)", rail_selftest_str(bit), (unsigned)bit); + } + } + abort(); +} + +/* ---- 五个任务(需求 §10.3) --------------------------------------------- + 任务体只在这个文件里,而且都短得能一眼看完 —— 真正的活在 main/ 的其它 .c 里, + 那些在宿主机上编得过、测得到。**任务体里不许出现判据与时序**:写进死循环里的 + 东西,宿主机一行也跑不到。 */ + +/* 巡检快照。**文件作用域**(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; + 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(snapp); /* 器件读失败不在这里处理:§10.2 失联 ≠ 断电 */ + + /* 每 5 s 报一次总线占用。§10.3 手算是「约 1.5 ms / 约 8 %」, + 首板上电时要拿逻辑分析仪复核这个数(§10.3 末尾那句)。 */ + 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); + } + sched_periodic_wait(&tick); + } +} + +/* 下面四个是骨架:周期与优先级已经按 §10.3 定好,任务体等各自的包。 + 现在就把它们建起来,是为了让 A9~A16 只需要往里填,不用再动启动流程。 */ + +static void task_thermal(void *arg) +{ + (void)arg; + sched_periodic_t tick; + sched_periodic_init(&tick, task_desc(TASK_THERMAL)->period_ms); + for (;;) { + /* A16:两路 NTC + 载板 LM75 → 风扇 PWM 闭环(TR-6 的 TACH 采样窗口)。 */ + sched_periodic_wait(&tick); + } +} + +/* A9 的三个状态机。任务体还是三行:推一步 / 拿它算出来的下一个截止时刻 / 睡到那时。 + 判据与时序全在 `t_actuator_step()` 里,那份在宿主机上跑得到。 */ +static void task_actuator(void *arg) +{ + (void)arg; + for (;;) { + /* 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); + } +} + +static void task_link(void *arg) +{ + (void)arg; + for (;;) { + /* A12~A14:命令处理器 + 两条传输链路。 */ + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +static void task_log(void *arg) +{ + (void)arg; + for (;;) { + /* A11:事件环形缓冲落盘。 */ + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} + +static void spawn(task_id_t id, TaskFunction_t fn) +{ + const task_desc_t *d = task_desc(id); + /* ⚠️ ESP-IDF 的 xTaskCreate 收的 stack_depth 是**字节**(原版 FreeRTOS 是字)。 */ + if (xTaskCreate(fn, d->name, d->stack_bytes, NULL, d->prio, NULL) != pdPASS) { + ESP_LOGE(TAG, "建任务失败:%s", d->name); + abort(); + } +} + +void app_main(void) +{ + ESP_LOGI(TAG, "PhoneRail %s boot, %d rails, t=%u ms", phonerail_version(), + PHONERAIL_RAIL_COUNT, (unsigned)hal_time_now_ms()); + + selftest_or_die(); + + /* 十根 EN 线配成输出并全部拉低。**先于一切外设**:DAC / eFuse 一上电就有 + 输出,这两根线这时必须已经确定是低的。 */ + hal_err_t err = rail_en_init(); + if (err != HAL_OK) { + ESP_LOGE(TAG, "EN 引脚初始化失败:%s", hal_err_str(err)); + abort(); + } + ESP_LOGI(TAG, "五路输出与 VBUS 全部关闭"); + + /* 两把锁必须早于任何一个用总线的任务。 */ + (void)hal_lock_init(); + + const hal_i2c_cfg_t i2c = { + .sda_gpio = BOARD_PIN_I2C_SDA, + .scl_gpio = BOARD_PIN_I2C_SCL, + .freq_hz = 400000, + }; + err = hal_i2c_init(&i2c); + if (err != HAL_OK) { + ESP_LOGE(TAG, "I²C 初始化失败:%s", hal_err_str(err)); + abort(); + } + + /* ⚠️ 器件初始化(INA226 的 Config/Calibration、DAC、PCA9555 方向、595 安全值) + 与**降级矩阵**是 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); + spawn(TASK_LINK, task_link); + spawn(TASK_LOG, task_log); + + ESP_LOGI(TAG, "五个任务已启动(t_safety %u ms / t_thermal %u ms)," + "I²C 预算 %u us 每周期", + (unsigned)task_desc(TASK_SAFETY)->period_ms, + (unsigned)task_desc(TASK_THERMAL)->period_ms, + (unsigned)t_safety_budget_us()); +} diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h index 4d672c1..e4b40a9 100644 --- a/firmware/main/phonerail.h +++ b/firmware/main/phonerail.h @@ -1,23 +1,23 @@ -/* PhoneRail 固件公共头 —— 与硬件无关,宿主机与 ESP32-S3 上都编得过。 */ -#ifndef PHONERAIL_H -#define PHONERAIL_H - -/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ -#define PHONERAIL_FW_VERSION "0.1.0-a8" - -/* 通道数。五路直供,见设计文档 §2。 */ -#define PHONERAIL_RAIL_COUNT 5 - -#ifdef __cplusplus -extern "C" { -#endif - -/* 返回 PHONERAIL_FW_VERSION。存在的意义是让宿主机测试有一个真的会被链接进去的 - 符号 —— 骨架期用来证明「同一份 main/ 源码两个目标都编得过」。 */ -const char *phonerail_version(void); - -#ifdef __cplusplus -} -#endif - -#endif /* PHONERAIL_H */ +/* PhoneRail 固件公共头 —— 与硬件无关,宿主机与 ESP32-S3 上都编得过。 */ +#ifndef PHONERAIL_H +#define PHONERAIL_H + +/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ +#define PHONERAIL_FW_VERSION "0.1.0-a9" + +/* 通道数。五路直供,见设计文档 §2。 */ +#define PHONERAIL_RAIL_COUNT 5 + +#ifdef __cplusplus +extern "C" { +#endif + +/* 返回 PHONERAIL_FW_VERSION。存在的意义是让宿主机测试有一个真的会被链接进去的 + 符号 —— 骨架期用来证明「同一份 main/ 源码两个目标都编得过」。 */ +const char *phonerail_version(void); + +#ifdef __cplusplus +} +#endif + +#endif /* PHONERAIL_H */ diff --git a/firmware/main/t_actuator.c b/firmware/main/t_actuator.c new file mode 100644 index 0000000..76fbb60 --- /dev/null +++ b/firmware/main/t_actuator.c @@ -0,0 +1,418 @@ +/* 三个执行器状态机 + 互斥。契约、时间常量的出处、三条规矩都在 t_actuator.h。 */ +#include "t_actuator.h" + +#include +#include +#include +#include + +#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; +} diff --git a/firmware/main/t_actuator.h b/firmware/main/t_actuator.h new file mode 100644 index 0000000..660ddc2 --- /dev/null +++ b/firmware/main/t_actuator.h @@ -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 +#include +#include + +#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 */ diff --git a/firmware/test/test_t_actuator.c b/firmware/test/test_t_actuator.c new file mode 100644 index 0000000..f136c26 --- /dev/null +++ b/firmware/test/test_t_actuator.c @@ -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 +#include + +#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(); +} diff --git a/需求文档.md b/需求文档.md index 933f03d..a386e08 100644 --- a/需求文档.md +++ b/需求文档.md @@ -1,851 +1,881 @@ -# 五路手机直供电源板 —— 项目需求文档 - -| 项 | 值 | -|---|---| -| 文档版本 | **v1.2**(2026-09-06)—— 上位机链路定稿为**方案 C(WiFi 常态 + USB CDC 兜底)**,回填 §10.8,新增 D5a / SW-12 / IF-19 与两条验收
v1.1 —— 新增 **§10 软件架构设计**,原 §10~§14 顺延为 §11~§15 | -| 项目代号 | PhoneRail | -| 对应设计文档 | `五路直供电源板设计.md` v1.6 及其后续增补(§22 起的两板架构) | -| 状态 | 需求已冻结;硬件处于 PCB 布线阶段(见 `PROGRESS.md`) | - -> **本文档与设计文档的分工** -> 本文只回答 **「要做成什么样、凭什么算做到了」**; -> **「怎么做到」** 一律在 `五路直供电源板设计.md` 里,本文只给条目号交叉引用。 -> 两者冲突时:**电气数值以设计文档为准**(它是逐项算过并对过 datasheet 的), -> **需求的取舍与验收标准以本文为准**。 -> 设计文档 §0.1 的 13 条决策已定稿,本文把它们表述成需求,不重新讨论。 -> **唯一的例外是 §10 软件架构设计** —— 那是「怎么做」,但固件在设计文档里没有落脚点, -> 暂放本文,理由写在该节开头。 - ---- - -## 1. 背景与目标 - -### 1.1 要解决的问题 - -手机农场里成排运行的小米8(dipper)主板,用原装电池供电有三个绕不开的问题: - -1. **电池会老化、会鼓包**,几十台规模下变成持续的维护负担和安全隐患; -2. **手机卡死时没法远程恢复** —— 人必须跑到机架前拔电池 / 长按电源键; -3. **充电管理系统一直在工作**,长期 100% 电量反而触发系统的异常逻辑。 - -### 1.2 项目目标 - -做一块**电池位直供**的电源板:拆掉电芯与保护板,从主板电池座直接灌一个受控的、 -稳定在正常锂电范围内的电压;同时把「断电重启、按键模拟、进 Fastboot / 9008、 -USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU 远程执行。 - -**一句话验收目标**:五台小米8 主板挂在这套板子上连续跑 24 小时无异常重启, -任何一台卡死时都能在**不到机架前**的情况下恢复到正常运行状态。 - -### 1.3 目标用户与使用场景 - -| 用户 | 场景 | -|---|---| -| 农场运维 | 日常挂机;某台无响应时下发一次重启;查某台的电流曲线定位接触不良 | -| 刷机 / 开发 | 远程进 Fastboot 或 9008(EDL) 刷包;有线 adb 抓日志 | -| 本项目自身 | 首板调试、长稳测试、失效注入验证 | - ---- - -## 2. 范围 - -### 2.1 交付范围(In Scope) - -| # | 交付物 | -|---|---| -| D1 | **下板(电源板)**:12V 输入 → 五路 4 A 直供 + eFuse + 遥测 + ESP32-S3 控制 | -| D2 | **上板(载板)**:五块主板的机械承载 + 板载 USB Hub + VBUS 开关 + 侧键顶针 + EDL 口 | -| D3 | 两板对插的板对板接口(两条 2×20 排针,共 80 针) | -| D4 | 手机侧线束与离板电容组(电池排线改造、尾插 USB 线、EDL 引线) | -| D5 | ESP32-S3 固件(遥测、两级重启、按键 / EDL 模拟、VBUS 通断、风扇闭环、告警) | -| D5a | **双链路命令接口**(2026-09-06 用户拍板):**WiFi 为常态、USB CDC 为兜底**,两条链路共用同一套命令语义。上位机侧的调用方由农场既有系统实现(N7),本项目交付板侧协议与实现,见 §10.8 | -| D6 | 完整原理图(11 页)+ 两块 PCB + BOM + 首板调试与验收记录 | - -### 2.2 明确不做(Out of Scope) - -| # | 不做 | 理由 | -|---|---|---| -| N1 | 电池充放电管理、模拟真实电池阻抗 | 直供不需要,电芯已移除 | -| N2 | 支持 1S 以外的机型 / 非小米8 主板 | 全部机械与引脚定义都是 dipper 专用(§13) | -| N3 | 自制 USB-C 尾插转接板 | 决策 10:装原厂尾插小板,口温 NTC / CC / ESD / 天线馈点全在上面 | -| N4 | USB PD / BC1.2 协商,接快充充电器 | 决策 11:上游只接电脑 / Hub,VBUS 恒 5 V | -| N5 | 把过流保护做进 MCU 实时环路 | 那是 eFuse 的职责(SR-3) | -| N6 | 五路 Buck 错相同步 | §8.4 已否决:器件离散度远大于想制造的相位差 | -| N7 | 上位机管理软件 / Web 界面 | 本期只交付固件与命令接口,上层由农场既有系统对接 | - ---- - -## 3. 系统组成(需求视角) - -```diagram=system -12V/10A 适配器 - │ - ┌────┴─────────────── 下板(电源 + 大脑)────────────────────┐ - │ 输入保护 → 五路独立 Buck → eFuse → 分流检测 → 输出 │ - │ ESP32-S3 + DAC ×2 + INA226 ×6 + 74HC595 ×3 + PCA9555 │ - │ 侧键驱动管 ×15 · 风扇驱动 · 辅助 3V3 / 5V │ - └────┬──────────── 2×20 排针 ×2(无高速信号)─────────────────┘ - ┌────┴─────────────── 上板(载板)────────────────────────────┐ - │ 5 × 小米8 主板(NPTH 定位孔 + 尼龙立柱) │ - │ USB2517I 7 口 Hub + 上游 USB-C + VBUS 负载开关 ×5 │ - │ 侧键顶针 ×15 · EDL 模拟开关 ×5 · P+/P− 焊盘 + 电容组 ×5 │ - └──────────────────────────────────────────────────────────────┘ -``` - -两板之间保留 **13 mm 缝**,既是装配空间也是散热风道。 - ---- - -## 4. 功能需求(FR) - -| ID | 需求 | 优先级 | 设计出处 | -|---|---|---|---| -| **FR-1** | 同时为 **5 台**小米8 主板供电,各路**电气独立**(独立回流地,不在手机端共地) | 必须 | §1 / §6.4 | -| **FR-2** | 每路输出电压可**独立微调**,调节在正常锂电范围内进行,步进优于 1 mV | 必须 | §4 | -| **FR-3** | 每路可**独立硬断电并主动放电**,实现真正意义上的「拔电池」 | 必须 | §7 | -| **FR-4** | 每台手机可模拟 **3 个侧键**(PWR / VOL+ / VOL−),支持组合键 | 必须 | §13.3 | -| **FR-5** | 每台手机可远程进入 **EDL(9008)** 模式 | 必须 | §22.4 | -| **FR-6** | 每台手机的 **USB VBUS 可独立通断**,默认断开 | 必须 | §15.1 | -| **FR-7** | 对外只用**一根 USB 线**,五路下行由板载 Hub 分 | 必须 | §15.1 | -| **FR-8** | 每路可实时读取**输出电压与电流**;整板可读取**输入电压与电流** | 必须 | §8.2 | -| **FR-9** | 上报每路 eFuse 故障、电源好(PG)等状态 | 必须 | §8.2 | -| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」 | 必须 | §22.6 | -| **FR-11** | 记录过流 / 故障事件(哪一路、何时、多大电流) | 高 | §8.3 | -| **FR-12** | 模拟电量曲线,避免系统因「电量永远 100%」触发异常逻辑 | 中(可选) | §8.3 | -| **FR-13** | 板级温度监控,超阈值先降流再关路 | 高 | §8.3 | -| **FR-14** | 整板功率预算,超过适配器能力时按优先级降载 | 中 | §8.3 | -| **FR-15** | 调试口不依赖外部转串口芯片,插上 USB-C 即可下载与看日志 | 必须 | §8.1 | - -### 4.1 崩溃恢复流程(FR-3 / FR-4 的组合,本项目的核心价值) - -固件必须实现**两级重启**,且**先软后硬**: - -| 级 | 动作 | 触发条件 | 要求 | -|---|---|---|---| -| 一级(软) | 长按电源键 ≈10 s | `adb` 探活连续失败 N 次,或电流曲线卡死 | 优先使用,对闪存和文件系统友好 | -| 二级(硬) | `EFUSE_EN` ↓ → 放电 → 等 3~5 s → `EFUSE_EN` ↑ | 一级无效 | 输出必须真的掉到 <0.5 V,不能靠自然放电 | - -> **为什么二级必须有主动放电**:手机侧 2200 µF 配 ≈50 µA 关机待机电流,自然放电到安全 -> 电压要 **154 秒**,「断电 10 秒」根本不触发重启。见 §7.1。 - ---- - -## 5. 电气与性能需求(PR) - -| ID | 参数 | 要求 | 出处 | -|---|---|---|---| -| **PR-1** | 输入电压 | 12 V ±10%,成品适配器 | §1 | -| **PR-2** | 输入电流 | ≤ 8 A(含风扇 ≈8.3 A),建议配 12 V / 10 A(120 W) | §1 / §22.6 | -| **PR-3** | 输出路数 | 5,彼此独立不共地 | §1 | -| **PR-4** | 输出电压范围 | **3.52 ~ 4.01 V** 连续可调 | §4.3 | -| **PR-5** | **硬件电压上限** | 标称 **4.01 V**,最坏容差 **4.12 V**,**软件无法越过** | §4.4 | -| **PR-6** | 电压设定分辨率 | 12 位 DAC 微调,步进 ≈0.12 mV | §4.5 | -| **PR-7** | 输出电流 | 4 A / 路(峰值);手机常态 0.3 ~ 1 A | §1 | -| **PR-8** | 输出纹波 | < 20 mVpp(板端测试点) | §1 | -| **PR-9** | 开关频率 | 480 kHz,五路统一,不错相 | §1 / §8.4 | -| **PR-10** | 整板功率 | 峰值 80 W(不会发生);常态 10 ~ 25 W | §1 | -| **PR-11** | 电流测量分辨率 | ≥ 0.5 mA(5 mΩ 分流 + Kelvin 四线) | §8.2 | -| **PR-12** | 负载阶跃响应 | 0→4 A 阶跃(带 20 cm 线 + 电容组)压降 < 300 mV,**无 6 kHz 振铃** | §6.2 | -| **PR-13** | 满载温升 | 4 A 连续 30 min,电感 / IC 温升 < 40 K | §11 | -| **PR-14** | 断电放电时间 | `EFUSE_EN` ↓ 后 **200 ms 内**输出 < 0.5 V | §7.2 | -| **PR-15** | 上电斜坡 | 缓启动 0.36 V/ms,涌流 ≤ 0.8 A;`EN`↑ 到稳定 ≈17 ms | §5 | - -### 5.1 输出上限为什么定在 4.01 V(这条需求的来源) - -去掉原厂保护板后,输出上限**由 1S 锂电端子的绝对上限 4.45 V 反推**,而不是由保护板决定。 -4.01 V 这个数字不是「够用就行」,是为了**给 eFuse 的过压跳闸窗口让开 86 mV** —— -若按早期的 4.22 V,第一层的最坏输出(4.34 V)会和 eFuse 的最坏跳闸下沿(4.206 V)重叠, -坏角落下 DAC 失效会**误跳保护**。详见 §5 的容差窗口分析。 - -**因此 PR-4 / PR-5 是安全需求的一部分,不得为了「多给一点电压」而放宽。** - ---- - -## 6. 安全需求(SR)—— 本项目最高优先级 - -> **必须记录的失效模式**:直供电最致命的失效**不是固件写错电压值**,而是 -> **反馈分压的下电阻开路 → FB = 0 V → Buck 满占空比 → 输出直接跳到 12 V**。 -> **软件对此完全无能为力。** 全部安全需求都要围绕这一条来读。 - -| ID | 需求 | 出处 | -|---|---|---| -| **SR-1** | **三层保护彼此独立**:任何一层单独失效都不得导致手机损坏 | §5 | -| **SR-2** | **第一层 · 拓扑限幅**:分压网络与 DAC 量程在物理上把输出限死在 3.42 ~ 4.12 V(含最坏容差),全部落在正常锂电电压内。防固件 bug / I²C 误码 / MCU 跑飞 | §4 | -| **SR-3** | **第二层 · eFuse 独立过压断开**(每路一颗,**最后一道硬件防线**):OVP 4.36 V 标称、**断开而非钳位**、响应 2 µs;限流 4.45 A 恒流、不依赖 MCU | §5 | -| **SR-4** | eFuse 的过压响应**必须来得及**:按最坏值(Buck 高侧限流 14 A、负载 2200 µF)核算,余量 ≥ 10 倍 | §5 | -| **SR-5** | **第三层 · 软件监控**:电压偏离设定 >±100 mV 且持续 20 ms → 拉低该路 EN 并上报。**仅承担记录与告警,不承担实时保护** | §5 | -| **SR-6** | **复位安全**:ESP32 复位 / 上电期间,五路输出全关、五路 VBUS 全断、15 路按键全不按、5 路 EDL 全不触发、风扇不转。靠**上下拉电阻**保证,不靠固件 | §8.1 / §22.4 | -| **SR-7** | 放电 MOS 与 eFuse EN **硬件反相联锁**,物理上不可能同时导通 | §7.3 | -| **SR-8** | **输入保护**:保险丝 + TVS + P-MOS 反接保护 | §2 | -| **SR-9** | **12 V 母线泄放**:母线 >13.2 V 时导通泄放,防 USB 反灌或突卸能量抬高母线 | §15.2 | -| **SR-10** | **USB 反灌三条对策全做,一条都不能省**:① 软件关充电 ② VBUS 负载开关 ③ 母线泄放 | 决策 12 / §13.5 | -| **SR-11** | 上游 USB 不做 PD 协商(CC 双 5.1 kΩ 下拉,纯 UFP),杜绝 VBUS 被抬到 9 / 12 V | §15.1 | -| **SR-12** | 五台手机的地**不得**在载板上并成一根:定位孔做 **NPTH**,立柱用**尼龙** | §25.2 | -| **SR-13** | 固件**不得**提供任何能让输出超过 4.2 V 的接口 | §8.3 | -| **SR-14** | 任一路输出**不得**给 ESP32 供电(用独立的 12 V→3.3 V 小 Buck) | §8.3 | -| **SR-15** | 电源**必须**接原厂排线的 `P+`/`P−`(保护板输出侧),**不是** `B+`/`B−` | 决策 6 / §13.5 | - -> ⚠️ **SR-15 是最容易在装配环节做错、且常温下完全测不出来的一条。** -> 焊在 `B+`/`B−` 时一切表现正常,只有过压时才暴露 —— 那时保护板仍串在通路里。 -> 判别与验证步骤见 §13.5 / §13.7,已列为打样前阻塞项(V-1)。 - -> ⚠️ **`J600` 的 `GND3`/`GND4` 固定脚实际接的是 VBATT,`J1401` 的 `GND2` 是 USB_VBUS。** -> 按名字当地用会直接短路电池。 - ---- - -## 7. 接口需求(IF) - -> **编号只增不改**:新增接口一律取下一个空号,位置按逻辑归类 —— -> 所以表内序号不一定连续(如 IF-19 排在 IF-14 后面)。ID 是稳定的引用标识, -> 不是目录序号;改动已有编号会让所有交叉引用与验收记录失效。 - -### 7.1 手机侧接口(每台一套) - -| ID | 接口 | 要求 | 出处 | -|---|---|---|---| -| **IF-1** | 供电 | 原装 BM3E 电池排线的 `P+`/`P−` 焊点;保留 BTB 公座 / NTC / ID,**旁路保护板**;电芯移除 | §13.2 / §13.5 | -| **IF-2** | 侧键 | J601(VOL+) / J602(VOL−) / J603(PWR) / J604(公共地),**对地短接**方式,绝不从外部灌电压 | §13.3 | -| **IF-3** | EDL | 短接主板上的 TP1204 ↔ TP1205(1.8 V / 1 mA),用**模拟开关**,不用普通 N-MOS | §22.4 | -| **IF-4** | USB | 装**原厂尾插小板**,本板只接 VBUS / D± / GND 四根 | 决策 10 / §15.1 | -| **IF-5** | 离板电容组 | 每路 2200 µF **普通铝电解**(靠 ESR 阻尼)+ 100 µF ×2 + 22 µF ×2 + 100 nF,**必须装在排线端,不在电源板上** | §6.3 | -| **IF-6** | 线缆 | ≤ 20 cm、18 AWG 或更粗、正负双绞或紧贴平行;板端用 Micro-Fit 3.0 / XT30,**不用杜邦线** | §6.4 | - -> ⚠️ **IF-5 的 2200 µF 千万不能买成聚合物电容。** 整个阻尼设计靠的就是普通铝电解那 -> 几十 mΩ 的 ESR;换成 12 mΩ 的聚合物,Q 从 0.19 跳回 4.5,线缆 LC 会在 6.2 kHz 上振铃。 - -> ⚠️ **IF-5 的大电容必须在排线端。** 放在电源板上等于隔着 300 nH 的线缆电感,基本无效。 - -### 7.2 板对板接口 - -| ID | 需求 | 出处 | -|---|---|---| -| **IF-7** | 两条 2×20 排针(共 80 针),分列板子两端 | §22.2 / §25.10 | -| **IF-8** | **一根高速差分都不穿连接器** —— Hub、上游 USB-C、VBUS 开关全部上移到载板 | §22.1 | -| **IF-9** | 排针只走电源与低速控制;每路 `P+`/`P−` 独立分配针位,保证独立回流(FR-1) | §22.2 | -| **IF-10** | 两板间净空 13 mm;载板底面元件与下板高元件的高度预算必须逐点核过 | §26.2 | - -### 7.3 对外接口 - -| ID | 接口 | 要求 | -|---|---|---| -| **IF-11** | 电源输入 | DC 12 V 插座(XT30 级),配 10 A 适配器 | -| **IF-12** | 上游 USB | 载板 USB-C(UFP),一根线接电脑 | -| **IF-13** | 调试 USB | 下板 USB-C,ESP32-S3 原生 USB-Serial-JTAG;**VBUS 故意不接**(物理上杜绝反灌 3V3)。**同一个口兼作 SW-12 的兜底命令链路**(原生 USB-Serial-JTAG 本身就是 CDC-ACM,不影响 esptool 下载),分帧要求见 §10.8 | -| **IF-14** | 风扇 | 5 × KF2510-3A(+12 V / 回流 / TACH) | -| **IF-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时先用 IF-13 的 USB 链路配网 | - -> ⚠️ **IF-12 与 IF-13 是两个长得一样的 USB-C,插错的现象是「adb 整排不见」**, -> 很难联想到是插错口。**丝印必须写清楚**,建议用不同颜色座子或在外壳上做标记。 - -### 7.4 内部数字接口 - -| ID | 需求 | -|---|---| -| **IF-15** | I²C 单条总线挂 10 个器件:INA226 ×6(0x40/41/44/45/42/43)、DAC63204 ×2(0x4A / 0x4B)、PCA9555(0x20)、LM75BD(0x48),地址互不冲突 | -| **IF-16** | ⚠️ **INA226 的通道号 ≠ 地址低位**(为布线让路重排过),固件必须**查表**,不能算 | -| **IF-17** | 74HC595 ×3 级联成 **24 位**移位链:15 路侧键 + `HUB_RST` + 5 路 `EDL_EN`;**先移入的字节落在 U33 上** | -| **IF-18** | `EFUSE_EN` ×5 与 `VBUS_EN` ×5 **必须 GPIO 直连**,不得经移位寄存器 —— 安全关键,故障瞬间要能立刻关断 | - ---- - -## 8. 机械与热需求(MR / TR) - -| ID | 需求 | 出处 | -|---|---|---| -| **MR-1** | 载板 220 × 135 mm,四角 R5;五块主板 **3+2 双排面对面**,第二排居中 | §25.1 | -| **MR-2** | 下板 160 × 125 mm,装配时相对载板原点偏移 (30, 5) | §25.2 | -| **MR-3** | 每台手机 7 个定位孔,全部 **NPTH Φ2.5 + 尼龙立柱**(见 SR-12) | §25.2 | -| **MR-4** | 侧键用顶针接触,顶针焊盘必须避免把相邻触点短路 | §25.3 | -| **MR-5** | 排线穿槽:电池排线 8×38 → 槽 10×3.0;尾插排线 18×55 → 槽 20×3.0(在 J1401 正下方) | §25.16 | -| **TR-1** | 总热负荷 ≈25 W(5×SDM845 满载 10~15 W + 下板功率级 ≈9 W) | §22.6 | -| **TR-2** | 强制风冷 **4 进 1 出 = 机箱正压**(灰尘往外走);两板间 13 mm 缝作天然风道 | §22.6 | -| **TR-3** | 所需风量仅 ≈2.9 CFM,**风量不是瓶颈,风道与正压才是设计要点** | §22.6 | -| **TR-4** | 风扇 5 × 12 V ≈0.70 A;低边 N-MOS + PWM,漏极**必须并续流二极管** | §22.6 | -| **TR-5** | 风扇闭环的测温点**放载板**(测的是手机),不能用下板功率级的两路 NTC 做反馈 | §22.6 | -| **TR-6** | TACH 必须分压后再进 GPIO;⚠️ 低边调速下 **PWM 关断期间读不到转速**,固件需在导通窗口采样 | §22.6 | - ---- - -## 9. 固件需求(SW) - -> 本节只列「固件要做到什么」。**怎么组织成一份固件见 §10 软件架构设计。** - -### 9.1 必做 - -| ID | 需求 | -|---|---| -| **SW-1** | 崩溃检测 + 两级自动重启(§4.1)—— **整块板子最核心的价值** | -| **SW-2** | 20 ms 周期巡检六路 INA226,电压偏离 >±100 mV 持续 20 ms 即关路告警(SR-5) | -| **SW-3** | 过流 / 故障事件记录:路号、时刻、电流值 —— 排查排线接触不良的唯一线索 | -| **SW-4** | 单路电压微调接口(跑不稳的手机提到 4.0 V,或反向降压降温),**上限受 SR-13 约束** | -| **SW-5** | 侧键序列:短按 100 ms 唤醒 / 长按 2 s 开机 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot / VOL++PWR 进 Recovery | -| **SW-6** | EDL 时序**固定为** `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0`(先短接再插 USB) | -| **SW-7** | 开机脚本执行「关充电」(写 `input_suspend` 类节点),路径需在首台机上确认后写死 | -| **SW-8** | USB 反灌保护:检测到该路电流转负或母线上升,**200 ms 内**断开该路 VBUS | -| **SW-9** | 风扇 PWM 闭环(以载板 LM75BD 为反馈)+ TACH 停转告警 | -| **SW-10** | 整板功率预算:读输入侧 INA226,超适配器能力时按优先级降载 | -| **SW-11** | 上电默认全关:五路输出关、VBUS 断、按键不按、EDL 不触发 | -| **SW-12** | **双链路命令接口**:WiFi 为常态、USB CDC 为兜底,两条同时在线、共用同一套命令语义与同一个命令处理器;命令带全局序号做幂等判重。链路失联**不触发任何动作**。架构见 §10.8 | - -### 9.2 明确禁止 - -| ID | 禁止事项 | 理由 | -|---|---|---| -| **SW-N1** | 让 ESP32 参与过流保护的实时环路 | 那是 eFuse 的活,MCU 不够快也不够可靠 | -| **SW-N2** | 提供任何能让输出超过 4.2 V 的接口 | SR-13 | -| **SW-N3** | 烧写 `DIS_USB_JTAG` eFuse | 烧了之后引脚 JTAG 和 USB 两边都进不去,板上没有 JTAG 排针 | -| **SW-N4** | 把 INA226 通道号当地址算 | IF-16 | - ---- - -## 10. 软件架构设计 - -> **这一节为什么在需求文档里**:本文原则上只讲「做成什么样」,但**固件在设计文档里没有 -> 落脚点** —— 设计文档 §8.3 只有一张按价值排序的功能清单,没有结构。在补出一份独立的 -> 软件设计文档之前,架构先放这里,与 §9 的固件需求贴在一起看。 -> 硬件的「怎么做」仍然一律在设计文档。 - -### 10.1 分层与职责边界 - -```diagram=layers -┌─ 农场管理系统(既有,本项目不交付 —— N7)────────────────────────┐ -│ adb 探活 / 业务调度 / 崩溃判定 │ -└───────┬────────────────────────────────┬──────────────────────────┘ - │ adb over WiFi(业务面) │ 控制面(10.8,⚠️ 链路待定) - ▼ ▼ - 5 台小米8 主板 ┌─────────────────────┐ - ▲ │ PhoneRail 控制器 │ - │ 供电 / 侧键 / EDL / VBUS │ ESP32-S3 固件 │ - └──────────────────────────┤ 遥测 · 执行 · 记录 │ - └──────────┬──────────┘ - │ 只能建议,不能否决 - ┌───────────────▼───────────────┐ - │ 硬实时层:eFuse OVP / 限流 │ - │ 放电 MOS 反相联锁 / 上下拉 │ - │ —— 不含任何软件(SR-3、SR-7) │ - └───────────────────────────────┘ -``` - -**一条边界决定了下面所有取舍**:固件是**遥测 + 执行器 + 记录仪**,不是保护装置 -(SR-5、SW-N1)。凡是「必须及时」的事都已经在硬件里了;固件里没有任何一条路径 -是「不跑到就会烧手机」的。因此: - -- 任务优先级不需要为保护让路,只需要保证遥测不被执行器序列饿死; -- 看门狗复位是**可接受的失效**(复位即全关,SR-6),不需要为了「保持输出」而弱化看门狗; -- 固件可以随时被重启、被降级运行,硬件安全性不变。 - -### 10.2 架构的第一个判断:失联 ≠ 断电 - -| 状态 | 输出该怎么办 | 为什么 | -|---|---|---| -| ESP32 复位 / 上电中 | **全关**(硬件下拉保证,SR-6) | 状态未知,且此刻手机本来就没在跑 | -| 与上位机失联 | **保持供电不动** | 五台手机正在跑业务;因为控制链路断了就把它们全断电,是把一次网络故障放大成一次全场停机 | -| 本地判据触发(SR-5 / SW-8 / 过温) | **关对应那一路** | 已知的危险,且范围可控 | - -⚠️ 这三行看起来像常识,但它们互相矛盾过一次:**「安全默认值 = 关」只对未知状态成立**。 -失联是已知状态,此时最安全的动作是「什么都不做」。固件里不要写「心跳超时就关输出」。 - -**推论**:失联期间固件必须能独立运行全部本地职责 —— 巡检、风扇闭环、事件记录、 -按队列执行未完成的序列。上位机回来之后靠事件日志补齐时间线(10.7)。 - -### 10.3 任务划分(FreeRTOS / ESP-IDF) - -| 任务 | 周期 | 优先级 | 职责 | 失败后果 | -|---|---|---|---|---| -| `t_safety` | **20 ms** | 最高 | 读 6 片 INA226(V+I)与 PCA9555 输入;执行 SW-2 / SW-8 判据;越界直接操作 `EFUSE_EN` / `VBUS_EN`(**GPIO 直连,不经任何队列或抽象层**) | 告警延迟;保护本身不受影响(eFuse 是硬件) | -| `t_thermal` | 1 s | 中 | 两路 NTC(下板功率级)+ 载板 LM75BD(手机侧)→ 风扇 PWM 闭环;过温先降流后关路(FR-13、TR-5) | 风扇维持上一个占空比 | -| `t_actuator` | 事件驱动 | 中 | 每路一个状态机:断电重启 / 按键序列 / EDL 序列(10.6) | 序列停在中间 —— 必须有超时兜底 | -| `t_link` | 事件驱动 | 低 | 上位机命令解析与遥测上报(10.8) | 见 10.2:不影响本地职责 | -| `t_log` | 空闲 | 最低 | 环形缓冲落盘(10.7) | 丢日志,不丢功能 | - -**共享资源只有两个**,各配一把互斥锁: - -1. **I²C 总线** —— 10 个器件在同一条上(IF-15),其中 LM75BD 还在排针另一侧的载板上。 -2. **74HC595 的 24 位链** —— 全板共享,见 10.6 的影子寄存器。 - -**I²C 带宽核算**(400 kHz,`t_safety` 是唯一的高频用户): - -``` -一次 INA226 寄存器读 ≈ 46 个位周期 ≈ 115 µs -6 片 × 2 个寄存器(总线电压 + 分流电压) = 12 次 ≈ 1.4 ms -PCA9555 读两个输入端口 ≈ 0.12 ms - 合计 ≈ 1.5 ms / 20 ms = 约 8 % 占用 -``` - -余量充足,`t_thermal` 和偶发的 DAC 写完全挤得下。**首板上电时用逻辑分析仪实测复核一次** -——这个数是按理想时序算的,没算时钟延展和重试。 - -> **A8 已落地**(2026-09-07,`firmware/main/{tasks,sched,t_safety}.c` + -> `components/phonerail_hal/{include/hal_lock.h,hal_i2c_acct.c}`)。五条实现约定: -> -> 1. **五个任务是一张 const 表**(`tasks.c`),周期与优先级写在一起,开机 `task_table_ok()` -> 自检,不过就 abort。查的是**相对顺序**:safety 唯一最高、log 唯一最低、 -> thermal 与 actuator 同级、link 夹在中间;外加「巡检周期必须长于 INA226 的 -> 一帧转换 8.8 ms」,否则同一帧被读两次,现象是「电流特别稳」。 -> 2. **周期是间隔,不是延时。** `sched_periodic_*()` 记的是下一次该醒的**绝对时刻** -> (回绕安全,一律用 `int32_t` 差判先后)。写成「干完睡 20 ms」的话, -> 实际周期是 21.6 ms 且随总线忙闲抖动。过载时**跳过错过的整周期、保住相位**, -> 不补跑 —— 补跑的现象是「卡一下之后连着打满总线」。 -> 3. **两把锁不许嵌套持有。** 595 是纯 GPIO、I²C 是纯总线,两者没有交集, -> 所以「拿着 A 去拿 B」永远是写错了,`hal_lock_take()` 直接判 `HAL_ERR_STATE` -> ——与其定一套加锁顺序再指望人人记得,不如让它不可能发生。ESP 侧用的是 -> **mutex 不是 binary semaphore**(要优先级继承:t_link 拿着总线时 -> t_safety 不能被中优先级任务无限期挡住)。 -> 4. **巡检不死等锁**:等 5 ms(周期的 1/4)拿不到就跳过这一圈并计数。 -> 死等的现象是最高优先级的任务整个停摆,而且没有任何人会发现。 -> 同样地,**一个器件读失败不中断这一圈**,失败那一路保留上一圈的值并把 `ok` 标 false -> ——清零的话 A10 的过压判据会看到一个「正常」的 0 V。 -> 5. **那 8 % 是算出来的,不是抄的。** `hal_i2c_acct` 在 HAL 里按线上真实字节数记账 -> (两个目标同一份代码),`t_safety_budget_us()` 按事务形状静态预算,两者必须相等。 -> 实测:一次寄存器读 **48** 个位周期(本节手算的 46 没数 STOP 和一个重复 START), -> 一圈 13 次事务 = **1560 µs / 20 ms = 7.8 %**,与本节的「约 1.5 ms / 约 8 %」相符。 -> 多读一次寄存器就会让静态预算与实测对不上,测试当场变红。 -> 首板仍要按本节末尾那句用逻辑分析仪复核一次(这个数不含时钟延展与重试)。 - -### 10.4 五路一张表:禁止用通道号算任何东西 - -这是**本项目最容易写出「看起来对、实际错一路」的地方**。三组编号都不是逻辑顺序: - -- **INA226 地址**按布线难度重排过(IF-16,设计文档 §8.2); -- **74HC595 的位序**按 PCB 几何排(设计文档 §17.2 / §22.4); -- **GPIO 编号**按出线方向倒推(设计文档 §26.18)。 - -所以固件里**只允许有一张静态配置表**,全部五路循环都索引它: - -```c -typedef struct { - uint8_t ina_addr; /* 0x40 0x41 0x44 0x45 0x42 —— 查表,不能算 */ - uint8_t dac_chip; /* 0x4A: CH1-3 0x4B: CH4-5 */ - uint8_t dac_ch; /* U6.OUT0/1/2 U7.OUT0/1 */ - gpio_num_t efuse_en; /* GPIO 直连 */ - gpio_num_t vbus_en; /* GPIO 直连 */ - uint8_t key_bit[3]; /* PWR / VUP / VDN 在 24 位链里的位号 */ - uint8_t edl_bit; /* U33 上的位号 */ - uint8_t flt_bit; /* PCA9555 IO0_0..4 */ - uint8_t pg_bit; /* PCA9555 IO1_0..4 */ -} rail_cfg_t; -``` - -> ⚠️ **`ina_addr = 0x40 + ch` 这行代码一定会被写出来,而且只有 CH3/CH4/CH5 是错的。** -> 实际地址是 `0x40 / 0x41 / 0x44 / 0x45 / 0x42`。查表,不要算。 - -> **A3 已落地**:表在 `firmware/components/drv/include/ina226.h`,取值只走 -> `ina226_rail_addr(rail)`(rail 是 1~5,越界 abort), -> `test_channel_index_is_not_the_address_low_bits` 专门拦「改回算式」这一手。 -> 同处还记了两条 INA226 的实现约定:**寄存器指针不自增**(每次读前重写指针, -> sim 会自增、真件不会,靠自增在宿主机上测不出来);**Calibration 丢了不报错、 -> 只让 Current/Power 恒读 0**,所以巡检要定期 `ina226_check()`。 - -同理,**PCA9555 的方向寄存器只能配成全输入**:备用脚 Port0[7] / Port1[7:5] 在硬件上 -直接接地,配成输出并写 1 就是对地短路(设计文档 §17.2 已在图纸上注明)。 - -> **A6 已落地**:驱动在 `firmware/components/drv/include/pca9555.h`, -> **根本不提供「把某个脚配成输出」的接口**(和 sr595.h 不提供「抬 OE#」同一条规矩)。 -> 方向字一律过 `pca9555_dir_safe()`,不安全直接 abort。 -> 同处还记了四条这一片的实现约定: -> ① ⚠️ **Output Port 寄存器的复位值是 0xFF** —— 所以把接地脚配成输出的那一刻它就 -> 输出高电平,中间没有「先写个 0 再说」的余地,这比「配错方向」本身更急; -> ② **极性反转寄存器必须无条件清零**,不能赌复位值:看门狗只复位 ESP32, -> PCA9555 不掉电,上一条命的残留值原样留着,而写错的现象是「五路同时报故障」 -> 或者更糟的「五路故障全都看不见」,I²C 一切正常; -> ③ **三种极性**(FLT# 低有效 / PGOOD 高有效 / INA_ALERT 低有效)各自只在 -> `pca9555_rail_fault()` 等四个函数里出现一次,上层不许自己去 & 位; -> ④ **INT# 是加速路径不是主路径**:它要读走输入端口才释放,漏读一次就再也等不到 -> 下一个沿,所以 `t_safety` 必须无条件轮询(§10.3 的 20 ms 就是按这个排的)。 -> -> ⚠️ **FLT 与 PG 两张位表这次确实是顺排的**(都是 `bit = rail - 1`), -> 和 INA226 地址、595 位号那两张表不一样。所以这里最容易写出来的错不是「算式」, -> 而是**把 Port0 和 Port1 搞反** —— 两个端口的 bit0~4 长得一模一样,搞反之后 -> 「有故障」会被读成「电源良好」,方向正好相反,而且**静息态两个端口读回来都是 -> 全 1,单看一帧完全看不出来**。`test_one_rail_at_a_time` 专门拦这一手。 - -> **A7 已落地**(2026-09-07):那张 `rail_cfg_t` 表在 `firmware/main/rail_cfg.h` / -> `rail_cfg.c`,取值只走 `rail_cfg(rail)`(1~5,越界 abort)。六列的样子: -> -> | CH | INA226 | DAC | 按键位 PWR/VUP/VDN | EDL 位 | EFUSE_EN | VBUS_EN | -> |---|---|---|---|---|---|---| -> | 1 | 0x40 | U6.OUT2 | 1 / 2 / 3 | 16 | IO39 | IO40 | -> | 2 | 0x41 | U6.OUT0 | 19 / 17 / 18 | 7 | **IO48** | IO10 | -> | 3 | 0x44 | **U7**.OUT0 | 22 / 21 / 20 | 6 | IO6 | IO11 | -> | 4 | 0x45 | U6.OUT1 | 9 / 10 / 11 | 0 | IO41 | IO42 | -> | 5 | 0x42 | U7.OUT1 | 15 / 14 / 13 | 5 | IO8 | IO17 | -> -> 落地时定下来的五条: -> ① **本表与四个驱动那四张表是「两处写同一个数」,所以要对账而不是现算。** -> 开机自检 `rail_selftest()` 逐格拿本表和 `ina226_rail_addr()` 等取值函数比一遍, -> 对不上就报 `RAIL_SELFTEST_TABLE`。做成「开机从驱动现算」的话按定义就查不出分家, -> 而且表不再是 const 的、用之前得先记着初始化。 -> ② **判据本身也要被测**:真表是 const 的,测试改不动它,所以判据单拎成 -> `rail_selftest_table(t)`,测试喂几张故意写坏的表进去看它认不认 -> (分家 / 引脚重复 / 撞 SDA / strapping / 不存在的号 / 越界,六种都验过)。 -> ③ **两列 GPIO 是全固件唯一一处出处**,没有第二张表能对账 —— 所以 -> 「`EFUSE_EN1` 和 `VBUS_EN1` 写反」这种错自检**查不出来**(两个都是合法空闲脚、 -> 不重复、不越界)。唯一的拦网是 `test_en_pins_match_the_pcb_netlist` 里钉死的 -> 十个值(出处:`.claude/pcb1-parts.json` 的 U20 焊盘网络)和 B1 上板实测。 -> ④ **「不是顺排」要看整列,不能逐格撞算式**:CH4 的按键位正好是 9/10/11、 -> CH1 的 EDL 位正好是 16、CH3 的两根 EN 正好等于「IO4 起 / IO9 起」那个算式 —— -> 单看任何一路都「像顺排」。测试改成断言整列**不是等差数列**。 -> ⑤ ⚠️ **HAL 两侧的 GPIO 上限原来是 `pin < 48`,把 IO48 关在了门外**, -> 而 `EFUSE_EN2` 就在 IO48 上 —— 现象是 CH2 的 eFuse 既开不了也**关不掉**。 -> 已改成 0~48(含)。ESP32-S3 的编号是连续的但芯片不是:IO22~IO25 不存在、 -> IO26~IO32 是模组内部 flash/PSRAM,写进配置表 `gpio_config()` 不报错也不干活, -> 所以自检把这些号也当成越界。 -> -> 同处还落地了**全板引脚表** `firmware/main/board_pins.h`(§8.1 那张表里 -> 「全板独一份」的那些脚)与两根 EN 线的表驱动遍历 -> (`rail_en_init()` / `rail_efuse_enable()` / `rail_vbus_enable()` / `rail_all_off()`)。 -> `rail_all_off()` **先断五路 VBUS 再关五路 eFuse**,中间不留「手机只靠 USB -> 供着电」的窗口;十个脚全部试一遍,不因为某一个失败就提前返回。 -> 高有效这件事只在 `en_write()` 里出现一次。 - -### 10.5 电压通道:DAC 只做微调,而且是反向的 - -| 事实 | 含义 | -|---|---| -| `Vd = 0 V` → 输出 **4.010 V**(上限) | **DAC 写得越大,输出越低** | -| `Vd = 0.548 V` → 输出 3.90 V(NVM 出厂默认) | 上电即安全值,不依赖 ESP32 初始化成功 | -| `Vd = 2.424 V`(满量程)→ 输出 **3.524 V**(下限) | 满量程是 2.424 V,不是 2.5 V(内部基准 1.212 V ×2) | - -> ⚠️ **反向关系是本节最容易写反的一条。** 写反的现象不是报错,而是 -> 「调高电压的命令把手机调到了 3.52 V」——手机照样能开机,只是更容易在负载尖峰下掉电, -> 而且**要跑一阵子才看得出来**。固件里把这一步封成 `rail_set_mv()` 单一入口, -> 内部做 mV → Vd → code 的换算,**不要在别处直接写 DAC 码值**。 - -三条固件规矩: - -1. **越界即拒绝**:`rail_set_mv()` 收到 >4010 mV 的请求直接返回错误并记事件(SR-13)。 - 注意这是**第四道**限制,前三道在硬件里 —— 它的作用只是让操作者早点知道自己写错了。 -2. **不用 DAC 做保护**:任何时候都不要靠「把电压调低」来处理异常,那是 `EFUSE_EN` 的活。 -3. **出厂标定**:分压网络与 DAC 基准都有 ±1 % 级的容差,用该路 INA226 读回实际输出, - 做一次两点线性标定存进 NVS。标定值缺失时退回理论公式(误差约 ±40 mV,可用)。 - -> **A4 已落地**:换算与设定在 `firmware/main/rail.c`,唯一入口 `rail_set_mv(rail, mv)` -> (rail 是 **1~5**)。三个验收点各有一条测试:4020 mV 被拒、码大电压低、3900 mV ↔ Vd 0.548 V。 -> 标定(本节规矩 3)**还没做**,现在走的是理论公式;`RAIL_VOUT0_UV` / `RAIL_DAC_SPAN_UV` -> 两个常量就是将来要被标定值顶替的地方。 -> -> 同处记下三条 DAC63204 的实现约定(出处 SLASEX3A): -> - **12 位码在 `DAC-X-DATA`(19h~1Ch) 里是左对齐的**(bit15-4)。忘了 `<< 4` 不报错, -> 只让输出变成 1/16 —— 而在本板上那表现为「五路都是 4.010 V 满电」,看不出异常。 -> - **`EN-INT-REF` 要先于增益设定写**,且**上电安全码要写在通道上电之前**: -> 复位码是 0,而 Vd = 0 就是输出上限 4.010 V。`dac63204_init()` 因此把 -> `COMMON-CONFIG` 写两次(先按住输出,写完码再上电)。 -> - **通道分配不是 `rail−1`**(设计文档 §26.18 重排过,五路里四路对不上): -> CH1→U6.OUT2 / CH2→U6.OUT0 / CH3→**U7**.OUT0 / CH4→U6.OUT1 / CH5→U7.OUT1。 -> 四通道电气对等 ⇒ 接错了照样工作,只是调错路,功能测试测不出来。 - -### 10.6 执行器:三个状态机 + 一个影子寄存器 - -**每路三个序列,彼此互斥、不可重入**(同一路上同时只能跑一个): - -| 序列 | 时序 | 关键约束 | -|---|---|---| -| 硬重启 | `EN↓` → 等 3~5 s → `EN↑` → 等 **17 ms**(tON 5.1 ms + 缓启动斜坡 11.5 ms)→ 等 PG 置位 | 放电由硬件联锁自动完成(SR-7),固件**不要**去控制放电 MOS | -| 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | 组合键(VOL−+PWR)必须**同一帧**写进去,不能先后两次 | -| EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | 顺序固定(SW-6):先短接再插 USB | - -**影子寄存器是这一节的核心**:15 路按键 + `HUB_RST` + 5 路 `EDL_EN` 全在**同一条 24 位 -移位链**上,写任何一位都要把整条链重发一遍。 - -``` -ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U33) -写入顺序:U33 字节 → U22 字节 → U21 字节,最后一个 RCLK 上升沿统一生效 -``` - -规矩:**内存里保存一份 24 位影子值,全固件只有一个函数能写 595**,任何任务改按键 -或 EDL 都是「改影子 → 请求刷新」。多处直接操作移位链,症状是**另一台手机的按键被顺手 -清掉**,而且复现要靠两个动作正好撞在一起 —— 属于会拖很久的那类 bug。 - -> `RCLK` 打点之前输出不变,所以整帧刷新是原子的;`OE#` 保持有效,**不要**用拉高 `OE#` -> 的方式「临时全松开」——那会让 15 个栅极同时进高阻,靠的是每个栅极自己的 100 kΩ 下拉, -> 是复位路径,不是正常控制路径。 - -> **2026-09-06 A5 落地**:`components/drv/sr595.{h,c}`。影子是一个 24 位整数 -> (bit0 = U21.QA 链首 … bit23 = U33.QH 链尾),唯一写入点是 `sr595_flush()`; -> 别处只准 `sr595_modify()` 改影子,组合键走 `sr595_write(mask, value)` 一帧发完。 -> 位表见设计文档 §17.2(§26.18 重排后的那张,**不是 09-04 那张**)。 -> -> 同处记下三条这一层的实现约定: -> - **上电顺序是「先锁存安全值,再使能 `OE#`」,不能换。** 三片的 `MR#` 接死在 +3V3, -> 没有复位线 ⇒ 上电时移位寄存器内容**不确定**。先拉低 `OE#` 就是把那坨随机值 -> 放到 15 个栅极上 —— 五台手机的电源键可能一起被按下去。所以 `sr595_init()` 里 -> `KEY_OE#` 在安全值锁存完之前**根本不配成输出**,靠板上 R609 上拉着。 -> (ESP-IDF 的 `gpio_config()` 一配成输出就开始驱动,输出寄存器复位值是 0。) -> - **`HUB_RST` 低有效**,所以上电安全影子是 `1 << 23` 而不是 0。写成 0 的现象是 -> 「Hub 插上枚举不出来」,而五路电压电流全都正常,看不出跟固件有关。 -> - **发送失败要留着脏**(`sr595_flush()` 失败不清 dirty,下次重发整帧)。 -> 丢了脏标志 = 链上和影子永久对不上,而后面每一次 flush 都会「成功」。 - -### 10.7 事件记录与存储 - -事件是 SW-3 的交付物,也是排查排线接触不良的唯一线索,所以**记录路径不能依赖上位机在线**。 - -| 项 | 设计 | -|---|---| -| 缓冲 | RAM 环形缓冲,≥256 条;`t_safety` 只入队,不落盘 | -| 落盘 | `t_log` 空闲时批量写 NVS / SPIFFS 分区 | -| 单条 | 时间戳 + 路号 + 事件类型 + 当时的 V/I(16 B 量级) | -| 容量 | 4096 条 ≈ 64 KB,按每天几十条算够存半年以上 | -| 时间 | 上电从 0 计;上位机连上后下发一次墙钟做偏移校正(不加 RTC 芯片) | - -必记事件:eFuse FAULT / PG 变化、SR-5 触发关路、SW-8 反灌断 VBUS、过温降载、 -每次重启序列(软 / 硬、谁下的命令)、越界命令被拒、I²C 器件失联、风扇停转。 - -### 10.8 上位机接口 ✅ 已定稿(2026-09-06 用户拍板:方案 C) - -**这一节回答的问题**:SW-1 的触发条件写的是「`adb` 探活连续失败 N 次」, -而 **ESP32 跑不了 adb** —— 所以崩溃判定必然发生在上位机,板子只负责执行动作。 -这意味着必须有一条控制链路,而在此之前**全部文档里都没有定过它**。 - -**结论:WiFi 为常态链路,USB CDC 为兜底链路,两条同时在线。** - -选它不是因为「都要」,而是因为 **SW-1 是这块板子的核心价值, -而 WiFi 与手机 adb 走的是同一张网** —— 只做 WiFi 等于让「救援通道」和「故障通道」 -共命运,恰好在最需要它的时候一起失效。USB 的增量成本很低:线本来就插着。 - -留档备查的两个单选方案: - -| 方案 | 优点 | 为什么不单独用 | -|---|---|---| -| A. 只做 WiFi | 无线、可多台组网;设计文档 §9.5 的 3V3 预算里**已经给 ESP32-S3 的 WiFi 突发留了 ≈350 mA**,说明当初就是按带 WiFi 设计的 | 网炸了救砖通道跟着炸,见上 | -| B. 只做 USB CDC | 最可靠,不依赖网络 | 每块板占上位机一个 USB 口;机架规模上去之后不现实 | - -#### 两条链路怎么共存 - -```diagram=links - WiFi (MQTT/HTTP) USB CDC (J22) - │ │ - ┌───────▼────────┐ ┌────────▼───────┐ - │ 传输适配器 A │ │ 传输适配器 B │ ← 只管收发字节与分帧 - └───────┬────────┘ └────────┬───────┘ - └──────────┬─────────────┘ - ┌──────▼───────┐ - │ 单一命令处理器 │ ← 全部鉴权、限幅、幂等判重都在这里 - └──────┬───────┘ - ▼ - 执行器 / 遥测 -``` - -四条规矩: - -1. **命令处理器只有一个。** 两条链路是**传输**,不是两套功能。任何安全约束 - (SR-13 的限幅、越界拒绝、序号判重)都写在处理器里,绝不写进适配器 —— - 否则以后加第三条链路时必然漏掉一处,而**功能测试完全测不出来**。 -2. **不做主备切换,两条都常开。** 谁先送到算谁的,靠 3 的序号去重。 - 做「主备」就要判定"主链路死了没有",而那个判定本身就是新的故障点。 -3. **序号是全局的,不分链路。** 上位机给每条命令一个单调递增的序号, - 板子记住最近 N 个已执行的序号。WiFi 重传 + USB 又发了一遍同一条命令时, - 第二次必须被丢弃 —— 否则「按 10 秒电源键」会执行两遍,第二遍正好按在刚开机的手机上。 -4. **USB 链路与日志共用一条流。** ESP32-S3 的原生 USB-Serial-JTAG 本身就是 - CDC-ACM,不需要切到 TinyUSB,也不影响 esptool 下载(FR-15 不变)。 - 代价是命令响应和运行日志在同一条串流上,**必须分帧**(建议命令用带前缀的单行 - JSON,日志走另一个前缀),否则日志一刷就把响应冲散。 - -> ⚠️ **别把 WiFi 凭据和 MQTT 地址编译进固件。** 放 NVS,用 USB 链路配置 —— -> 这正好是兜底链路的第二个用途:新板子上架时先插 USB 配网。 - -命令语义与链路无关,两条链路完全一致: - -| 命令 | 参数 | 说明 | -|---|---|---| -| `status` | — | 五路 V/I/PG/FLT、输入 V/I、两路 NTC + 载板温度、风扇转速、各路状态机状态 | -| `set_voltage` | 路号, mV | 走 `rail_set_mv()`,越界拒绝(10.5) | -| `power_cycle` | 路号, 断电秒数 | 二级硬重启(4.1) | -| `key` | 路号, 键位掩码, 保持 ms | 单键与组合键同一个接口 | -| `edl` | 路号, on/off | 内部按 SW-6 的固定时序展开 | -| `vbus` | 路号, on/off | 默认断开 | -| `fan` | 占空比 / auto | `auto` 交回 `t_thermal` 闭环 | -| `events` | 起始序号 | 增量拉取事件日志 | - -三条接口硬约束: - -- **没有任何命令能让输出超过 4.2 V**(SR-13)—— 包括「原始 DAC 写入」这类调试命令, - 它压根不该存在于发布固件里。 -- **命令必须幂等或带序号**:链路重传时不能让「按 10 秒电源键」执行两遍(上面第 3 条)。 -- **链路失联不触发任何动作**(10.2):没有心跳超时关输出这回事。 - -### 10.9 启动顺序 - -``` -1 上电:GPIO 全部显式置安全态(硬件下拉已保证,固件再确认一次) -2 I²C 扫描 10 个器件 —— 缺任何一个都进「降级模式」(10.10),不要照常启动 -3 读回 DAC 通道值(NVM 默认 3.90 V),与期望比对 -4 载入 NVS 里的标定值与上次的每路使能状态 -5 逐路上电,**路与路之间错开 100 ms** -6 等各路 PG,超时则记事件并保持该路关闭 -7 起风扇闭环,起链路任务 -``` - -> ⚠️ **第 5 步的错开不是讲究,是必须**:单路缓启动涌流 0.8 A,五路同时开就是 4 A -> 叠在 12 V 输入上,而输入预算本来就是 8.3 A(PR-2)。错开 100 ms 之后 -> 各路的斜坡(11.5 ms)完全不重叠。 - -### 10.10 降级矩阵 - -| 失效 | 检测方式 | 行为 | -|---|---|---| -| 某片 INA226 无应答 | I²C NAK | 该路失去遥测 → **保持供电**,标记为「盲跑」,禁用该路的 SR-5 / SW-8 判据并持续告警 | -| I²C 总线挂死(SDA 被拉低) | 连续超时 | 发 9 个时钟脉冲做总线恢复;仍不通则复位 I²C 外设;再不通进降级模式 | -| DAC 无应答 | I²C NAK | **不关输出** —— DAC 不响应时输出停在 NVM 默认的 3.90 V,是安全值 | -| PCA9555 无应答 | I²C NAK | 失去 FAULT / PG 可见性;改用 INA226 的电压判据兜底 | -| 载板 LM75BD 无应答 | I²C NAK | 多半是排针接触不良(它在排针另一侧)→ **风扇转到 100 %** 并告警,不要按「读不到温度=不热」处理 | -| 上位机失联 | 心跳超时 | 见 10.2:**什么都不做** | -| 看门狗复位 | — | 复位即全关(SR-6),重启后按 10.9 恢复,并记一条复位事件 | - -**降级模式**的定义:只保留「关断能力」与「事件记录」,不再自动上电、不接受调压命令, -持续告警。宁可五台手机不开机,也不要在盲态下带电跑。 - -### 10.11 关于 FR-12「模拟电量曲线」的一个说明 - -这条需求的实现方式和它的字面意思不一样,值得在架构层写清楚:**手机的电量计是从 -电池端电压推算 SoC 的**,我们给的电压就是它看到的全部信息。所以「模拟电量曲线」 -= **按锂电放电曲线缓慢改变输出电压**(比如 12 小时从 4.01 V 走到 3.60 V,再跳回去)。 - -两个后果: - -- 它**不需要**任何新硬件,是 `rail_set_mv()` 之上的一个慢速调度器; -- 它与 SW-4(单路电压微调)**共用同一个执行器**,两者必须互斥 —— - 否则运维把某台调到 4.0 V,曲线任务转头又把它拉回 3.6 V。 - -FR-12 是「中(可选)」,建议**首版先不做**,把接口留出来即可。 - -### 10.12 架构层面的待定项 - -| 项 | 状态 | -|---|---| -| ~~上位机链路选型(10.8)~~ | ✅ **2026-09-06 用户拍板:方案 C**,WiFi 常态 + USB CDC 兜底 | -| **OTA 升级** | 既然定了 WiFi,**建议做**:双分区 + 失败回滚。没有 OTA 的话一次坏固件要把板子从机架上拆下来插 USB,而 USB 兜底链路本身也能烧 —— 所以这是「省事」不是「救命」,可以排在首版之后 | -| WiFi 掉线重连策略 | 指数退避 + 上限;**重连过程不得阻塞 `t_safety`**(10.3),链路任务优先级最低就是为这个 | -| 时间同步 | 当前方案是上位机下发墙钟做偏移,不加 RTC。若日志要脱机长期归档需重议 | -| FR-12 电量曲线 | 建议首版不做,只留接口(10.11) | -| `input_suspend` 节点路径 | 与 SW-7 / V-8 是同一件事,首台机上确认后写死 | - ---- - -## 11. 约束与假设 - -### 11.1 硬约束 - -| ID | 约束 | -|---|---| -| **C-1** | 全部为 1S 机型,负载固定为小米8(dipper)主板 | -| **C-2** | 原厂电池保护板**被旁路** ⇒ 失去一层与本板独立的防线,全部安全责任落在 SR-2 / 3 / 5 上 | -| **C-3** | 无电池 ⇒ 失去一个内阻只有几十 mΩ 的缓冲电容。**瞬态阻抗设计(§6)比电压精度重要得多**,是这类板子的主要故障来源 | -| **C-4** | ESP32-S3-WROOM-1 可用 IO 有限(19 根信号已排满),新增功能只能走移位寄存器或 I²C 扩展 | -| **C-5** | USB2517I 要求 D± 走线 ≤ 6 inch(152 mm) ⇒ Hub 只能放在载板通道正中 | -| **C-6** | BM24(尾插)连接器只有 **10 次插拔寿命** ⇒ 装配流程必须减少反复插拔 | -| **C-7** | 小米8 原理图 PDF 是第三方扫描件,**一切引脚 / 机械结论以实测为准** | - -### 11.2 假设(若不成立需重新评估) - -| ID | 假设 | -|---|---| -| **A-1** | 上游 USB 恒为 5 V(只接电脑 / Hub,不接快充头) | -| **A-2** | 手机关机待机电流在 ≈50 µA 量级 | -| **A-3** | 目标 ROM 上存在可写的「关充电」sysfs 节点(SW-7,路径待确认) | -| **A-4** | 手机常态功耗 0.3 ~ 1 A / 路,五路同时 4 A 满载不会发生 | - ---- - -## 12. 验收准则 - -### 12.1 首板上电验收(17 步,**严禁第一次上电就接手机**) - -完整步骤与通过标准见设计文档 **§11**。其中: - -- **第 5 步(人为断开 R2,模拟致命故障)** 与 **第 6 步(负载阶跃 + 阻尼验证)** - 是这块板子的两个**核心验证项,不得跳过** —— 它们分别验证 SR-3 和 PR-12 / C-3。 -- **第 12 步(ESP32 复位安全测试)** 验证 SR-6。 -- **第 14 步(USB 反灌测试)** 验证 SR-10 / SW-8。 - -### 12.2 需求 → 验收方法对照 - -| 需求 | 验收方法 | 通过标准 | -|---|---|---| -| PR-4 / PR-5 | 扫 DAC 全量程 | 输出扫过 3.52 ~ 4.01 V,与公式误差 <1%;**任何码值都到不了 4.2 V** | -| SR-2 | 固件写越界值 | 输出被拓扑限死,不越过上限 | -| SR-3 / SR-4 | 人为断开反馈下电阻 | eFuse 断开,输出侧尖峰不超过 4.7 V | -| SR-5 | 注入 ±150 mV 偏差 | 20 ms 内关路并产生告警记录 | -| SR-6 | 手动复位 ESP32 | 示波器确认五路立即关断、15 路按键无一误触发、EDL 无触发 | -| SR-7 | 固件同时置位 EN 与放电 | 硬件互斥,10 Ω 上无持续功耗 | -| SR-9 | 可调电源把母线升到 13.5 V | 泄放导通并拉回 13.2 V 以下 | -| SR-10 / SW-8 | 不关充电直接接 USB | 电流转负 → 固件 200 ms 内断 VBUS | -| SR-15 | 排线加 4.40 V | **不断开**(断开说明焊到了 `B+`/`B−`) | -| PR-12 | 0→4 A 阶跃(带 20 cm 线 + 电容组) | 压降 <300 mV,**无 6 kHz 振铃** | -| PR-13 | 满载 4 A 跑 30 min | 温升 <40 K | -| PR-14 | 断电重启时序 | 输出 200 ms 内 <0.5 V | -| FR-4 / SW-5 | 逐项试按键 | 短按唤醒 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot 均有效 | -| FR-5 / SW-6 | 下发 EDL 序列 | 主机可见 9008 设备 | -| FR-7 | 五台全接 | `adb devices` 与 `fastboot devices` 均可见 | -| FR-10 / TR-6 | 拔掉一个风扇 | TACH 频率下降约 1/5,产生告警 | -| SW-12 | 断开 WiFi(拔 AP / 关热点),从 USB 下命令 | 命令照常执行;**五路输出不受影响**(10.2 的失联行为) | -| SW-12 | 同一序号的命令从两条链路各发一遍 | **只执行一次**,第二条被判重丢弃并记事件 | -| **总体目标** | 五台长稳 | **24 h 无重启、无告警** | - -### 12.3 装配前必做的实物验证(阻塞项) - -| 项 | 内容 | 出处 | -|---|---|---| -| V-1 | **排线旁路验证**:确认焊在 `P+`/`P−`,4.40 V 不断开 | §13.7 | -| V-2 | **侧键触点定位**:万用表 5 分钟,拍照存档 | §13.3 | -| V-3 | **环路补偿实测扫频**(三个位置已留焊盘) | §9.2 | -| V-4 | **量两个新连接器实物高度**(XT30PW-M / 2510-3A)—— 余量只有 1~3 mm,**别信估计值** | §26.16 | -| V-5 | **两条排线路径试装**(电池 / 尾插穿槽走背面) | §25.16 | -| V-6 | **J7n 镜像位按底层装法复做**;装好后做两点通断复核 | §24.3 / §25.16 | -| V-7 | **风扇线装配顺序**:必须「先插五根风扇线,再合上载板」 | §26.16 | -| V-8 | 确认目标 ROM 上 `input_suspend` 的实际路径 | §15.2 | - -> 完整的待办 / 已关闭清单以设计文档 **§12** 为准,本表只列对需求验收有约束的。 - ---- - -## 13. 已知风险与已接受的取舍 - -| # | 风险 | 处置 | -|---|---|---| -| R1 | 去掉原厂保护板 = 少一层独立防线 | **已接受**。定量核算 eFuse 余量 ≈11 倍;且 eFuse 断开后 2200 µF 仍撑着,手机继续运行不掉电,表现优于保护板断开 | -| R2 | 最坏角落下 eFuse 可能到 4.519 V 才跳,超 1S 上限约 60 mV | **已接受**。走到这一步说明第一层已失效,输出以 6.4 mV/µs 冲过这 70 mV 只需 ≈10 µs,远短于锂电过压损伤时间常数 | -| R3 | 两个同款 USB-C 插错 | **已接受**(用户在 USB-B / USB-C 之间选了 C)。靠丝印 + 颜色 + 外壳标记缓解 | -| R4 | 低边调速导致 PWM 关断期间读不到转速 | **已接受**。若最终选 4 线 PWM 风扇,MOS + 续流管 + 分压可全删 | -| R5 | IF-5 的 100 µF MLCC 在 3.9 V 偏压下只剩 ≈40 µF,中频储能从 200 µF 降到 ≈80 µF | **待复核**。严格照原方案需换真聚合物 | -| R6 | 电感 SPM6530T-4R7M 的 Isat 6.2 A < 原定的 7 A | **已接受**,裕量分析见设计文档「已知遗留」 | -| R7 | BM24 仅 10 次插拔寿命 | 靠装配流程规避(C-6) | -| R8 | 手机主板机械固定方式(是否连中框)未定 | **不阻塞打样** | - ---- - -## 14. 需求 → 设计追溯表(速查) - -| 需求域 | 设计文档章节 | -|---|---| -| 规格 / 输入输出 | §1、§3 | -| 电压范围与本质安全 | §4(含 §4.4 失效态验算) | -| 三层保护链 | §5 | -| 瞬态阻抗与阻尼(C-3) | §6 | -| 断电重启与放电 | §7 | -| MCU 接口 / GPIO / I²C | §8、§26.18 | -| 选型与 BOM | §9 | -| PCB 与叠层 | §10、§25、§26 | -| 验收与调试 | §11、§14 | -| 手机侧接口 | §13 | -| USB 子系统 | §15、§18、§21 | -| 两板架构与机械 | §22、§25 | -| 散热 | §22.6 | -| EDL | §22.4 | -| **软件架构** | **设计文档里没有对应章节 —— 见本文 §10** | - ---- - -## 15. 术语 - -| 术语 | 含义 | -|---|---| -| **直供** | 拆掉电芯与保护板,从电池座直接供给受控电压 | -| **dipper** | 小米8 的代号 | -| **P+ / P−** | 电池保护板的**输出侧**焊点(本项目的接入点) | -| **B+ / B−** | 电芯侧焊点(**接错这里是本项目最隐蔽的装配错误**) | -| **eFuse** | 带可调过压 / 限流 / 缓启动的电子保险丝(TPS25940A) | -| **EDL / 9008** | 高通紧急下载模式 | -| **本质安全** | 靠拓扑与元件取值在物理上限死危险状态,而不是靠软件判断 | -| **下板 / 载板** | 下板 = 电源板;载板(上板)= 承载五块手机主板的板 | +# 五路手机直供电源板 —— 项目需求文档 + +| 项 | 值 | +|---|---| +| 文档版本 | **v1.2**(2026-09-06)—— 上位机链路定稿为**方案 C(WiFi 常态 + USB CDC 兜底)**,回填 §10.8,新增 D5a / SW-12 / IF-19 与两条验收
v1.1 —— 新增 **§10 软件架构设计**,原 §10~§14 顺延为 §11~§15 | +| 项目代号 | PhoneRail | +| 对应设计文档 | `五路直供电源板设计.md` v1.6 及其后续增补(§22 起的两板架构) | +| 状态 | 需求已冻结;硬件处于 PCB 布线阶段(见 `PROGRESS.md`) | + +> **本文档与设计文档的分工** +> 本文只回答 **「要做成什么样、凭什么算做到了」**; +> **「怎么做到」** 一律在 `五路直供电源板设计.md` 里,本文只给条目号交叉引用。 +> 两者冲突时:**电气数值以设计文档为准**(它是逐项算过并对过 datasheet 的), +> **需求的取舍与验收标准以本文为准**。 +> 设计文档 §0.1 的 13 条决策已定稿,本文把它们表述成需求,不重新讨论。 +> **唯一的例外是 §10 软件架构设计** —— 那是「怎么做」,但固件在设计文档里没有落脚点, +> 暂放本文,理由写在该节开头。 + +--- + +## 1. 背景与目标 + +### 1.1 要解决的问题 + +手机农场里成排运行的小米8(dipper)主板,用原装电池供电有三个绕不开的问题: + +1. **电池会老化、会鼓包**,几十台规模下变成持续的维护负担和安全隐患; +2. **手机卡死时没法远程恢复** —— 人必须跑到机架前拔电池 / 长按电源键; +3. **充电管理系统一直在工作**,长期 100% 电量反而触发系统的异常逻辑。 + +### 1.2 项目目标 + +做一块**电池位直供**的电源板:拆掉电芯与保护板,从主板电池座直接灌一个受控的、 +稳定在正常锂电范围内的电压;同时把「断电重启、按键模拟、进 Fastboot / 9008、 +USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU 远程执行。 + +**一句话验收目标**:五台小米8 主板挂在这套板子上连续跑 24 小时无异常重启, +任何一台卡死时都能在**不到机架前**的情况下恢复到正常运行状态。 + +### 1.3 目标用户与使用场景 + +| 用户 | 场景 | +|---|---| +| 农场运维 | 日常挂机;某台无响应时下发一次重启;查某台的电流曲线定位接触不良 | +| 刷机 / 开发 | 远程进 Fastboot 或 9008(EDL) 刷包;有线 adb 抓日志 | +| 本项目自身 | 首板调试、长稳测试、失效注入验证 | + +--- + +## 2. 范围 + +### 2.1 交付范围(In Scope) + +| # | 交付物 | +|---|---| +| D1 | **下板(电源板)**:12V 输入 → 五路 4 A 直供 + eFuse + 遥测 + ESP32-S3 控制 | +| D2 | **上板(载板)**:五块主板的机械承载 + 板载 USB Hub + VBUS 开关 + 侧键顶针 + EDL 口 | +| D3 | 两板对插的板对板接口(两条 2×20 排针,共 80 针) | +| D4 | 手机侧线束与离板电容组(电池排线改造、尾插 USB 线、EDL 引线) | +| D5 | ESP32-S3 固件(遥测、两级重启、按键 / EDL 模拟、VBUS 通断、风扇闭环、告警) | +| D5a | **双链路命令接口**(2026-09-06 用户拍板):**WiFi 为常态、USB CDC 为兜底**,两条链路共用同一套命令语义。上位机侧的调用方由农场既有系统实现(N7),本项目交付板侧协议与实现,见 §10.8 | +| D6 | 完整原理图(11 页)+ 两块 PCB + BOM + 首板调试与验收记录 | + +### 2.2 明确不做(Out of Scope) + +| # | 不做 | 理由 | +|---|---|---| +| N1 | 电池充放电管理、模拟真实电池阻抗 | 直供不需要,电芯已移除 | +| N2 | 支持 1S 以外的机型 / 非小米8 主板 | 全部机械与引脚定义都是 dipper 专用(§13) | +| N3 | 自制 USB-C 尾插转接板 | 决策 10:装原厂尾插小板,口温 NTC / CC / ESD / 天线馈点全在上面 | +| N4 | USB PD / BC1.2 协商,接快充充电器 | 决策 11:上游只接电脑 / Hub,VBUS 恒 5 V | +| N5 | 把过流保护做进 MCU 实时环路 | 那是 eFuse 的职责(SR-3) | +| N6 | 五路 Buck 错相同步 | §8.4 已否决:器件离散度远大于想制造的相位差 | +| N7 | 上位机管理软件 / Web 界面 | 本期只交付固件与命令接口,上层由农场既有系统对接 | + +--- + +## 3. 系统组成(需求视角) + +```diagram=system +12V/10A 适配器 + │ + ┌────┴─────────────── 下板(电源 + 大脑)────────────────────┐ + │ 输入保护 → 五路独立 Buck → eFuse → 分流检测 → 输出 │ + │ ESP32-S3 + DAC ×2 + INA226 ×6 + 74HC595 ×3 + PCA9555 │ + │ 侧键驱动管 ×15 · 风扇驱动 · 辅助 3V3 / 5V │ + └────┬──────────── 2×20 排针 ×2(无高速信号)─────────────────┘ + ┌────┴─────────────── 上板(载板)────────────────────────────┐ + │ 5 × 小米8 主板(NPTH 定位孔 + 尼龙立柱) │ + │ USB2517I 7 口 Hub + 上游 USB-C + VBUS 负载开关 ×5 │ + │ 侧键顶针 ×15 · EDL 模拟开关 ×5 · P+/P− 焊盘 + 电容组 ×5 │ + └──────────────────────────────────────────────────────────────┘ +``` + +两板之间保留 **13 mm 缝**,既是装配空间也是散热风道。 + +--- + +## 4. 功能需求(FR) + +| ID | 需求 | 优先级 | 设计出处 | +|---|---|---|---| +| **FR-1** | 同时为 **5 台**小米8 主板供电,各路**电气独立**(独立回流地,不在手机端共地) | 必须 | §1 / §6.4 | +| **FR-2** | 每路输出电压可**独立微调**,调节在正常锂电范围内进行,步进优于 1 mV | 必须 | §4 | +| **FR-3** | 每路可**独立硬断电并主动放电**,实现真正意义上的「拔电池」 | 必须 | §7 | +| **FR-4** | 每台手机可模拟 **3 个侧键**(PWR / VOL+ / VOL−),支持组合键 | 必须 | §13.3 | +| **FR-5** | 每台手机可远程进入 **EDL(9008)** 模式 | 必须 | §22.4 | +| **FR-6** | 每台手机的 **USB VBUS 可独立通断**,默认断开 | 必须 | §15.1 | +| **FR-7** | 对外只用**一根 USB 线**,五路下行由板载 Hub 分 | 必须 | §15.1 | +| **FR-8** | 每路可实时读取**输出电压与电流**;整板可读取**输入电压与电流** | 必须 | §8.2 | +| **FR-9** | 上报每路 eFuse 故障、电源好(PG)等状态 | 必须 | §8.2 | +| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」 | 必须 | §22.6 | +| **FR-11** | 记录过流 / 故障事件(哪一路、何时、多大电流) | 高 | §8.3 | +| **FR-12** | 模拟电量曲线,避免系统因「电量永远 100%」触发异常逻辑 | 中(可选) | §8.3 | +| **FR-13** | 板级温度监控,超阈值先降流再关路 | 高 | §8.3 | +| **FR-14** | 整板功率预算,超过适配器能力时按优先级降载 | 中 | §8.3 | +| **FR-15** | 调试口不依赖外部转串口芯片,插上 USB-C 即可下载与看日志 | 必须 | §8.1 | + +### 4.1 崩溃恢复流程(FR-3 / FR-4 的组合,本项目的核心价值) + +固件必须实现**两级重启**,且**先软后硬**: + +| 级 | 动作 | 触发条件 | 要求 | +|---|---|---|---| +| 一级(软) | 长按电源键 ≈10 s | `adb` 探活连续失败 N 次,或电流曲线卡死 | 优先使用,对闪存和文件系统友好 | +| 二级(硬) | `EFUSE_EN` ↓ → 放电 → 等 3~5 s → `EFUSE_EN` ↑ | 一级无效 | 输出必须真的掉到 <0.5 V,不能靠自然放电 | + +> **为什么二级必须有主动放电**:手机侧 2200 µF 配 ≈50 µA 关机待机电流,自然放电到安全 +> 电压要 **154 秒**,「断电 10 秒」根本不触发重启。见 §7.1。 + +--- + +## 5. 电气与性能需求(PR) + +| ID | 参数 | 要求 | 出处 | +|---|---|---|---| +| **PR-1** | 输入电压 | 12 V ±10%,成品适配器 | §1 | +| **PR-2** | 输入电流 | ≤ 8 A(含风扇 ≈8.3 A),建议配 12 V / 10 A(120 W) | §1 / §22.6 | +| **PR-3** | 输出路数 | 5,彼此独立不共地 | §1 | +| **PR-4** | 输出电压范围 | **3.52 ~ 4.01 V** 连续可调 | §4.3 | +| **PR-5** | **硬件电压上限** | 标称 **4.01 V**,最坏容差 **4.12 V**,**软件无法越过** | §4.4 | +| **PR-6** | 电压设定分辨率 | 12 位 DAC 微调,步进 ≈0.12 mV | §4.5 | +| **PR-7** | 输出电流 | 4 A / 路(峰值);手机常态 0.3 ~ 1 A | §1 | +| **PR-8** | 输出纹波 | < 20 mVpp(板端测试点) | §1 | +| **PR-9** | 开关频率 | 480 kHz,五路统一,不错相 | §1 / §8.4 | +| **PR-10** | 整板功率 | 峰值 80 W(不会发生);常态 10 ~ 25 W | §1 | +| **PR-11** | 电流测量分辨率 | ≥ 0.5 mA(5 mΩ 分流 + Kelvin 四线) | §8.2 | +| **PR-12** | 负载阶跃响应 | 0→4 A 阶跃(带 20 cm 线 + 电容组)压降 < 300 mV,**无 6 kHz 振铃** | §6.2 | +| **PR-13** | 满载温升 | 4 A 连续 30 min,电感 / IC 温升 < 40 K | §11 | +| **PR-14** | 断电放电时间 | `EFUSE_EN` ↓ 后 **200 ms 内**输出 < 0.5 V | §7.2 | +| **PR-15** | 上电斜坡 | 缓启动 0.36 V/ms,涌流 ≤ 0.8 A;`EN`↑ 到稳定 ≈17 ms | §5 | + +### 5.1 输出上限为什么定在 4.01 V(这条需求的来源) + +去掉原厂保护板后,输出上限**由 1S 锂电端子的绝对上限 4.45 V 反推**,而不是由保护板决定。 +4.01 V 这个数字不是「够用就行」,是为了**给 eFuse 的过压跳闸窗口让开 86 mV** —— +若按早期的 4.22 V,第一层的最坏输出(4.34 V)会和 eFuse 的最坏跳闸下沿(4.206 V)重叠, +坏角落下 DAC 失效会**误跳保护**。详见 §5 的容差窗口分析。 + +**因此 PR-4 / PR-5 是安全需求的一部分,不得为了「多给一点电压」而放宽。** + +--- + +## 6. 安全需求(SR)—— 本项目最高优先级 + +> **必须记录的失效模式**:直供电最致命的失效**不是固件写错电压值**,而是 +> **反馈分压的下电阻开路 → FB = 0 V → Buck 满占空比 → 输出直接跳到 12 V**。 +> **软件对此完全无能为力。** 全部安全需求都要围绕这一条来读。 + +| ID | 需求 | 出处 | +|---|---|---| +| **SR-1** | **三层保护彼此独立**:任何一层单独失效都不得导致手机损坏 | §5 | +| **SR-2** | **第一层 · 拓扑限幅**:分压网络与 DAC 量程在物理上把输出限死在 3.42 ~ 4.12 V(含最坏容差),全部落在正常锂电电压内。防固件 bug / I²C 误码 / MCU 跑飞 | §4 | +| **SR-3** | **第二层 · eFuse 独立过压断开**(每路一颗,**最后一道硬件防线**):OVP 4.36 V 标称、**断开而非钳位**、响应 2 µs;限流 4.45 A 恒流、不依赖 MCU | §5 | +| **SR-4** | eFuse 的过压响应**必须来得及**:按最坏值(Buck 高侧限流 14 A、负载 2200 µF)核算,余量 ≥ 10 倍 | §5 | +| **SR-5** | **第三层 · 软件监控**:电压偏离设定 >±100 mV 且持续 20 ms → 拉低该路 EN 并上报。**仅承担记录与告警,不承担实时保护** | §5 | +| **SR-6** | **复位安全**:ESP32 复位 / 上电期间,五路输出全关、五路 VBUS 全断、15 路按键全不按、5 路 EDL 全不触发、风扇不转。靠**上下拉电阻**保证,不靠固件 | §8.1 / §22.4 | +| **SR-7** | 放电 MOS 与 eFuse EN **硬件反相联锁**,物理上不可能同时导通 | §7.3 | +| **SR-8** | **输入保护**:保险丝 + TVS + P-MOS 反接保护 | §2 | +| **SR-9** | **12 V 母线泄放**:母线 >13.2 V 时导通泄放,防 USB 反灌或突卸能量抬高母线 | §15.2 | +| **SR-10** | **USB 反灌三条对策全做,一条都不能省**:① 软件关充电 ② VBUS 负载开关 ③ 母线泄放 | 决策 12 / §13.5 | +| **SR-11** | 上游 USB 不做 PD 协商(CC 双 5.1 kΩ 下拉,纯 UFP),杜绝 VBUS 被抬到 9 / 12 V | §15.1 | +| **SR-12** | 五台手机的地**不得**在载板上并成一根:定位孔做 **NPTH**,立柱用**尼龙** | §25.2 | +| **SR-13** | 固件**不得**提供任何能让输出超过 4.2 V 的接口 | §8.3 | +| **SR-14** | 任一路输出**不得**给 ESP32 供电(用独立的 12 V→3.3 V 小 Buck) | §8.3 | +| **SR-15** | 电源**必须**接原厂排线的 `P+`/`P−`(保护板输出侧),**不是** `B+`/`B−` | 决策 6 / §13.5 | + +> ⚠️ **SR-15 是最容易在装配环节做错、且常温下完全测不出来的一条。** +> 焊在 `B+`/`B−` 时一切表现正常,只有过压时才暴露 —— 那时保护板仍串在通路里。 +> 判别与验证步骤见 §13.5 / §13.7,已列为打样前阻塞项(V-1)。 + +> ⚠️ **`J600` 的 `GND3`/`GND4` 固定脚实际接的是 VBATT,`J1401` 的 `GND2` 是 USB_VBUS。** +> 按名字当地用会直接短路电池。 + +--- + +## 7. 接口需求(IF) + +> **编号只增不改**:新增接口一律取下一个空号,位置按逻辑归类 —— +> 所以表内序号不一定连续(如 IF-19 排在 IF-14 后面)。ID 是稳定的引用标识, +> 不是目录序号;改动已有编号会让所有交叉引用与验收记录失效。 + +### 7.1 手机侧接口(每台一套) + +| ID | 接口 | 要求 | 出处 | +|---|---|---|---| +| **IF-1** | 供电 | 原装 BM3E 电池排线的 `P+`/`P−` 焊点;保留 BTB 公座 / NTC / ID,**旁路保护板**;电芯移除 | §13.2 / §13.5 | +| **IF-2** | 侧键 | J601(VOL+) / J602(VOL−) / J603(PWR) / J604(公共地),**对地短接**方式,绝不从外部灌电压 | §13.3 | +| **IF-3** | EDL | 短接主板上的 TP1204 ↔ TP1205(1.8 V / 1 mA),用**模拟开关**,不用普通 N-MOS | §22.4 | +| **IF-4** | USB | 装**原厂尾插小板**,本板只接 VBUS / D± / GND 四根 | 决策 10 / §15.1 | +| **IF-5** | 离板电容组 | 每路 2200 µF **普通铝电解**(靠 ESR 阻尼)+ 100 µF ×2 + 22 µF ×2 + 100 nF,**必须装在排线端,不在电源板上** | §6.3 | +| **IF-6** | 线缆 | ≤ 20 cm、18 AWG 或更粗、正负双绞或紧贴平行;板端用 Micro-Fit 3.0 / XT30,**不用杜邦线** | §6.4 | + +> ⚠️ **IF-5 的 2200 µF 千万不能买成聚合物电容。** 整个阻尼设计靠的就是普通铝电解那 +> 几十 mΩ 的 ESR;换成 12 mΩ 的聚合物,Q 从 0.19 跳回 4.5,线缆 LC 会在 6.2 kHz 上振铃。 + +> ⚠️ **IF-5 的大电容必须在排线端。** 放在电源板上等于隔着 300 nH 的线缆电感,基本无效。 + +### 7.2 板对板接口 + +| ID | 需求 | 出处 | +|---|---|---| +| **IF-7** | 两条 2×20 排针(共 80 针),分列板子两端 | §22.2 / §25.10 | +| **IF-8** | **一根高速差分都不穿连接器** —— Hub、上游 USB-C、VBUS 开关全部上移到载板 | §22.1 | +| **IF-9** | 排针只走电源与低速控制;每路 `P+`/`P−` 独立分配针位,保证独立回流(FR-1) | §22.2 | +| **IF-10** | 两板间净空 13 mm;载板底面元件与下板高元件的高度预算必须逐点核过 | §26.2 | + +### 7.3 对外接口 + +| ID | 接口 | 要求 | +|---|---|---| +| **IF-11** | 电源输入 | DC 12 V 插座(XT30 级),配 10 A 适配器 | +| **IF-12** | 上游 USB | 载板 USB-C(UFP),一根线接电脑 | +| **IF-13** | 调试 USB | 下板 USB-C,ESP32-S3 原生 USB-Serial-JTAG;**VBUS 故意不接**(物理上杜绝反灌 3V3)。**同一个口兼作 SW-12 的兜底命令链路**(原生 USB-Serial-JTAG 本身就是 CDC-ACM,不影响 esptool 下载),分帧要求见 §10.8 | +| **IF-14** | 风扇 | 5 × KF2510-3A(+12 V / 回流 / TACH) | +| **IF-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时先用 IF-13 的 USB 链路配网 | + +> ⚠️ **IF-12 与 IF-13 是两个长得一样的 USB-C,插错的现象是「adb 整排不见」**, +> 很难联想到是插错口。**丝印必须写清楚**,建议用不同颜色座子或在外壳上做标记。 + +### 7.4 内部数字接口 + +| ID | 需求 | +|---|---| +| **IF-15** | I²C 单条总线挂 10 个器件:INA226 ×6(0x40/41/44/45/42/43)、DAC63204 ×2(0x4A / 0x4B)、PCA9555(0x20)、LM75BD(0x48),地址互不冲突 | +| **IF-16** | ⚠️ **INA226 的通道号 ≠ 地址低位**(为布线让路重排过),固件必须**查表**,不能算 | +| **IF-17** | 74HC595 ×3 级联成 **24 位**移位链:15 路侧键 + `HUB_RST` + 5 路 `EDL_EN`;**先移入的字节落在 U33 上** | +| **IF-18** | `EFUSE_EN` ×5 与 `VBUS_EN` ×5 **必须 GPIO 直连**,不得经移位寄存器 —— 安全关键,故障瞬间要能立刻关断 | + +--- + +## 8. 机械与热需求(MR / TR) + +| ID | 需求 | 出处 | +|---|---|---| +| **MR-1** | 载板 220 × 135 mm,四角 R5;五块主板 **3+2 双排面对面**,第二排居中 | §25.1 | +| **MR-2** | 下板 160 × 125 mm,装配时相对载板原点偏移 (30, 5) | §25.2 | +| **MR-3** | 每台手机 7 个定位孔,全部 **NPTH Φ2.5 + 尼龙立柱**(见 SR-12) | §25.2 | +| **MR-4** | 侧键用顶针接触,顶针焊盘必须避免把相邻触点短路 | §25.3 | +| **MR-5** | 排线穿槽:电池排线 8×38 → 槽 10×3.0;尾插排线 18×55 → 槽 20×3.0(在 J1401 正下方) | §25.16 | +| **TR-1** | 总热负荷 ≈25 W(5×SDM845 满载 10~15 W + 下板功率级 ≈9 W) | §22.6 | +| **TR-2** | 强制风冷 **4 进 1 出 = 机箱正压**(灰尘往外走);两板间 13 mm 缝作天然风道 | §22.6 | +| **TR-3** | 所需风量仅 ≈2.9 CFM,**风量不是瓶颈,风道与正压才是设计要点** | §22.6 | +| **TR-4** | 风扇 5 × 12 V ≈0.70 A;低边 N-MOS + PWM,漏极**必须并续流二极管** | §22.6 | +| **TR-5** | 风扇闭环的测温点**放载板**(测的是手机),不能用下板功率级的两路 NTC 做反馈 | §22.6 | +| **TR-6** | TACH 必须分压后再进 GPIO;⚠️ 低边调速下 **PWM 关断期间读不到转速**,固件需在导通窗口采样 | §22.6 | + +--- + +## 9. 固件需求(SW) + +> 本节只列「固件要做到什么」。**怎么组织成一份固件见 §10 软件架构设计。** + +### 9.1 必做 + +| ID | 需求 | +|---|---| +| **SW-1** | 崩溃检测 + 两级自动重启(§4.1)—— **整块板子最核心的价值** | +| **SW-2** | 20 ms 周期巡检六路 INA226,电压偏离 >±100 mV 持续 20 ms 即关路告警(SR-5) | +| **SW-3** | 过流 / 故障事件记录:路号、时刻、电流值 —— 排查排线接触不良的唯一线索 | +| **SW-4** | 单路电压微调接口(跑不稳的手机提到 4.0 V,或反向降压降温),**上限受 SR-13 约束** | +| **SW-5** | 侧键序列:短按 100 ms 唤醒 / 长按 2 s 开机 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot / VOL++PWR 进 Recovery | +| **SW-6** | EDL 时序**固定为** `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0`(先短接再插 USB) | +| **SW-7** | 开机脚本执行「关充电」(写 `input_suspend` 类节点),路径需在首台机上确认后写死 | +| **SW-8** | USB 反灌保护:检测到该路电流转负或母线上升,**200 ms 内**断开该路 VBUS | +| **SW-9** | 风扇 PWM 闭环(以载板 LM75BD 为反馈)+ TACH 停转告警 | +| **SW-10** | 整板功率预算:读输入侧 INA226,超适配器能力时按优先级降载 | +| **SW-11** | 上电默认全关:五路输出关、VBUS 断、按键不按、EDL 不触发 | +| **SW-12** | **双链路命令接口**:WiFi 为常态、USB CDC 为兜底,两条同时在线、共用同一套命令语义与同一个命令处理器;命令带全局序号做幂等判重。链路失联**不触发任何动作**。架构见 §10.8 | + +### 9.2 明确禁止 + +| ID | 禁止事项 | 理由 | +|---|---|---| +| **SW-N1** | 让 ESP32 参与过流保护的实时环路 | 那是 eFuse 的活,MCU 不够快也不够可靠 | +| **SW-N2** | 提供任何能让输出超过 4.2 V 的接口 | SR-13 | +| **SW-N3** | 烧写 `DIS_USB_JTAG` eFuse | 烧了之后引脚 JTAG 和 USB 两边都进不去,板上没有 JTAG 排针 | +| **SW-N4** | 把 INA226 通道号当地址算 | IF-16 | + +--- + +## 10. 软件架构设计 + +> **这一节为什么在需求文档里**:本文原则上只讲「做成什么样」,但**固件在设计文档里没有 +> 落脚点** —— 设计文档 §8.3 只有一张按价值排序的功能清单,没有结构。在补出一份独立的 +> 软件设计文档之前,架构先放这里,与 §9 的固件需求贴在一起看。 +> 硬件的「怎么做」仍然一律在设计文档。 + +### 10.1 分层与职责边界 + +```diagram=layers +┌─ 农场管理系统(既有,本项目不交付 —— N7)────────────────────────┐ +│ adb 探活 / 业务调度 / 崩溃判定 │ +└───────┬────────────────────────────────┬──────────────────────────┘ + │ adb over WiFi(业务面) │ 控制面(10.8,⚠️ 链路待定) + ▼ ▼ + 5 台小米8 主板 ┌─────────────────────┐ + ▲ │ PhoneRail 控制器 │ + │ 供电 / 侧键 / EDL / VBUS │ ESP32-S3 固件 │ + └──────────────────────────┤ 遥测 · 执行 · 记录 │ + └──────────┬──────────┘ + │ 只能建议,不能否决 + ┌───────────────▼───────────────┐ + │ 硬实时层:eFuse OVP / 限流 │ + │ 放电 MOS 反相联锁 / 上下拉 │ + │ —— 不含任何软件(SR-3、SR-7) │ + └───────────────────────────────┘ +``` + +**一条边界决定了下面所有取舍**:固件是**遥测 + 执行器 + 记录仪**,不是保护装置 +(SR-5、SW-N1)。凡是「必须及时」的事都已经在硬件里了;固件里没有任何一条路径 +是「不跑到就会烧手机」的。因此: + +- 任务优先级不需要为保护让路,只需要保证遥测不被执行器序列饿死; +- 看门狗复位是**可接受的失效**(复位即全关,SR-6),不需要为了「保持输出」而弱化看门狗; +- 固件可以随时被重启、被降级运行,硬件安全性不变。 + +### 10.2 架构的第一个判断:失联 ≠ 断电 + +| 状态 | 输出该怎么办 | 为什么 | +|---|---|---| +| ESP32 复位 / 上电中 | **全关**(硬件下拉保证,SR-6) | 状态未知,且此刻手机本来就没在跑 | +| 与上位机失联 | **保持供电不动** | 五台手机正在跑业务;因为控制链路断了就把它们全断电,是把一次网络故障放大成一次全场停机 | +| 本地判据触发(SR-5 / SW-8 / 过温) | **关对应那一路** | 已知的危险,且范围可控 | + +⚠️ 这三行看起来像常识,但它们互相矛盾过一次:**「安全默认值 = 关」只对未知状态成立**。 +失联是已知状态,此时最安全的动作是「什么都不做」。固件里不要写「心跳超时就关输出」。 + +**推论**:失联期间固件必须能独立运行全部本地职责 —— 巡检、风扇闭环、事件记录、 +按队列执行未完成的序列。上位机回来之后靠事件日志补齐时间线(10.7)。 + +### 10.3 任务划分(FreeRTOS / ESP-IDF) + +| 任务 | 周期 | 优先级 | 职责 | 失败后果 | +|---|---|---|---|---| +| `t_safety` | **20 ms** | 最高 | 读 6 片 INA226(V+I)与 PCA9555 输入;执行 SW-2 / SW-8 判据;越界直接操作 `EFUSE_EN` / `VBUS_EN`(**GPIO 直连,不经任何队列或抽象层**) | 告警延迟;保护本身不受影响(eFuse 是硬件) | +| `t_thermal` | 1 s | 中 | 两路 NTC(下板功率级)+ 载板 LM75BD(手机侧)→ 风扇 PWM 闭环;过温先降流后关路(FR-13、TR-5) | 风扇维持上一个占空比 | +| `t_actuator` | 事件驱动 | 中 | 每路一个状态机:断电重启 / 按键序列 / EDL 序列(10.6) | 序列停在中间 —— 必须有超时兜底 | +| `t_link` | 事件驱动 | 低 | 上位机命令解析与遥测上报(10.8) | 见 10.2:不影响本地职责 | +| `t_log` | 空闲 | 最低 | 环形缓冲落盘(10.7) | 丢日志,不丢功能 | + +**共享资源只有两个**,各配一把互斥锁: + +1. **I²C 总线** —— 10 个器件在同一条上(IF-15),其中 LM75BD 还在排针另一侧的载板上。 +2. **74HC595 的 24 位链** —— 全板共享,见 10.6 的影子寄存器。 + +**I²C 带宽核算**(400 kHz,`t_safety` 是唯一的高频用户): + +``` +一次 INA226 寄存器读 ≈ 46 个位周期 ≈ 115 µs +6 片 × 2 个寄存器(总线电压 + 分流电压) = 12 次 ≈ 1.4 ms +PCA9555 读两个输入端口 ≈ 0.12 ms + 合计 ≈ 1.5 ms / 20 ms = 约 8 % 占用 +``` + +余量充足,`t_thermal` 和偶发的 DAC 写完全挤得下。**首板上电时用逻辑分析仪实测复核一次** +——这个数是按理想时序算的,没算时钟延展和重试。 + +> **A8 已落地**(2026-09-07,`firmware/main/{tasks,sched,t_safety}.c` + +> `components/phonerail_hal/{include/hal_lock.h,hal_i2c_acct.c}`)。五条实现约定: +> +> 1. **五个任务是一张 const 表**(`tasks.c`),周期与优先级写在一起,开机 `task_table_ok()` +> 自检,不过就 abort。查的是**相对顺序**:safety 唯一最高、log 唯一最低、 +> thermal 与 actuator 同级、link 夹在中间;外加「巡检周期必须长于 INA226 的 +> 一帧转换 8.8 ms」,否则同一帧被读两次,现象是「电流特别稳」。 +> 2. **周期是间隔,不是延时。** `sched_periodic_*()` 记的是下一次该醒的**绝对时刻** +> (回绕安全,一律用 `int32_t` 差判先后)。写成「干完睡 20 ms」的话, +> 实际周期是 21.6 ms 且随总线忙闲抖动。过载时**跳过错过的整周期、保住相位**, +> 不补跑 —— 补跑的现象是「卡一下之后连着打满总线」。 +> 3. **两把锁不许嵌套持有。** 595 是纯 GPIO、I²C 是纯总线,两者没有交集, +> 所以「拿着 A 去拿 B」永远是写错了,`hal_lock_take()` 直接判 `HAL_ERR_STATE` +> ——与其定一套加锁顺序再指望人人记得,不如让它不可能发生。ESP 侧用的是 +> **mutex 不是 binary semaphore**(要优先级继承:t_link 拿着总线时 +> t_safety 不能被中优先级任务无限期挡住)。 +> 4. **巡检不死等锁**:等 5 ms(周期的 1/4)拿不到就跳过这一圈并计数。 +> 死等的现象是最高优先级的任务整个停摆,而且没有任何人会发现。 +> 同样地,**一个器件读失败不中断这一圈**,失败那一路保留上一圈的值并把 `ok` 标 false +> ——清零的话 A10 的过压判据会看到一个「正常」的 0 V。 +> 5. **那 8 % 是算出来的,不是抄的。** `hal_i2c_acct` 在 HAL 里按线上真实字节数记账 +> (两个目标同一份代码),`t_safety_budget_us()` 按事务形状静态预算,两者必须相等。 +> 实测:一次寄存器读 **48** 个位周期(本节手算的 46 没数 STOP 和一个重复 START), +> 一圈 13 次事务 = **1560 µs / 20 ms = 7.8 %**,与本节的「约 1.5 ms / 约 8 %」相符。 +> 多读一次寄存器就会让静态预算与实测对不上,测试当场变红。 +> 首板仍要按本节末尾那句用逻辑分析仪复核一次(这个数不含时钟延展与重试)。 + +### 10.4 五路一张表:禁止用通道号算任何东西 + +这是**本项目最容易写出「看起来对、实际错一路」的地方**。三组编号都不是逻辑顺序: + +- **INA226 地址**按布线难度重排过(IF-16,设计文档 §8.2); +- **74HC595 的位序**按 PCB 几何排(设计文档 §17.2 / §22.4); +- **GPIO 编号**按出线方向倒推(设计文档 §26.18)。 + +所以固件里**只允许有一张静态配置表**,全部五路循环都索引它: + +```c +typedef struct { + uint8_t ina_addr; /* 0x40 0x41 0x44 0x45 0x42 —— 查表,不能算 */ + uint8_t dac_chip; /* 0x4A: CH1-3 0x4B: CH4-5 */ + uint8_t dac_ch; /* U6.OUT0/1/2 U7.OUT0/1 */ + gpio_num_t efuse_en; /* GPIO 直连 */ + gpio_num_t vbus_en; /* GPIO 直连 */ + uint8_t key_bit[3]; /* PWR / VUP / VDN 在 24 位链里的位号 */ + uint8_t edl_bit; /* U33 上的位号 */ + uint8_t flt_bit; /* PCA9555 IO0_0..4 */ + uint8_t pg_bit; /* PCA9555 IO1_0..4 */ +} rail_cfg_t; +``` + +> ⚠️ **`ina_addr = 0x40 + ch` 这行代码一定会被写出来,而且只有 CH3/CH4/CH5 是错的。** +> 实际地址是 `0x40 / 0x41 / 0x44 / 0x45 / 0x42`。查表,不要算。 + +> **A3 已落地**:表在 `firmware/components/drv/include/ina226.h`,取值只走 +> `ina226_rail_addr(rail)`(rail 是 1~5,越界 abort), +> `test_channel_index_is_not_the_address_low_bits` 专门拦「改回算式」这一手。 +> 同处还记了两条 INA226 的实现约定:**寄存器指针不自增**(每次读前重写指针, +> sim 会自增、真件不会,靠自增在宿主机上测不出来);**Calibration 丢了不报错、 +> 只让 Current/Power 恒读 0**,所以巡检要定期 `ina226_check()`。 + +同理,**PCA9555 的方向寄存器只能配成全输入**:备用脚 Port0[7] / Port1[7:5] 在硬件上 +直接接地,配成输出并写 1 就是对地短路(设计文档 §17.2 已在图纸上注明)。 + +> **A6 已落地**:驱动在 `firmware/components/drv/include/pca9555.h`, +> **根本不提供「把某个脚配成输出」的接口**(和 sr595.h 不提供「抬 OE#」同一条规矩)。 +> 方向字一律过 `pca9555_dir_safe()`,不安全直接 abort。 +> 同处还记了四条这一片的实现约定: +> ① ⚠️ **Output Port 寄存器的复位值是 0xFF** —— 所以把接地脚配成输出的那一刻它就 +> 输出高电平,中间没有「先写个 0 再说」的余地,这比「配错方向」本身更急; +> ② **极性反转寄存器必须无条件清零**,不能赌复位值:看门狗只复位 ESP32, +> PCA9555 不掉电,上一条命的残留值原样留着,而写错的现象是「五路同时报故障」 +> 或者更糟的「五路故障全都看不见」,I²C 一切正常; +> ③ **三种极性**(FLT# 低有效 / PGOOD 高有效 / INA_ALERT 低有效)各自只在 +> `pca9555_rail_fault()` 等四个函数里出现一次,上层不许自己去 & 位; +> ④ **INT# 是加速路径不是主路径**:它要读走输入端口才释放,漏读一次就再也等不到 +> 下一个沿,所以 `t_safety` 必须无条件轮询(§10.3 的 20 ms 就是按这个排的)。 +> +> ⚠️ **FLT 与 PG 两张位表这次确实是顺排的**(都是 `bit = rail - 1`), +> 和 INA226 地址、595 位号那两张表不一样。所以这里最容易写出来的错不是「算式」, +> 而是**把 Port0 和 Port1 搞反** —— 两个端口的 bit0~4 长得一模一样,搞反之后 +> 「有故障」会被读成「电源良好」,方向正好相反,而且**静息态两个端口读回来都是 +> 全 1,单看一帧完全看不出来**。`test_one_rail_at_a_time` 专门拦这一手。 + +> **A7 已落地**(2026-09-07):那张 `rail_cfg_t` 表在 `firmware/main/rail_cfg.h` / +> `rail_cfg.c`,取值只走 `rail_cfg(rail)`(1~5,越界 abort)。六列的样子: +> +> | CH | INA226 | DAC | 按键位 PWR/VUP/VDN | EDL 位 | EFUSE_EN | VBUS_EN | +> |---|---|---|---|---|---|---| +> | 1 | 0x40 | U6.OUT2 | 1 / 2 / 3 | 16 | IO39 | IO40 | +> | 2 | 0x41 | U6.OUT0 | 19 / 17 / 18 | 7 | **IO48** | IO10 | +> | 3 | 0x44 | **U7**.OUT0 | 22 / 21 / 20 | 6 | IO6 | IO11 | +> | 4 | 0x45 | U6.OUT1 | 9 / 10 / 11 | 0 | IO41 | IO42 | +> | 5 | 0x42 | U7.OUT1 | 15 / 14 / 13 | 5 | IO8 | IO17 | +> +> 落地时定下来的五条: +> ① **本表与四个驱动那四张表是「两处写同一个数」,所以要对账而不是现算。** +> 开机自检 `rail_selftest()` 逐格拿本表和 `ina226_rail_addr()` 等取值函数比一遍, +> 对不上就报 `RAIL_SELFTEST_TABLE`。做成「开机从驱动现算」的话按定义就查不出分家, +> 而且表不再是 const 的、用之前得先记着初始化。 +> ② **判据本身也要被测**:真表是 const 的,测试改不动它,所以判据单拎成 +> `rail_selftest_table(t)`,测试喂几张故意写坏的表进去看它认不认 +> (分家 / 引脚重复 / 撞 SDA / strapping / 不存在的号 / 越界,六种都验过)。 +> ③ **两列 GPIO 是全固件唯一一处出处**,没有第二张表能对账 —— 所以 +> 「`EFUSE_EN1` 和 `VBUS_EN1` 写反」这种错自检**查不出来**(两个都是合法空闲脚、 +> 不重复、不越界)。唯一的拦网是 `test_en_pins_match_the_pcb_netlist` 里钉死的 +> 十个值(出处:`.claude/pcb1-parts.json` 的 U20 焊盘网络)和 B1 上板实测。 +> ④ **「不是顺排」要看整列,不能逐格撞算式**:CH4 的按键位正好是 9/10/11、 +> CH1 的 EDL 位正好是 16、CH3 的两根 EN 正好等于「IO4 起 / IO9 起」那个算式 —— +> 单看任何一路都「像顺排」。测试改成断言整列**不是等差数列**。 +> ⑤ ⚠️ **HAL 两侧的 GPIO 上限原来是 `pin < 48`,把 IO48 关在了门外**, +> 而 `EFUSE_EN2` 就在 IO48 上 —— 现象是 CH2 的 eFuse 既开不了也**关不掉**。 +> 已改成 0~48(含)。ESP32-S3 的编号是连续的但芯片不是:IO22~IO25 不存在、 +> IO26~IO32 是模组内部 flash/PSRAM,写进配置表 `gpio_config()` 不报错也不干活, +> 所以自检把这些号也当成越界。 +> +> 同处还落地了**全板引脚表** `firmware/main/board_pins.h`(§8.1 那张表里 +> 「全板独一份」的那些脚)与两根 EN 线的表驱动遍历 +> (`rail_en_init()` / `rail_efuse_enable()` / `rail_vbus_enable()` / `rail_all_off()`)。 +> `rail_all_off()` **先断五路 VBUS 再关五路 eFuse**,中间不留「手机只靠 USB +> 供着电」的窗口;十个脚全部试一遍,不因为某一个失败就提前返回。 +> 高有效这件事只在 `en_write()` 里出现一次。 + +### 10.5 电压通道:DAC 只做微调,而且是反向的 + +| 事实 | 含义 | +|---|---| +| `Vd = 0 V` → 输出 **4.010 V**(上限) | **DAC 写得越大,输出越低** | +| `Vd = 0.548 V` → 输出 3.90 V(NVM 出厂默认) | 上电即安全值,不依赖 ESP32 初始化成功 | +| `Vd = 2.424 V`(满量程)→ 输出 **3.524 V**(下限) | 满量程是 2.424 V,不是 2.5 V(内部基准 1.212 V ×2) | + +> ⚠️ **反向关系是本节最容易写反的一条。** 写反的现象不是报错,而是 +> 「调高电压的命令把手机调到了 3.52 V」——手机照样能开机,只是更容易在负载尖峰下掉电, +> 而且**要跑一阵子才看得出来**。固件里把这一步封成 `rail_set_mv()` 单一入口, +> 内部做 mV → Vd → code 的换算,**不要在别处直接写 DAC 码值**。 + +三条固件规矩: + +1. **越界即拒绝**:`rail_set_mv()` 收到 >4010 mV 的请求直接返回错误并记事件(SR-13)。 + 注意这是**第四道**限制,前三道在硬件里 —— 它的作用只是让操作者早点知道自己写错了。 +2. **不用 DAC 做保护**:任何时候都不要靠「把电压调低」来处理异常,那是 `EFUSE_EN` 的活。 +3. **出厂标定**:分压网络与 DAC 基准都有 ±1 % 级的容差,用该路 INA226 读回实际输出, + 做一次两点线性标定存进 NVS。标定值缺失时退回理论公式(误差约 ±40 mV,可用)。 + +> **A4 已落地**:换算与设定在 `firmware/main/rail.c`,唯一入口 `rail_set_mv(rail, mv)` +> (rail 是 **1~5**)。三个验收点各有一条测试:4020 mV 被拒、码大电压低、3900 mV ↔ Vd 0.548 V。 +> 标定(本节规矩 3)**还没做**,现在走的是理论公式;`RAIL_VOUT0_UV` / `RAIL_DAC_SPAN_UV` +> 两个常量就是将来要被标定值顶替的地方。 +> +> 同处记下三条 DAC63204 的实现约定(出处 SLASEX3A): +> - **12 位码在 `DAC-X-DATA`(19h~1Ch) 里是左对齐的**(bit15-4)。忘了 `<< 4` 不报错, +> 只让输出变成 1/16 —— 而在本板上那表现为「五路都是 4.010 V 满电」,看不出异常。 +> - **`EN-INT-REF` 要先于增益设定写**,且**上电安全码要写在通道上电之前**: +> 复位码是 0,而 Vd = 0 就是输出上限 4.010 V。`dac63204_init()` 因此把 +> `COMMON-CONFIG` 写两次(先按住输出,写完码再上电)。 +> - **通道分配不是 `rail−1`**(设计文档 §26.18 重排过,五路里四路对不上): +> CH1→U6.OUT2 / CH2→U6.OUT0 / CH3→**U7**.OUT0 / CH4→U6.OUT1 / CH5→U7.OUT1。 +> 四通道电气对等 ⇒ 接错了照样工作,只是调错路,功能测试测不出来。 + +### 10.6 执行器:三个状态机 + 一个影子寄存器 + +**每路三个序列,彼此互斥、不可重入**(同一路上同时只能跑一个): + +| 序列 | 时序 | 关键约束 | +|---|---|---| +| 硬重启 | `EN↓` → 等 3~5 s → `EN↑` → 等 **17 ms**(tON 5.1 ms + 缓启动斜坡 11.5 ms)→ 等 PG 置位 | 放电由硬件联锁自动完成(SR-7),固件**不要**去控制放电 MOS | +| 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | 组合键(VOL−+PWR)必须**同一帧**写进去,不能先后两次 | +| EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | 顺序固定(SW-6):先短接再插 USB | + +**影子寄存器是这一节的核心**:15 路按键 + `HUB_RST` + 5 路 `EDL_EN` 全在**同一条 24 位 +移位链**上,写任何一位都要把整条链重发一遍。 + +``` +ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U33) +写入顺序:U33 字节 → U22 字节 → U21 字节,最后一个 RCLK 上升沿统一生效 +``` + +规矩:**内存里保存一份 24 位影子值,全固件只有一个函数能写 595**,任何任务改按键 +或 EDL 都是「改影子 → 请求刷新」。多处直接操作移位链,症状是**另一台手机的按键被顺手 +清掉**,而且复现要靠两个动作正好撞在一起 —— 属于会拖很久的那类 bug。 + +> `RCLK` 打点之前输出不变,所以整帧刷新是原子的;`OE#` 保持有效,**不要**用拉高 `OE#` +> 的方式「临时全松开」——那会让 15 个栅极同时进高阻,靠的是每个栅极自己的 100 kΩ 下拉, +> 是复位路径,不是正常控制路径。 + +> **2026-09-06 A5 落地**:`components/drv/sr595.{h,c}`。影子是一个 24 位整数 +> (bit0 = U21.QA 链首 … bit23 = U33.QH 链尾),唯一写入点是 `sr595_flush()`; +> 别处只准 `sr595_modify()` 改影子,组合键走 `sr595_write(mask, value)` 一帧发完。 +> 位表见设计文档 §17.2(§26.18 重排后的那张,**不是 09-04 那张**)。 +> +> 同处记下三条这一层的实现约定: +> - **上电顺序是「先锁存安全值,再使能 `OE#`」,不能换。** 三片的 `MR#` 接死在 +3V3, +> 没有复位线 ⇒ 上电时移位寄存器内容**不确定**。先拉低 `OE#` 就是把那坨随机值 +> 放到 15 个栅极上 —— 五台手机的电源键可能一起被按下去。所以 `sr595_init()` 里 +> `KEY_OE#` 在安全值锁存完之前**根本不配成输出**,靠板上 R609 上拉着。 +> (ESP-IDF 的 `gpio_config()` 一配成输出就开始驱动,输出寄存器复位值是 0。) +> - **`HUB_RST` 低有效**,所以上电安全影子是 `1 << 23` 而不是 0。写成 0 的现象是 +> 「Hub 插上枚举不出来」,而五路电压电流全都正常,看不出跟固件有关。 +> - **发送失败要留着脏**(`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 的交付物,也是排查排线接触不良的唯一线索,所以**记录路径不能依赖上位机在线**。 + +| 项 | 设计 | +|---|---| +| 缓冲 | RAM 环形缓冲,≥256 条;`t_safety` 只入队,不落盘 | +| 落盘 | `t_log` 空闲时批量写 NVS / SPIFFS 分区 | +| 单条 | 时间戳 + 路号 + 事件类型 + 当时的 V/I(16 B 量级) | +| 容量 | 4096 条 ≈ 64 KB,按每天几十条算够存半年以上 | +| 时间 | 上电从 0 计;上位机连上后下发一次墙钟做偏移校正(不加 RTC 芯片) | + +必记事件:eFuse FAULT / PG 变化、SR-5 触发关路、SW-8 反灌断 VBUS、过温降载、 +每次重启序列(软 / 硬、谁下的命令)、越界命令被拒、I²C 器件失联、风扇停转。 + +### 10.8 上位机接口 ✅ 已定稿(2026-09-06 用户拍板:方案 C) + +**这一节回答的问题**:SW-1 的触发条件写的是「`adb` 探活连续失败 N 次」, +而 **ESP32 跑不了 adb** —— 所以崩溃判定必然发生在上位机,板子只负责执行动作。 +这意味着必须有一条控制链路,而在此之前**全部文档里都没有定过它**。 + +**结论:WiFi 为常态链路,USB CDC 为兜底链路,两条同时在线。** + +选它不是因为「都要」,而是因为 **SW-1 是这块板子的核心价值, +而 WiFi 与手机 adb 走的是同一张网** —— 只做 WiFi 等于让「救援通道」和「故障通道」 +共命运,恰好在最需要它的时候一起失效。USB 的增量成本很低:线本来就插着。 + +留档备查的两个单选方案: + +| 方案 | 优点 | 为什么不单独用 | +|---|---|---| +| A. 只做 WiFi | 无线、可多台组网;设计文档 §9.5 的 3V3 预算里**已经给 ESP32-S3 的 WiFi 突发留了 ≈350 mA**,说明当初就是按带 WiFi 设计的 | 网炸了救砖通道跟着炸,见上 | +| B. 只做 USB CDC | 最可靠,不依赖网络 | 每块板占上位机一个 USB 口;机架规模上去之后不现实 | + +#### 两条链路怎么共存 + +```diagram=links + WiFi (MQTT/HTTP) USB CDC (J22) + │ │ + ┌───────▼────────┐ ┌────────▼───────┐ + │ 传输适配器 A │ │ 传输适配器 B │ ← 只管收发字节与分帧 + └───────┬────────┘ └────────┬───────┘ + └──────────┬─────────────┘ + ┌──────▼───────┐ + │ 单一命令处理器 │ ← 全部鉴权、限幅、幂等判重都在这里 + └──────┬───────┘ + ▼ + 执行器 / 遥测 +``` + +四条规矩: + +1. **命令处理器只有一个。** 两条链路是**传输**,不是两套功能。任何安全约束 + (SR-13 的限幅、越界拒绝、序号判重)都写在处理器里,绝不写进适配器 —— + 否则以后加第三条链路时必然漏掉一处,而**功能测试完全测不出来**。 +2. **不做主备切换,两条都常开。** 谁先送到算谁的,靠 3 的序号去重。 + 做「主备」就要判定"主链路死了没有",而那个判定本身就是新的故障点。 +3. **序号是全局的,不分链路。** 上位机给每条命令一个单调递增的序号, + 板子记住最近 N 个已执行的序号。WiFi 重传 + USB 又发了一遍同一条命令时, + 第二次必须被丢弃 —— 否则「按 10 秒电源键」会执行两遍,第二遍正好按在刚开机的手机上。 +4. **USB 链路与日志共用一条流。** ESP32-S3 的原生 USB-Serial-JTAG 本身就是 + CDC-ACM,不需要切到 TinyUSB,也不影响 esptool 下载(FR-15 不变)。 + 代价是命令响应和运行日志在同一条串流上,**必须分帧**(建议命令用带前缀的单行 + JSON,日志走另一个前缀),否则日志一刷就把响应冲散。 + +> ⚠️ **别把 WiFi 凭据和 MQTT 地址编译进固件。** 放 NVS,用 USB 链路配置 —— +> 这正好是兜底链路的第二个用途:新板子上架时先插 USB 配网。 + +命令语义与链路无关,两条链路完全一致: + +| 命令 | 参数 | 说明 | +|---|---|---| +| `status` | — | 五路 V/I/PG/FLT、输入 V/I、两路 NTC + 载板温度、风扇转速、各路状态机状态 | +| `set_voltage` | 路号, mV | 走 `rail_set_mv()`,越界拒绝(10.5) | +| `power_cycle` | 路号, 断电秒数 | 二级硬重启(4.1) | +| `key` | 路号, 键位掩码, 保持 ms | 单键与组合键同一个接口 | +| `edl` | 路号, on/off | 内部按 SW-6 的固定时序展开 | +| `vbus` | 路号, on/off | 默认断开 | +| `fan` | 占空比 / auto | `auto` 交回 `t_thermal` 闭环 | +| `events` | 起始序号 | 增量拉取事件日志 | + +三条接口硬约束: + +- **没有任何命令能让输出超过 4.2 V**(SR-13)—— 包括「原始 DAC 写入」这类调试命令, + 它压根不该存在于发布固件里。 +- **命令必须幂等或带序号**:链路重传时不能让「按 10 秒电源键」执行两遍(上面第 3 条)。 +- **链路失联不触发任何动作**(10.2):没有心跳超时关输出这回事。 + +### 10.9 启动顺序 + +``` +1 上电:GPIO 全部显式置安全态(硬件下拉已保证,固件再确认一次) +2 I²C 扫描 10 个器件 —— 缺任何一个都进「降级模式」(10.10),不要照常启动 +3 读回 DAC 通道值(NVM 默认 3.90 V),与期望比对 +4 载入 NVS 里的标定值与上次的每路使能状态 +5 逐路上电,**路与路之间错开 100 ms** +6 等各路 PG,超时则记事件并保持该路关闭 +7 起风扇闭环,起链路任务 +``` + +> ⚠️ **第 5 步的错开不是讲究,是必须**:单路缓启动涌流 0.8 A,五路同时开就是 4 A +> 叠在 12 V 输入上,而输入预算本来就是 8.3 A(PR-2)。错开 100 ms 之后 +> 各路的斜坡(11.5 ms)完全不重叠。 + +### 10.10 降级矩阵 + +| 失效 | 检测方式 | 行为 | +|---|---|---| +| 某片 INA226 无应答 | I²C NAK | 该路失去遥测 → **保持供电**,标记为「盲跑」,禁用该路的 SR-5 / SW-8 判据并持续告警 | +| I²C 总线挂死(SDA 被拉低) | 连续超时 | 发 9 个时钟脉冲做总线恢复;仍不通则复位 I²C 外设;再不通进降级模式 | +| DAC 无应答 | I²C NAK | **不关输出** —— DAC 不响应时输出停在 NVM 默认的 3.90 V,是安全值 | +| PCA9555 无应答 | I²C NAK | 失去 FAULT / PG 可见性;改用 INA226 的电压判据兜底 | +| 载板 LM75BD 无应答 | I²C NAK | 多半是排针接触不良(它在排针另一侧)→ **风扇转到 100 %** 并告警,不要按「读不到温度=不热」处理 | +| 上位机失联 | 心跳超时 | 见 10.2:**什么都不做** | +| 看门狗复位 | — | 复位即全关(SR-6),重启后按 10.9 恢复,并记一条复位事件 | + +**降级模式**的定义:只保留「关断能力」与「事件记录」,不再自动上电、不接受调压命令, +持续告警。宁可五台手机不开机,也不要在盲态下带电跑。 + +### 10.11 关于 FR-12「模拟电量曲线」的一个说明 + +这条需求的实现方式和它的字面意思不一样,值得在架构层写清楚:**手机的电量计是从 +电池端电压推算 SoC 的**,我们给的电压就是它看到的全部信息。所以「模拟电量曲线」 += **按锂电放电曲线缓慢改变输出电压**(比如 12 小时从 4.01 V 走到 3.60 V,再跳回去)。 + +两个后果: + +- 它**不需要**任何新硬件,是 `rail_set_mv()` 之上的一个慢速调度器; +- 它与 SW-4(单路电压微调)**共用同一个执行器**,两者必须互斥 —— + 否则运维把某台调到 4.0 V,曲线任务转头又把它拉回 3.6 V。 + +FR-12 是「中(可选)」,建议**首版先不做**,把接口留出来即可。 + +### 10.12 架构层面的待定项 + +| 项 | 状态 | +|---|---| +| ~~上位机链路选型(10.8)~~ | ✅ **2026-09-06 用户拍板:方案 C**,WiFi 常态 + USB CDC 兜底 | +| **OTA 升级** | 既然定了 WiFi,**建议做**:双分区 + 失败回滚。没有 OTA 的话一次坏固件要把板子从机架上拆下来插 USB,而 USB 兜底链路本身也能烧 —— 所以这是「省事」不是「救命」,可以排在首版之后 | +| WiFi 掉线重连策略 | 指数退避 + 上限;**重连过程不得阻塞 `t_safety`**(10.3),链路任务优先级最低就是为这个 | +| 时间同步 | 当前方案是上位机下发墙钟做偏移,不加 RTC。若日志要脱机长期归档需重议 | +| FR-12 电量曲线 | 建议首版不做,只留接口(10.11) | +| `input_suspend` 节点路径 | 与 SW-7 / V-8 是同一件事,首台机上确认后写死 | + +--- + +## 11. 约束与假设 + +### 11.1 硬约束 + +| ID | 约束 | +|---|---| +| **C-1** | 全部为 1S 机型,负载固定为小米8(dipper)主板 | +| **C-2** | 原厂电池保护板**被旁路** ⇒ 失去一层与本板独立的防线,全部安全责任落在 SR-2 / 3 / 5 上 | +| **C-3** | 无电池 ⇒ 失去一个内阻只有几十 mΩ 的缓冲电容。**瞬态阻抗设计(§6)比电压精度重要得多**,是这类板子的主要故障来源 | +| **C-4** | ESP32-S3-WROOM-1 可用 IO 有限(19 根信号已排满),新增功能只能走移位寄存器或 I²C 扩展 | +| **C-5** | USB2517I 要求 D± 走线 ≤ 6 inch(152 mm) ⇒ Hub 只能放在载板通道正中 | +| **C-6** | BM24(尾插)连接器只有 **10 次插拔寿命** ⇒ 装配流程必须减少反复插拔 | +| **C-7** | 小米8 原理图 PDF 是第三方扫描件,**一切引脚 / 机械结论以实测为准** | + +### 11.2 假设(若不成立需重新评估) + +| ID | 假设 | +|---|---| +| **A-1** | 上游 USB 恒为 5 V(只接电脑 / Hub,不接快充头) | +| **A-2** | 手机关机待机电流在 ≈50 µA 量级 | +| **A-3** | 目标 ROM 上存在可写的「关充电」sysfs 节点(SW-7,路径待确认) | +| **A-4** | 手机常态功耗 0.3 ~ 1 A / 路,五路同时 4 A 满载不会发生 | + +--- + +## 12. 验收准则 + +### 12.1 首板上电验收(17 步,**严禁第一次上电就接手机**) + +完整步骤与通过标准见设计文档 **§11**。其中: + +- **第 5 步(人为断开 R2,模拟致命故障)** 与 **第 6 步(负载阶跃 + 阻尼验证)** + 是这块板子的两个**核心验证项,不得跳过** —— 它们分别验证 SR-3 和 PR-12 / C-3。 +- **第 12 步(ESP32 复位安全测试)** 验证 SR-6。 +- **第 14 步(USB 反灌测试)** 验证 SR-10 / SW-8。 + +### 12.2 需求 → 验收方法对照 + +| 需求 | 验收方法 | 通过标准 | +|---|---|---| +| PR-4 / PR-5 | 扫 DAC 全量程 | 输出扫过 3.52 ~ 4.01 V,与公式误差 <1%;**任何码值都到不了 4.2 V** | +| SR-2 | 固件写越界值 | 输出被拓扑限死,不越过上限 | +| SR-3 / SR-4 | 人为断开反馈下电阻 | eFuse 断开,输出侧尖峰不超过 4.7 V | +| SR-5 | 注入 ±150 mV 偏差 | 20 ms 内关路并产生告警记录 | +| SR-6 | 手动复位 ESP32 | 示波器确认五路立即关断、15 路按键无一误触发、EDL 无触发 | +| SR-7 | 固件同时置位 EN 与放电 | 硬件互斥,10 Ω 上无持续功耗 | +| SR-9 | 可调电源把母线升到 13.5 V | 泄放导通并拉回 13.2 V 以下 | +| SR-10 / SW-8 | 不关充电直接接 USB | 电流转负 → 固件 200 ms 内断 VBUS | +| SR-15 | 排线加 4.40 V | **不断开**(断开说明焊到了 `B+`/`B−`) | +| PR-12 | 0→4 A 阶跃(带 20 cm 线 + 电容组) | 压降 <300 mV,**无 6 kHz 振铃** | +| PR-13 | 满载 4 A 跑 30 min | 温升 <40 K | +| PR-14 | 断电重启时序 | 输出 200 ms 内 <0.5 V | +| FR-4 / SW-5 | 逐项试按键 | 短按唤醒 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot 均有效 | +| FR-5 / SW-6 | 下发 EDL 序列 | 主机可见 9008 设备 | +| FR-7 | 五台全接 | `adb devices` 与 `fastboot devices` 均可见 | +| FR-10 / TR-6 | 拔掉一个风扇 | TACH 频率下降约 1/5,产生告警 | +| SW-12 | 断开 WiFi(拔 AP / 关热点),从 USB 下命令 | 命令照常执行;**五路输出不受影响**(10.2 的失联行为) | +| SW-12 | 同一序号的命令从两条链路各发一遍 | **只执行一次**,第二条被判重丢弃并记事件 | +| **总体目标** | 五台长稳 | **24 h 无重启、无告警** | + +### 12.3 装配前必做的实物验证(阻塞项) + +| 项 | 内容 | 出处 | +|---|---|---| +| V-1 | **排线旁路验证**:确认焊在 `P+`/`P−`,4.40 V 不断开 | §13.7 | +| V-2 | **侧键触点定位**:万用表 5 分钟,拍照存档 | §13.3 | +| V-3 | **环路补偿实测扫频**(三个位置已留焊盘) | §9.2 | +| V-4 | **量两个新连接器实物高度**(XT30PW-M / 2510-3A)—— 余量只有 1~3 mm,**别信估计值** | §26.16 | +| V-5 | **两条排线路径试装**(电池 / 尾插穿槽走背面) | §25.16 | +| V-6 | **J7n 镜像位按底层装法复做**;装好后做两点通断复核 | §24.3 / §25.16 | +| V-7 | **风扇线装配顺序**:必须「先插五根风扇线,再合上载板」 | §26.16 | +| V-8 | 确认目标 ROM 上 `input_suspend` 的实际路径 | §15.2 | + +> 完整的待办 / 已关闭清单以设计文档 **§12** 为准,本表只列对需求验收有约束的。 + +--- + +## 13. 已知风险与已接受的取舍 + +| # | 风险 | 处置 | +|---|---|---| +| R1 | 去掉原厂保护板 = 少一层独立防线 | **已接受**。定量核算 eFuse 余量 ≈11 倍;且 eFuse 断开后 2200 µF 仍撑着,手机继续运行不掉电,表现优于保护板断开 | +| R2 | 最坏角落下 eFuse 可能到 4.519 V 才跳,超 1S 上限约 60 mV | **已接受**。走到这一步说明第一层已失效,输出以 6.4 mV/µs 冲过这 70 mV 只需 ≈10 µs,远短于锂电过压损伤时间常数 | +| R3 | 两个同款 USB-C 插错 | **已接受**(用户在 USB-B / USB-C 之间选了 C)。靠丝印 + 颜色 + 外壳标记缓解 | +| R4 | 低边调速导致 PWM 关断期间读不到转速 | **已接受**。若最终选 4 线 PWM 风扇,MOS + 续流管 + 分压可全删 | +| R5 | IF-5 的 100 µF MLCC 在 3.9 V 偏压下只剩 ≈40 µF,中频储能从 200 µF 降到 ≈80 µF | **待复核**。严格照原方案需换真聚合物 | +| R6 | 电感 SPM6530T-4R7M 的 Isat 6.2 A < 原定的 7 A | **已接受**,裕量分析见设计文档「已知遗留」 | +| R7 | BM24 仅 10 次插拔寿命 | 靠装配流程规避(C-6) | +| R8 | 手机主板机械固定方式(是否连中框)未定 | **不阻塞打样** | + +--- + +## 14. 需求 → 设计追溯表(速查) + +| 需求域 | 设计文档章节 | +|---|---| +| 规格 / 输入输出 | §1、§3 | +| 电压范围与本质安全 | §4(含 §4.4 失效态验算) | +| 三层保护链 | §5 | +| 瞬态阻抗与阻尼(C-3) | §6 | +| 断电重启与放电 | §7 | +| MCU 接口 / GPIO / I²C | §8、§26.18 | +| 选型与 BOM | §9 | +| PCB 与叠层 | §10、§25、§26 | +| 验收与调试 | §11、§14 | +| 手机侧接口 | §13 | +| USB 子系统 | §15、§18、§21 | +| 两板架构与机械 | §22、§25 | +| 散热 | §22.6 | +| EDL | §22.4 | +| **软件架构** | **设计文档里没有对应章节 —— 见本文 §10** | + +--- + +## 15. 术语 + +| 术语 | 含义 | +|---|---| +| **直供** | 拆掉电芯与保护板,从电池座直接供给受控电压 | +| **dipper** | 小米8 的代号 | +| **P+ / P−** | 电池保护板的**输出侧**焊点(本项目的接入点) | +| **B+ / B−** | 电芯侧焊点(**接错这里是本项目最隐蔽的装配错误**) | +| **eFuse** | 带可调过压 / 限流 / 缓启动的电子保险丝(TPS25940A) | +| **EDL / 9008** | 高通紧急下载模式 | +| **本质安全** | 靠拓扑与元件取值在物理上限死危险状态,而不是靠软件判断 | +| **下板 / 载板** | 下板 = 电源板;载板(上板)= 承载五块手机主板的板 |