文件
phonerail/固件开发计划.md
T
zhou yang和Claude Opus 5.5 b8ad69681f 固件:R2 迁移 R-0 ~ R-6(底板 + 5 片同构刀片)
2026-09-16 完成、当时未提交,这里补提交(原样,不含 09-27 上板的修改)。

- 器件层 / 拓扑层整体从 R1 换到 R2:TCA9548A 分通道、五路同址 INA226 0x40 + 输入侧 0x43、
  刀片 PCA9555(VBUS_EN 挪到 P0.4)、CH224A 只读驱动、风扇闭环 t_thermal
- 新增 i2cbus(mux 原子事务)、slot(刀片 I/O 门面)、host/qtest.sh
- 74HC595 链退役(代码与测试保留)
- 判据阈值与时间常量未动;19 个 ctest 全绿,idf.py build 通过

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 10:26:24 +08:00

37 KiB
原始文件 Blame 文件历史

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 行,超了就是在写日记)

# 交接
更新:<日期> 完成任务包 <编号>

## 一句话现状
<现在能跑到哪一步>

## 下一步
<下一个任务包编号 + 一句话>

## 本次新增/修改的文件
- 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)⇒ 低有效。只在启动自检脉冲一次,运行期不许动(D13 代价 2)
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)、 IO26IO32(模组内部 flash)、IO35IO37(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.0P0.4 输出 1, = 三个键全按下 + EDL 短接 + VBUS 上电,五台手机一起被按住电源键。 而 P0.5~7 / P1.4~7 七个脚在刀片上直接接地、不串限流电阻(上表), 方向写错一位就是对地短路 —— 不报错、不 NAK,只会发烫。 ⇒ 驱动里 pca9555_dir_safe() 的白名单从 R1 的「全输入」改成 **「只有 P0.0P0.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.57 / P1.47 配成输出的调用直接 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 逻辑跑通 A1A16 + R-0R-6 全绿;模拟器上能完成一次完整的「崩溃 → 软重启 → 硬重启」 ❌ 不需要
 └ 进度 R-0~R-6 ✅ 完成(2026-09-16,351 例);A1A11 ✅;**还欠 A12A16**
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 约定 依赖农场既有系统,A14 之前需要对方给出
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