- 启动序列从 app_main.c 拆进 main/bringup.{h,c},宿主机可测:
bringup_devices()(§10.9 第 1~4 步)→ 起 t_safety → bringup_power_restore()(第 5~6 步)
- 六片 INA226 写 Config/Calibration(之前没做,电流恒 0);输入侧 0x43 登记对调接
- 按「意图」逐路上电、错开 100 ms、每路等 PG 500 ms,等不到关回去并记 LOG_EV_PG_TIMEOUT(17)
- 意图只由 power 命令改(cmd_env_t.intent → NVS phonerail/rail_want)
- §10.10 逐行降级:INA/DAC/PCA9555/mux/PRSNT#/CH224A;mux 失联时不碰主干
- BOOT 事件 arg = 复位原因;HUB_RST# 仍只在上电复位时脉冲
- rail_volt_init() 一片失败不再放弃另一片;CH224A 主干 / 通道 5 两处都找
- sim:sim_gpio_rise_us / sim_gpio_set_hook,sim_gpio_drive 可在配置前调
- test_bringup.c 24 例 + test_cmd 意图 1 例,共 425 例全绿;版本 0.1.0-a15
- rack:事件表加 17 PG_TIMEOUT(已部署)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
38 KiB
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 硬规矩(违反其中任何一条,当次会话必然超预算)
-
禁止整篇读四份大文档(
五路直供电源板设计.md/刀片式方案规划-R2.md/PROGRESS.md/需求文档.md)。 需要什么就grep -n那个条目号再sed -n取那一段。 -
板级事实优先读机器可读的缓存,不要读文档:
.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,比读文档便宜两个数量级,而且它就是板子本身。
-
单个源文件控制在 ≈300 行以内。 超了就拆文件 —— 这条是为了让后续会话读得起。
-
构建与测试输出一律
2>&1 | tail -40。 -
长任务后台跑(
run_in_background)只看摘要。 -
一次会话只碰一个任务包。
-
固件会话与 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,规矩三条:
- 应用层不许直接调
hal_i2c_*。 驱动照旧只调hal_i2c_*(它们要能单独测), 但驱动的调用方必须经i2cbus把通道选好并持锁。 i2cbus_select(ch)不是一个可以单独调的公开函数。 对外只暴露i2cbus_xact(ch, fn, arg):拿锁 → 必要时选通道 → 跑fn→ 放锁。 通道号记在影子里,相同通道不重发选通字节(省掉每圈 5 次事务)。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)、
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.4 可以是输出」**,越界一律 P0.5~7 / P1.4~7 七个脚在刀片上直接接地、不串限流电阻(上表),
方向写错一位就是对地短路 —— 不报错、不 NAK,只会发烫。
⇒ 驱动里 pca9555_dir_safe() 的白名单从 R1 的「全输入」改成
**「只有 P0.0abort()。
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 的
U60x4A /U70x4B 与通道映射)。 ⚠️ 所以「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− 在板上是对调接的(
U13pin9=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.sh19 个测试 / 351 个用例全绿,build_idf.ps1通过 (phonerail.bin0x3fa90,app 分区剩 75 %)。交接见firmware/HANDOFF.md。迁移期间在生产代码里抓到的三个真缺陷(都是写测试时撞出来的,不是审出来的):
check_mux()的两段式拿「复位计数器 vs 自己的快照」当状态 ⇒ 永远相等 ⇒MUX_RST#每 20 ms 脉冲一次而MUX_LOST一次不出;check_present()读的是没人刷新的缓存 ⇒ 拔掉刀片永远判不出来;- 一圈六次锁 × 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 |
| 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 |
❌ 不需要 |
| └ 进度 | 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 之前不做——现在加就是为一个还不知道存不存在的失效付复杂度 |
| 2026-09-27 改由本项目自定(需求 D7:broker 在 home 服务器,前后端也由本项目做)。A14 开工时一并定 topic,写回需求 §10.8 | |
input_suspend 节点路径 |
与需求 V-8 是同一件事,首台机上确认 |
已定 v5.4(D:/Users/35138/esp/v5.4/esp-idf)。构建走 firmware/build_idf.ps1 |
|
已定 Unity(firmware/third_party/unity/)+ 宿主机 CMake/CTest,工具链 cygwin |