# PhoneRail 固件开发计划 | 项 | 值 | |---|---| | 版本 | **v2.0(2026-09-16)** —— 按 **R2 刀片式硬件**重排;v1.0(2026-09-06,R1 单板)见 git 历史 | | 范围 | `需求文档.md` 的 **D5 / D5a** —— ESP32-S3 固件与双链路命令接口 | | 架构依据 | `需求文档.md` **§10 软件架构设计**(本计划不重复架构,只排施工顺序) | | 硬件依据 | **`刀片式方案规划-R2.md`**(底板 + 5 片刀片)。R1 的 `五路直供电源板设计.md` 仍是**电路计算与阈值**的出处,但**板级拓扑一律以 R2 为准** | | 目标平台 | ESP32-S3-WROOM-1-**N16R8** / ESP-IDF v5.4 / C | > **这份计划是给「一次会话 ≤ 20% 上下文」这个约束量身排的。** > 它不是甘特图,是一串**自带入场清单与验收判据的任务包**: > 每包一次会话做完,做完时代码是绿的、交接是写好的,下一次会话不需要回头翻上一次的过程。 --- ## 0. v2.0 改了什么(先看这一节) v1.0 写在 2026-09-06,那时硬件是 R1:**一块下板 + 一块载板**、 六片不同地址的 INA226 挂一条 I²C、24 位 74HC595 链管按键、`VBUS_EN` 是 ESP32 直连 GPIO。 2026-09-08 起硬件改成 **R2 刀片式**(底板 + 5 片完全相同的刀片,PCI-E 金手指对插), 到 2026-09-16 底板与刀片的原理图、PCB、BOM 均已定稿并在收尾。 **A1~A11 那批已完成的固件是照 R1 写的,其中有六处与现在的板子对不上**, 差异与影响逐条列在 **§5**,迁移任务包是 **§6.2 的 R-0~R-6**。 一句话:**逻辑层(sched / evlog / rail 换算 / t_actuator 的时序 / t_safety 的判据阈值)全部有效, 器件层与拓扑层要改。** 本次修订没有推翻任何一个时间常量或阈值 —— 那些来自电路计算,R2 没改电路。 ⚠️ **R1 固件不再维护、也不做条件编译共存。** R1 的板子一片都没打样, 为一块不会存在的板子保留一套 `#ifdef` 分支,代价是此后每个任务包都要读两遍。 R1 的代码在 git 里,需要时去那里翻。 --- ## 1. 决定这份计划形状的四个约束 | # | 约束 | 对计划的影响 | |---|---|---| | **C-a** | **硬件仍未到手**,但**已定稿**:底板与刀片的原理图 / PCB / BOM 都已收口 | 绝大部分固件仍要在**没有板子**的情况下写完并验完 ⇒ 模拟器继续是主力,见 §4.2。**但器件层不再有"待定"**:地址、位号、引脚全部可从板上缓存读出来(§5 的每一行都注明了出处) | | **C-b** | **一次会话 ≤ 20 % 上下文**(按 200K 窗口 ≈ **40K token**) | 每个任务包必须**自带入场读取清单并给出上限**;源文件行数要控制;禁止整篇读大文档,见 §2 | | **C-c** | **保护不在软件里**(需求 SR-3 / SW-N1) | 固件写错的后果是「功能不好用」,不是「烧手机」⇒ 可以先把逻辑全部写完再上板调 | | **C-d** | **五片刀片完全同构**:同地址 INA226(0x40) + 同地址 PCA9555(0x20),靠底板 TCA9548A 分通道区分(R2 D8) | 「查表不要算」这条规矩**没有作废,只是换了对象**:现在因路而异的只有 **mux 通道**与 **`EFUSE_EN` GPIO** 两列,其余五路逐格相同。⚠️ 新的静默错误形态是**选错通道** —— 读回来的是另一台手机的电压电流,量纲、范围、变化趋势全都正常 | C-c 依然是最解放人的一条;C-d 是这一版新增的、也是最危险的一条。 --- ## 2. 上下文预算 —— 每次会话怎么花那 40K ### 2.1 实测的「读一份文档要花多少」 | 文件 | 大小 | ≈ token | 占 20 % 预算 | |---|---|---|---| | `CLAUDE.md` | 4 KB | ~1.3K | 3 % | | `需求文档.md` **全文** | 67 KB | ~22K | **55 %** | |  └ 单看 §10 软件架构设计 | 19 KB | ~6K | 15 % | | `刀片式方案规划-R2.md` **全文** | 291 KB | ~95K | **240 % ⛔** | |  └ 单看 §7.3 + §8.1(刀片 I²C 设备 + 固件改动) | 4 KB | ~1.3K | 3 % | | `PROGRESS.md` **全文** | 534 KB | ~175K | **440 % ⛔** | | `五路直供电源板设计.md` **全文** | 493 KB | ~160K | **400 % ⛔** | > 换算口径:UTF-8 中文约 3 字节/字,中文约 1 token/字,所以 **字节 ÷ 3 ≈ token**。 > 英文代码更省,约 4 字节/token。 ### 2.2 一次会话的预算分配 | 段 | 预算 | 内容 | |---|---|---| | 入场 | **≤ 8 %**(16K) | `CLAUDE.md` + 本计划里**该任务包那一节** + 上一次交接 + 要改的 1~3 个源文件 | | 干活 | **≤ 8 %**(16K) | 写代码、跑构建与测试、读报错 | | 收尾 | **≤ 4 %**(8K) | 更新交接、提交、把新踩到的坑写回文档 | ### 2.3 硬规矩(违反其中任何一条,当次会话必然超预算) 1. **禁止整篇读四份大文档**(`五路直供电源板设计.md` / `刀片式方案规划-R2.md` / `PROGRESS.md` / `需求文档.md`)。 需要什么就 `grep -n` 那个条目号再 `sed -n` 取那一段。 2. **板级事实优先读机器可读的缓存,不要读文档**: - `.claude/r2/_base_pcb_nets.json` —— 底板每个器件的**焊盘 → 网名**(`U20` 就是 ESP32 的全部引脚) - `.claude/r2/_blade_pcb_nets.json` —— 刀片同上(`U4` 就是 PCA9555 的全部引脚) - `.claude/_esp32_pinmap.json` 的 `r2_base` —— 模组**脚号 → 网名**(脚号不是 GPIO 号,换算见 §5.1) 一次 python 取一个器件 ≈ 200 token,比读文档便宜两个数量级,而且**它就是板子本身**。 3. **单个源文件控制在 ≈300 行以内。** 超了就拆文件 —— 这条是为了让后续会话读得起。 4. **构建与测试输出一律 `2>&1 | tail -40`。** 5. **长任务后台跑**(`run_in_background`)**只看摘要**。 6. **一次会话只碰一个任务包。** 7. **固件会话与 EasyEDA 会话分开。** --- ## 3. 会话协议 ### 3.1 每次会话的固定动作 ``` 开场:读 CLAUDE.md 读 固件开发计划.md 里【本次任务包】那一节(只读那一节) 读 firmware/HANDOFF.md(上一次的交接,控制在 60 行以内) 按任务包的「入场读取」清单读文件,读完就动手 收尾:跑一次完整验收命令,贴结果 更新 firmware/HANDOFF.md(覆盖,不追加 —— 它是「现状」不是「日志」) 把新发现的坑写回 需求文档.md §10 或本计划,一两句话即可 ``` ### 3.2 `firmware/HANDOFF.md` 模板(**≤ 60 行**,超了就是在写日记) ```markdown # 交接 更新:<日期> 完成任务包 <编号> ## 一句话现状 <现在能跑到哪一步> ## 下一步 <下一个任务包编号 + 一句话> ## 本次新增/修改的文件 - path — 干什么的 ## 验收命令与结果 $ <命令> <最后几行输出> ## 踩到的坑(只写会再犯的) - ... ``` ### 3.3 任务包的完成定义(DoD) - [ ] 宿主机测试全绿(`bash host/test.sh`),**且新增了针对本包的测试**; - [ ] ESP-IDF 目标也能编过(`.\build_idf.ps1`,不要求上板); - [ ] `HANDOFF.md` 已更新; - [ ] **没有跨会话的半成品** —— 宁可缩小本包范围,也不要留一个编不过的中间态。 --- ## 4. 工程结构与三个关键决定 ### 4.1 决定一:宿主机可编译 + 硬件抽象层(不变) ``` firmware/ ├── main/ 应用层 —— 与硬件无关,宿主机上能编能跑 │ ├── board_pins.h 底板 ESP32 引脚(R2) │ ├── rail_cfg.c/.h 五路一张表(R2:+ mux_ch、+ PCA9555 位,− vbus_en GPIO) │ ├── rail.c/.h mV ↔ DAC 码(§10.5,R2 零改动) │ ├── i2cbus.c/.h ⭐ R2 新增:mux 原子事务(见 §4.3) │ ├── t_safety.c SR-5 / SW-8 巡检判据(§10.3) │ ├── t_actuator.c 三个执行器状态机(§10.6) │ ├── t_thermal.c 风扇闭环(A16) │ ├── cmd.c 单一命令处理器(A12,§10.8) │ ├── evlog.c 事件环形缓冲 + 落盘(已完成) │ ├── sched.c 周期节拍(已完成) │ └── tasks.c 五个任务那张表(已完成) ├── components/ │ ├── phonerail_hal/ i2c / gpio / pwm / time / store / lock 的**接口** │ │ ├── esp/ 真实实现(ESP-IDF) │ │ └── sim/ 模拟实现(宿主机) │ └── drv/ ina226 / dac63204 / pca9555 / tca9548a / ch224a │ (sr595 已退役,见 §5.2 与 R-2) └── test/ 宿主机单元测试与场景测试 ``` **HAL 抽象六样东西**:I²C 事务、GPIO 电平、PWM/定时、单调时钟、掉电存储、锁。 不要抽象得更多 —— 抽象层每多一个概念,后面每次会话都要多读一遍。 > ⚠️ **组件名不能叫 `hal`**(A2 实测):ESP-IDF 自己有 `components/hal`,同名会被盖掉。 ### 4.2 决定二:模拟器仍是主力(不变) | 模拟 | 到什么程度 | 不模拟什么 | |---|---|---| | I²C 器件 | 寄存器读写 + 可编程返回值 + 可注入 NAK | 真实时序、时钟延展 | | **I²C mux** | ⭐ R2 新增:**通道状态决定哪些假器件可见**;选错通道就读到另一片 | 通道间串扰 | | 五路输出 | 一阶模型:`EN` 开 → 按 0.36 V/ms 斜坡到设定值;关 → 按 τ=22 ms 衰减 | 环路动态、纹波 | | 手机负载 | 一个可设定的电流值 + 「卡死」标志 | 真实功耗曲线 | | 故障注入 | 过压、负电流(反灌)、器件失联、PG 不来、风扇停转、**mux 失联**、**刀片不在位** | —— | > ⚠️ **模拟器不是用来证明设计对的**,它只用来证明**代码逻辑对**。 > 电压、余量、时序裕量那些结论全部来自设计文档的计算与首板实测。 ### 4.3 决定三(R2 新增):`i2cbus` —— 通道选择与事务是**一个**不可分的动作 R2 之后,一次「读 CH3 的电压」在总线上其实是两件事: **先写 TCA9548A 选通道 2,再读 0x40 的寄存器**。 这两件事之间如果被另一个任务插进来选了别的通道,读回来的是**另一台手机的数据**, 而且量纲、范围、变化趋势全部正常 —— 没有任何一层会报错。 所以在 `hal_i2c` 之上加一层薄薄的 `i2cbus`,规矩三条: 1. **应用层不许直接调 `hal_i2c_*`。** 驱动照旧只调 `hal_i2c_*`(它们要能单独测), 但驱动的**调用方**必须经 `i2cbus` 把通道选好并持锁。 2. **`i2cbus_select(ch)` 不是一个可以单独调的公开函数。** 对外只暴露 `i2cbus_xact(ch, fn, arg)`:拿锁 → 必要时选通道 → 跑 `fn` → 放锁。 通道号记在影子里,**相同通道不重发选通字节**(省掉每圈 5 次事务)。 3. **`t_safety` 的一圈是五次 `i2cbus_xact`,不是一把大锁包住整圈。** 每路一次 `xact`,中间给 `t_actuator` 留得进来的缝。 ⚠️ **mux 影子必须在任何一次写通道失败时作废**(置成"未知"), 否则下一次「通道相同、跳过选通」会把事务发到一个其实没切过去的通道上。 --- ## 5. ⭐ R1 → R2:硬件差异与固件影响(权威清单) **这一节是 v2.0 的核心。** 每一行的「板上出处」都可以用 §2.3 第 2 条的命令在 30 秒内复核。 **动手改任何一个器件层文件之前,先核对这里对应的那一行。** ### 5.1 底板 ESP32 引脚:整张表作废重来 R1 的 `board_pins.h` + `rail_cfg.c` 里那两列 GPIO **一个都不对**。 R2 的事实来源是 `.claude/_esp32_pinmap.json` 的 `r2_base`(脚号 → 网名) 与 `.claude/r2/_base_pcb_nets.json` 的 `U20`(脚号 → 网名,从 PCB 焊盘读回)。 两份已逐脚对上。**脚号 ≠ GPIO 号**: | 网 | 模组脚 | **GPIO** | R1 是 | 备注 | |---|---:|---:|---|---| | `EFUSE_EN1` | 17 | **IO9** | IO39 | 五路全变,且 R2 里**组内顺排**(R2 §29.5 为布线重排过) | | `EFUSE_EN2` | 18 | **IO10** | IO48 | | | `EFUSE_EN3` | 19 | **IO11** | IO6 | | | `EFUSE_EN4` | 20 | **IO12** | IO41 | | | `EFUSE_EN5` | 21 | **IO13** | IO8 | | | `VBUS_EN1~5` | — | **不存在** | IO40/10/11/42/17 | ⛔ **挪到刀片 PCA9555 的 P0.4**,见 §5.4 | | `SDA` | 34 | **IO41** | IO14 | | | `SCL` | 35 | **IO42** | IO15 | | | `PRSNT1#` | 8 | **IO15** | 无 | R2 新增,3V3 上拉(R606~R610)⇒ **低 = 刀片在位** | | `PRSNT2#` | 9 | **IO16** | 无 | | | `PRSNT3#` | 10 | **IO17** | 无 | | | `PRSNT4#` | 11 | **IO18** | 无 | | | `PRSNT5#` | 7 | **IO7** | 无 | ⚠️ 这一根**不在顺排里**(方案 B 重排,R2 §29.6) | | `MUX_RST#` | 33 | **IO40** | 无 | R2 新增,3V3 上拉(R611)⇒ 低 = 复位 TCA9548A | | `INT#` | 32 | **IO39** | IO38(`IOEXP_INT#`) | 五片刀片 PCA9555 的 INT# **wire-OR**(R604 上拉)⇒ 低 = 有某片报中断,**看不出是哪片** | | `INA_ALERT` | 31 | **IO38** | 无(R1 走 PCA9555 P0.6) | 只有**底板输入侧 U13** 一片的 ALERT(R605 上拉) | | `HUB_RST` | 12 | **IO8** | 无(R1 走 595) | 上拉到 `+3V3_HUB`(R716)⇒ 低有效。**只在上电复位(`ESP_RST_POWERON`)时脉冲一次,其余复位原因与运行期都不许动**(D13 代价 2;2026-09-27 上板改:原先每次重启都脉冲,esptool 经 USB 复位即断口) | | `UP_VBUS_DET` | 6 | **IO6** | IO47 | R721/R722 分压 | | `NTC1` / `NTC2` | 4 / 5 | **IO4 / IO5** | IO1 / IO2 | 仍是 ADC1(R660/R661 上拉分压) | | `FAN_PWM` | 24 | **IO47** | IO18 | 两个风扇座共用 | | `FAN_EN` | 25 | **IO48** | 无 | ⚠️ R2 新增:**复位低 = 风扇断电**,固件必须主动开(Q8 → Q7 两级) | | `FAN_TACH_IN` | 23 | **IO21** | IO16 | 进风扇,经 Q9 开集**反相** + R753 上拉 | | `FAN_TACH_OUT` | 22 | **IO14** | 无 | 出风扇,经 Q10 **反相** + R757 上拉 | | `USB6_DM`/`DP` | 13/14 | IO19/IO20 | 同 | 原生 USB 硬连线,接 Hub 第 6 口(D13) | | `IO0` | 27 | IO0 | — | 只接 J33 应急口,**不许当普通 GPIO 用** | | `TXD0`/`RXD0` | 37/36 | IO43/IO44 | 同 | J33 应急串口,永远能进(D13 代价 2 的退路) | | 备用 | 39/38 | IO1/IO2 | — | 全板只剩这两个空脚,都是 ADC1 | ⛔ **一律不许占用**:IO0/IO3/IO45/IO46(strapping)、IO19/IO20(USB PHY)、 IO26~IO32(模组内部 flash)、IO35~IO37(N16R8 的八线 PSRAM)、IO22~IO25(**S3 上不存在**)。 ### 5.2 器件清单:多了两个,退役两个 | 器件 | R1 | **R2** | 板上出处 | |---|---|---|---| | INA226 | 6 片 6 个地址,一条总线 | **5 片全是 0x40**(刀片 `U3`,各在自己的 mux 通道)+ **1 片 0x43**(底板 `U13`,主干) | `_blade` `U3` pin1/2 = GND/GND;`_base` `U13` pin1/2 = GND/SCL | | DAC63204 | 2 片 0x4A / 0x4B | **一模一样**(底板 `U6`/`U7`,**挂主干,不进 mux**) | `_base` `U6` pin7=SDA、`U7` pin7=SCL(ADDR strap) | | PCA9555 | 1 片 0x20,**16 脚全是输入** | **5 片全是 0x20**(刀片 `U4`),**P0.0~P0.4 是输出** | `_blade` `U4`,见 §5.3 | | 74HC595 ×3 | 24 位链,管按键 / EDL / HUB_RST | ⛔ **退役**(R2 D9) | 底板 BOM 里没有 | | **TCA9548A** | 无 | **新增,0x70**(底板 `U35`) | `_base` `U35` pin1/2/21 = GND(A0/A1/A2) | | **CH224A** | 无 | **新增**(底板 `U36`,PD 受电,挂 **mux 通道 5**) | `_base` `U36` pin2/3 = SCL6/SDA6 | | LM75 | 载板上一片 0x48 | ⛔ **没有了**(载板整个不存在) | 底板 BOM 无 | mux 通道分配(`_base` `U35` 逐脚读回,**写进表里查,不要算**): | 通道 | 引脚 | 去哪 | |---:|---|---| | 0 | SD0/SC0 = `SDA1`/`SCL1` | 槽 1(`J34`) | | 1 | `SDA2`/`SCL2` | 槽 2(`J35`) | | 2 | `SDA3`/`SCL3` | 槽 3(`J36`) | | 3 | `SDA4`/`SCL4` | 槽 4(`J37`) | | 4 | `SDA5`/`SCL5` | 槽 5(`J38`) | | **5** | `SDA6`/`SCL6` | **CH224A(`U36`)** | | 6 / 7 | 悬空 | —— | ### 5.3 刀片 PCA9555(`U4`):位表与方向全部重写 从 `.claude/r2/_blade_pcb_nets.json` 的 `U4` 逐脚读回(TSSOP-24): | 脚 | 位 | 网 | 方向 | 复位态 / 极性 | |---:|---|---|---|---| | 4 | P0.0 | `KEY_PWR` | **OUT** | 上电高阻 + 栅极 100 k 下拉 = 松开 ✅ | | 5 | P0.1 | `KEY_VUP` | **OUT** | 同上 | | 6 | P0.2 | `KEY_VDN` | **OUT** | 同上 | | 7 | P0.3 | `EDL_EN` | **OUT** | 同上 | | 8 | P0.4 | `VBUS_EN` | **OUT** | 高阻 + R701 100 k 下拉 = VBUS 断 ✅ | | 9,10,11 | P0.5~7 | **GND** | **必须 IN** | ⛔ 配成输出 = 直接对地短路 | | 13 | P1.0 | `EFUSE_FLT#` | IN | 开漏上拉 ⇒ **低 = 有故障** | | 14 | P1.1 | `BUCK_PG` | IN | ⇒ 高 = Buck 正常(**R2 里是本路的,不再是五路线或**) | | 15 | P1.2 | `INA_ALERT` | IN | ⇒ 低 = 本片 INA226 告警(不跨板) | | 16 | P1.3 | `EFUSE_PG` | IN | ⇒ 高 = eFuse 输出良好 | | 17~20 | P1.4~7 | **GND** | **必须 IN** | ⛔ 同上 | | 1 | INT# | 五槽 wire-OR → ESP32 IO39 | — | 读走输入端口才释放 | | 2,3,21 | A1,A2,A0 | GND | — | ⇒ 地址 **0x20** | ⛔⛔ **这是 R2 固件里最危险的一处,写错一次板子就冒烟:** PCA9555 的 **Output Port 寄存器复位值是 0xFF**,Config 复位值也是 0xFF(全输入)。 所以初始化**必须是这个顺序**: ``` 1. 写 OUTPUT0 = 0x00 (此时全是输入,写它对外没有任何影响) 2. 写 OUTPUT1 = 0x00 3. 写 POLINV0/1 = 0x00 4. 写 CONFIG1 = 0xFF (Port1 全输入) 5. 写 CONFIG0 = 0xE0 (P0.0~4 输出、P0.5~7 输入) ← 最后一步 6. 逐个回读校验 ``` **顺序反过来(先配方向再写输出)的后果**:那一刻 P0.0~P0.4 输出 **1**, = 三个键全按下 + EDL 短接 + VBUS 上电,五台手机一起被按住电源键。 而 `P0.5~7 / P1.4~7` 七个脚在刀片上**直接接地、不串限流电阻**(上表), 方向写错一位就是对地短路 —— 不报错、不 NAK,只会发烫。 ⇒ 驱动里 `pca9555_dir_safe()` 的白名单从 R1 的「全输入」改成 **「只有 P0.0~P0.4 可以是输出」**,越界一律 `abort()`。 ### 5.4 `VBUS_EN` 从 GPIO 变成 I²C —— 这条改了架构,不只是改了接口 R1 里 `t_safety` 的 SW-8 反灌判据可以**直接写引脚**(§10.3 原话「不经任何队列或抽象层」)。 R2 里 `VBUS_EN` 在刀片的 PCA9555 上 ⇒ **断 VBUS 变成一次跨 mux 的 I²C 事务**。 | | R1 | R2 | |---|---|---| | 断 VBUS 的动作 | `gpio_set_level()`,几百 ns | mux 选通 + 写 OUTPUT0,**约 0.16 ms**,且要持 I²C 锁 | | 判据延迟 | 一圈(20 ms) | 一圈 + 一次事务 ≈ 20.2 ms —— SW-8 的预算是 200 ms,**仍有 10 倍余量** | | I²C 失联时 | 照样断得掉 | ⛔ **断不掉** | ⇒ **`t_safety` 必须在同一圈里已经持有 I²C 锁的那个位置执行断 VBUS**,不要放锁后再拿一次。 ⇒ **新增一条降级行为并写进需求 §10.10**:「PCA9555 失联 ⇒ 该路 VBUS 不可控」。 兜底是关 `EFUSE_EN`(GPIO 直连,永远可用)—— **但要说清楚:关 eFuse 并不能阻止手机经 USB 往回灌**,它只是让这一路不再直供电。 真正的反灌保护是 R1 §15.2 的硬件(U29 泄放 + 母线电容),固件那一层在 I²C 失联时**确实降级了**。 这是 R2 用一根跨板针换来的代价,**是决定,不是待办**。 ⚠️ 相应地,`EFUSE_EN` **仍然是 ESP32 GPIO 直连**(金手指 A11 独占一针,R2 §7.3 的框)—— 这是保护链第三层唯一的执行手段,任何时候都不许把它挪到 I²C 后面。 ### 5.5 `t_safety` 一圈的总线账要重算 R1:一条总线上 6 片 INA226(各 2 次事务)+ 1 片 PCA9555(1 次)≈ **1.56 ms / 7.8 %**。 R2:五路各要「选通道 + 读 INA226 V、I + 读 PCA9555 两个输入字节」,加主干那片输入侧 INA226: | 项 | 次数 | 位周期 | @400 kHz | 小计 | |---|---:|---:|---:|---:| | mux 选通道(纯写 1 字节,**不带寄存器号**) | 6 | 20 | 50 µs | 300 µs | | 刀片 INA226 读 V / I(写 1 + 读 2) | 10 | 48 | 120 µs | 1200 µs | | 刀片 PCA9555 读两个输入端口(写 1 + 读 2) | 5 | 48 | 120 µs | 600 µs | | 输入侧 INA226 读 V / I | 2 | 48 | 120 µs | 240 µs | | **合计(23 次事务)** | | | | **2340 µs ⇒ 117 ‰(11.7 %)** | > 这一格不是手算的:`t_safety_cycle_txns()` = 23、`t_safety_budget_us()` = 2340 > 是跑出来的,位周期来自 `hal_i2c_txn_bits()`(START + 每字节 9 位 + 重复 START + STOP)。 > R1 是 13 次事务 / 1560 µs / 78 ‰。 **仍然装得进 20 ms 的节拍**(INA226 一帧 8.8 ms < 20 ms 的前提没变;占用从 7.8 % 涨到 11.7 %), 但 `t_safety_budget_us()` 与拿它对账的测试必须一起改, **并且要把「mux 选通可以跳过(通道没变)」算进去** —— 否则实测值永远比预算小, 测试一开始就对不上。⇒ 预算函数改成「按本圈实际发出的事务算」,测试拿它和实测逐圈对账。 ### 5.6 `t_actuator` 的锁前提被打破了 R1:执行器只碰 GPIO 与 595 ⇒ **与 I²C 无交集 ⇒ 两把锁永远不会嵌套**(`t_actuator.h` 开头那段)。 R2:按键 / EDL / VBUS 全在 PCA9555 上 ⇒ **执行器必须拿 I²C 锁**。 好消息是 595 那把锁**没了**,所以现在**全系统只剩一把锁**,嵌套在物理上不可能。 代价是执行器与巡检争用同一把锁: - 执行器的每一次动作都是「一次 `i2cbus_xact`」,**不许在持锁期间等时间** (按键保持 100 ms 是状态机的事,不是持锁的事); - `t_safety` 拿不到锁就跳过这一圈并计数(规矩没变),5 ms 超时不变; - **`ACT_ST_CLEANUP` 不放弃**这条规矩更重要了:I²C 忙时写不进去必须一直重试, 否则键留在按下状态(`t_actuator.h` 的「最坏的失败」)。 ### 5.7 五路一张表:换了列,没换规矩 ``` INA226 mux_ch DAC KEY 位(PWR/VUP/VDN) EDL位 VBUS位 FLT位 PG位 EFUSE_EN PRSNT# CH1 0x40 0 U6.OUT2 P0.0 P0.1 P0.2 P0.3 P0.4 P1.0 P1.3 IO9 IO15 CH2 0x40 1 U6.OUT0 同上 同上 同上 同上 同上 IO10 IO16 CH3 0x40 2 U7.OUT0 同上 同上 同上 同上 同上 IO11 IO17 CH4 0x40 3 U6.OUT1 同上 同上 同上 同上 同上 IO12 IO18 CH5 0x40 4 U7.OUT1 同上 同上 同上 同上 同上 IO13 IO7 ``` - **DAC 那两列一个字没改**(D11b:DAC 留在底板,仍是 R1 的 `U6` 0x4A / `U7` 0x4B 与通道映射)。 ⚠️ 所以「CH3 在 U7 而不是 U6」这个**不顺排**的坑原样还在。 - `mux_ch = rail − 1` 这一次**确实是顺排的**,`PRSNT#` 那一列**到 CH5 就断了**(IO7)。 两列都写进表里查 —— 顺排是当下的事实,不是不变量。 - `vbus_en` 这一列(GPIO)**删掉**,改成 PCA9555 位(五路相同)。 - `rail_selftest()` 的判据跟着改: - `RAIL_SELFTEST_PINS` 现在查五根 `EFUSE_EN` + 五根 `PRSNT#`(不重复、不越界、不撞 strapping / USB / PSRAM / 不存在的脚); - 新增 **`RAIL_SELFTEST_MUX`**:五个 `mux_ch` 互不重复、都 ≤ 7、都不等于 CH224A 那一路(5)。 ### 5.8 新增的两条安全判据(R2 §8.1 的 A10) | 判据 | 动作 | 为什么 | |---|---|---| | **mux 失联**(选通道连续失败 N 次) | **五路全部断电** | TCA9548A 是单点:它一挂,五路的电压电流**全部看不见**,SR-5 的整条监控链断了。这时「继续供电但看不见」比「停机」危险 | | **`PRSNT#` 为高(刀片不在位)** | 该路**不许上电**,命令一律拒绝并记事件 | 空槽上电 = 金手指裸露带 12V | ⚠️ mux 失联要**先试一次 `MUX_RST#` 脉冲再判**(低有效,给 1 ms)。 一次总线毛刺就停五台机器,代价太大。N 的取值见 §11。 ### 5.9 CH224A(PD 受电):新增的功率上限来源 - 挂在 **mux 通道 5**,地址**待实测确认**(§11)。 - **读 `0x50`** = 当前档位最大电流,单位 50 mA ⇒ 整板功率上限 = 20 V × 该值。 - ⛔ **禁止写 `0x0A = 5`**(请求 28 V EPR):板上 `D3` 是 SMBJ22A、`U37` 的 EN 分压按 20 V 设计, 28 V 会把 TVS 打到雪崩。驱动里**不提供写 `0x0A` 的接口**(和 `sr595` 不提供抬 `OE#` 同一条规矩)。 - 上限拿到之后的动作是 **R1 §8.3 的降载**:输入侧 INA226(0x43)实测功率逼近上限时按优先级关路。 ⚠️ 那片 INA226 **IN+/IN− 在板上是对调接的**(`U13` pin9=`VIN_SW` 上游、pin10=`+12V` 下游), 启动时必须调 `ina226_set_reversed(0x43, true)`,否则正常供电会被 SW-8 判成反灌。 **驱动侧已经做好了,就差启动时那一行。** ### 5.10 风扇:一路 PWM 两只风扇 + 两路测速 + 一个总开关 - `FAN_PWM`(IO47)→ 两个座 `J26`/`J27` 共用; - `FAN_EN`(IO48)→ Q8/Q7 两级 ⇒ **12V 总开关,复位低 = 不转**,固件要主动开; - `FAN_TACH_IN`(IO21)/ `FAN_TACH_OUT`(IO14)→ 各经一级开集三极管**反相**后上拉 ⇒ 数边沿没问题,但**任何「按电平判转没转」的写法都是反的**; - **没有 LM75 了**:温度只有两路 NTC(IO4/IO5,ADC1,R660/R661 上拉分压)。 `t_thermal` 里 LM75 那一路整个删掉。 --- ## 6. Track A —— 无硬件即可完成 ### 6.1 已完成(R1 硬件下写的,逻辑有效) | 包 | 状态 | R2 是否受影响 | |---|---|---| | A1 工程骨架(CMake 双目标 + ctest) | ✅ | 否 | | A2 HAL 接口 + sim 实现 | ✅ | 否(假 mux 是 sim 侧加东西,不改接口) | | A3 INA226 驱动 | ✅ | **是** → R-1 | | A4 DAC63204 驱动 + `rail_set_mv()` | ✅ | **否**(D11 的全部意义) | | A5 74HC595 驱动 | ✅ | ⛔ **退役** → R-2 | | A6 PCA9555 驱动 | ✅ | **是,大改** → R-2 | | A7 五路配置表 | ✅ | **是** → R-3 | | A8 任务骨架与调度 | ✅ | 表不变,**总线预算要重算** → R-1 | | A9 三个执行器状态机 | ✅ | **是**(595 → PCA9555,多一份锁争用)→ R-4 | | A10 安全巡检 | ✅ | **是**(遍历 mux、断 VBUS 走 I²C、两条新判据)→ R-4 | | A11 事件日志 | ✅ | 只加事件类型(往末尾加)→ R-5 | **✅ R-0~R-6 已于 2026-09-16 全部完成**,上面这一列「R2 是否受影响」的每一项都已落地。 ### 6.2 R2 迁移包 ✅ **全部完成(2026-09-16)** > 七包一次会话并行做完(主代理改生产代码,七个子代理分头补测试)。 > 验收:`host/test.sh` **19 个测试 / 351 个用例全绿**,`build_idf.ps1` 通过 > (`phonerail.bin` 0x3fa90,app 分区剩 75 %)。交接见 `firmware/HANDOFF.md`。 > > **迁移期间在生产代码里抓到的三个真缺陷**(都是写测试时撞出来的,不是审出来的): > 1. `check_mux()` 的两段式拿「复位计数器 vs 自己的快照」当状态 ⇒ 永远相等 ⇒ > `MUX_RST#` 每 20 ms 脉冲一次而 `MUX_LOST` 一次不出; > 2. `check_present()` 读的是没人刷新的缓存 ⇒ 拔掉刀片永远判不出来; > 3. 一圈六次锁 × 5 ms = **30 ms > 20 ms 周期** ⇒ 总线繁忙时巡检成片丢拍, > 而每次 `xact` 都按时返回了。现在有 `T_SAFETY_LOCK_BUDGET_MS` 这条整圈的闸。 > > 下面这张表留着,是因为**每一包的验收判据就是它现在的拦网**。 每包 = 一次会话。「入场读」列的是**除 `CLAUDE.md` 与 `HANDOFF.md` 之外**要读的。 | 包 | 目标 | 入场读 | 验收 | |---|---|---|---| | **R-0** | `board_pins.h` 按 §5.1 重写;`i2cbus` 接口 + sim 假 mux(§4.3);TCA9548A 驱动 | §4.3 + §5.1 + `_esp32_pinmap.json` | ①`i2cbus_xact` 在 sim 上选错通道读到的是另一片的值(有一条测试专门证明这件事**看起来完全正常**)②通道影子在写失败后作废 ③`MUX_RST#` 脉冲后影子作废 | | **R-1** | INA226 地址层:五路同址 + `(addr, mux_ch)` 对;`ina226_set_reversed(0x43,true)` 进启动;总线预算按 §5.5 重算 | §5.2 + §5.5 | ①断言五路地址**全是 0x40**、输入侧 0x43 ②反接那片读正常供电为**正** ③预算函数与实测事务数逐圈对上 | | **R-2** | PCA9555 驱动重写:输出支持 + 影子 + §5.3 的初始化顺序;`sr595` 退役 | §5.3 全节(**一个字都不要跳**) | ①`test_config_is_written_last()`:把顺序改反,测试必须变红 ②任何把 P0.5~7 / P1.4~7 配成输出的调用直接 abort ③影子写入点唯一:改 CH1 的键不动别的位 | | **R-3** | 五路表加 `mux_ch` / `PRSNT#`、删 `vbus_en` GPIO、按键位改 PCA9555;`rail_selftest` 加 `RAIL_SELFTEST_MUX` | §5.7 | ①五路表与驱动逐格对账(老规矩)②喂进重复的 `mux_ch` 要被查出来 ③`EFUSE_EN` 与 `PRSNT#` 十根互不重复、不撞保留脚 | | **R-4** | `t_safety` 遍历 mux + 断 VBUS 走 I²C(§5.4)+ 两条新判据(§5.8);`t_actuator` 换 PCA9555 并拿 I²C 锁(§5.6) | §5.4 + §5.6 + §5.8 | ①注入过压 → 20 ms 内关路(判据延迟没变)②注入 −60 mA → 该路 VBUS 断(**且是在持锁的那一段里断的**)③mux 连续失败 → 先脉冲复位、再失败才五路全断 ④`PRSNT#` 为高时上电命令被拒 ⑤执行器与巡检并发跑 1000 圈:`skipped` 有界、键一定松开 | | **R-5** | 事件类型补齐(`MUX_LOST` / `SLOT_EMPTY` / `PD_LIMIT` / `FAN_STALL` …,**只许往末尾加**) | `evlog.h` + HANDOFF 的约定 | `test_flash_format_is_frozen()` 全绿;每个新类型有名字 | | **R-6** | CH224A 驱动 + 整板功率上限(§5.9);`t_thermal` 删 LM75、加 `FAN_EN`、TACH 反相(§5.10) | §5.9 + §5.10 | ①驱动**不提供**写 `0x0A` 的接口 ②`0x50` 读出的档位换成瓦数 ③PWM < 100 % 时不会把「读不到脉冲」误判成停转 | **顺序**:R-0 是硬前置(后面每一包都要用 `i2cbus`);R-1 / R-2 独立可换序; R-3 依赖 R-1+R-2;R-4 依赖 R-3;R-5 / R-6 随时可插。 ⚠️ **R-2 和 R-4 是两个「容易超预算」的包**,开场发现入场清单读完已用掉 12 % 就地拆包。 ### 6.3 剩余的新功能包(R2 口径) | 包 | 目标 | 验收 | |---|---|---| | **A12** | 命令处理器(语义 + 序号判重 + 限幅) | 同序号发两遍只执行一次;越界命令被拒且记事件;**空槽(`PRSNT#` 高)的命令一律拒** | | **A13** | 传输适配器 B:**USB-Serial-JTAG CDC** 分帧(D13:烧录 / 控制台 / 数据同一根线) | 宿主机上用管道灌「命令 + 日志混流」,命令仍能被正确切分 | | **A14** | 传输适配器 A:WiFi/MQTT 骨架 | 协议层用 sim 传输测通;真连网留到 B 段 | | **A15** | 启动顺序 + 降级矩阵(**加 mux / PRSNT / CH224A 三行**,见 §5.4 / §5.8) | 逐个「拔掉」器件,行为与 §10.10 的表逐行对上;五路上电错开 100 ms;`HUB_RST` 只脉冲一次。
⭐ **先把启动序列从 `app_main.c` 里拆出一个宿主机可编的函数**(`board_bringup()` 之类):现在那一段一行也测不到(`app_main.c` 只进 IDF 目标),于是像 `ina226_set_reversed(INA226_ADDR_INPUT, true)` 这种「漏了就把正常供电判成反灌」的单行,**唯一的拦网是人眼**。拆出来之后第一条用例就是它 | | **A16** | 风扇闭环 + TACH 采样窗口(§5.10) | 见 R-6 的验收 | --- ## 7. Track B —— 需要真板 验收判据**不是我定的,是 `需求文档.md` §12.1 那 17 步**。R2 之后多三步(下表打 ⭐R2)。 | 包 | 对应 | 说明 | |---|---|---| | **B0** | 全部 | 把这些步做成一份**逐步 checklist 脚本**(提示 + 记录 + 存档),板子到之前就能写完 | | **B1** | 步 1~2 | 上电、3.3 V、**I²C 扫描**:主干应当只看到 `0x70` / `0x43` / `0x4A` / `0x4B`;**逐通道**扫到 0x40 + 0x20;通道 5 扫到 CH224A(**把真地址填回 §5.2 与 §11**)。⭐ 顺带拿逻辑分析仪看一次**选通字节真的让支路通了**——见 §11 那条「写下去不报错但没生效」 | | ⭐**B1b** | R2 新增 | **PD 取电**:不插刀片,量 20 V 请求成功、`U37` 输出 12.0 V、`0x50` 读数与充电器铭牌对得上 | | ⭐**B1c** | R2 新增 | **五张刀片逐一单插**(一次只插一片,五个槽各走一遍):`PRSNT#` 变低、mux 通道通、`EFUSE_EN` 直连有效;**然后拔掉**,确认固件判出不在位。
⚠️ **必须五个槽都走一遍,不能只试一片**:R-3 的测试证明不了「mux 通道整体错位」——五路的通道号只要互不重复、都不越界、都不等于 5,`rail_selftest()` 就全过(比如 0→1→2→3→4→0 这样循环挪一格)。现象是**每一路读到的都是隔壁那台手机**,量纲范围全正常。固件侧没有第二个独立来源能和这张表对账(不像 INA226 / DAC 地址有驱动那张表兜着),**唯一的判据就是这一步**:只插槽 N 时,只有第 N 路读得到电压,其余四路必须全部读不到 | | **B2** | 步 3~4 | 单路 Buck 空载 3.90 V;DAC 扫全量程与公式误差 < 1 % | | **B3** | 步 5 | **人为断开 R2 → eFuse 断开** ⭐ 核心验证项,单独一次会话 | | **B4** | 步 6 | 0→4 A 阶跃、无 6 kHz 振铃 ⭐ 另一个核心验证项 | | **B5** | 步 7~9 | 满载温升、断电重启时序、五路全装 | | **B6** | 步 12 | 复位安全:手动复位 ESP32,示波器确认全关、**15 路按键无一误触发**(R2 里由 PCA9555 上电高阻保证,要实测) | | **B7** | 步 13~15 | 母线泄放、USB 反灌、关充电后复测。⚠️ **顺带验 §5.4 的降级**:断开 mux 供电时该路 VBUS 确实断不掉 | | **B8** | 步 10~11、16 | 接第一台手机、按键与组合键、有线 adb / Fastboot | | ⭐**B8b** | R2 新增(D13) | **一根线**:一根 USB-C 插 `J23` 后电脑同时看到 5 台手机 + ESP32 的 CDC;`HUB_RST` 误触发的恢复路径(J33 应急口)走一遍 | | **B9** | 步 17 | 五台长稳 24 h + SW-12 的两条链路验收 | > ⚠️ **B3 与 B4 不要和任何别的事放在同一次会话。** --- ## 8. Track C —— 收尾 | 包 | 内容 | 触发条件 | |---|---|---| | **C1** | WiFi 配网流程(USB 下发凭据写 NVS,IF-19) | A14 之后 | | **C2** | OTA:双分区 + 失败回滚 | B9 之后 | | **C3** | 出厂标定流程(两点线性标定存 NVS) | B2 之后 | | **C4** | 文档回填:实测值填回 `需求文档.md` §12.2,坑写进 §10 | 随时 | --- ## 9. 里程碑 | 里程碑 | 判据 | 依赖硬件 | |---|---|---| | **M1 逻辑跑通** | A1~A16 + R-0~R-6 全绿;模拟器上能完成一次完整的「崩溃 → 软重启 → 硬重启」 | ❌ 不需要 | |  └ 进度 | **R-0~R-6 ✅ 完成**(2026-09-16,351 例);A1~A11 ✅;**A12 ✅**(2026-09-27,380 例);**A13 ✅**(同日,390 例,已上板);**A14 + C1 ✅**(同日,400 例,已上板真连网);**A15 ✅**(同日,425 例,已上板:输入电流 +98.5 mA、BOOT 带复位原因);**还欠 A16** | | | **M2 板子活了** | §12.1 的 17 步 + B1b / B1c / B8b 全过,尤其 B3 / B4 | ✅ | | **M3 交付** | 五台 24 h 无重启无告警;双链路验收通过 | ✅ | **M1 完全不依赖打样进度** —— 这是本计划最主要的排期收益。 --- ## 10. 明确不做的事 | # | 不做 | 理由 | |---|---|---| | 1 | 不为「更好测试」去改硬件设计 | 硬件已定稿(R2 §0.1 的 D1~D16) | | 2 | 不在固件里做任何形式的过流保护环路 | SW-N1 | | 3 | 不做上位机 UI | N7 | | 4 | 不追求模拟器的物理精度 | §4.2 的警告 | | 5 | 首版不做 FR-12 电量曲线 | §10.11:只留接口 | | 6 | 不写「跨会话的大重构」 | 违反 §3.3 的 DoD | | 7 | **不保留 R1 的条件编译分支** | §0 的理由:R1 的板子一片都没打样 | | 8 | **不让固件在运行期碰 `HUB_RST`** | D13 代价 2:自断控制台,且下一次烧录也进不来 | | 9 | **不提供写 CH224A `0x0A` 的接口** | §5.9:28 V 会把 `D3` SMBJ22A 打到雪崩 | --- ## 11. 待定项 | 项 | 说明 | |---|---| | **CH224A 的 I²C 从机地址** | R2 §36.2 只写了寄存器(`0x09` 状态 / `0x50` 档位电流 / `0x0A` 禁写),没写地址。**驱动做成地址可配**,B1 扫描时定死,然后回填本表与 §5.2 | | **TCA9548A 失联的判定次数 N** | §5.8 的「连续失败 N 次」。N=1 太敏感(一次毛刺停五台),N=5 就是 100 ms。**B1c 实测总线误码率之后定**,先按 3 写、常量单列 | | ⭐ **选通道「写下去不报错但没生效」要不要加周期性回读** | R-0 的测试写完之后发现的一个**模拟器造不出来的**失效:上拉太弱、某条支路被某个器件拉死时,`tca9548a_select()` 返回 `HAL_OK` 而通道其实没切过去。假器件的写永远生效,所以宿主机上一条都复现不了。真板上的表现是**「一直读到另一片」,而 `i2cbus_select_fail_streak()` 恒为 0 ⇒ §5.8 的 mux 失联判据完全失灵**。
两条路:① B1 上板时用逻辑分析仪实测一次,确认这种失效在这块板上是否可能(`R602`/`R603` 的上拉值、支路电容);② 给 `i2cbus` 加一条**低频回读校验**(比如每 100 圈回读一次控制字,与影子比对)。回读一次是 50 µs,摊到 100 圈可以忽略。**B1 之前不做**——现在加就是为一个还不知道存不存在的失效付复杂度 | | ~~MQTT broker 与 topic 约定~~ | **2026-09-27 改由本项目自定**(需求 D7:broker 在 home 服务器,前后端也由本项目做)。A14 开工时一并定 topic,写回需求 §10.8 | | `input_suspend` 节点路径 | 与需求 V-8 是同一件事,首台机上确认 | | ~~ESP-IDF 版本~~ | **已定 v5.4**(`D:/Users/35138/esp/v5.4/esp-idf`)。构建走 `firmware/build_idf.ps1` | | ~~单元测试框架~~ | **已定 Unity**(`firmware/third_party/unity/`)+ 宿主机 CMake/CTest,工具链 cygwin |