文件
phonerail/固件开发计划.md
zhou yang和Claude Opus 5.5 ae1aad3b7c 固件 C2:OTA 双槽 + 失败回滚(已上板实测正常 / 哈希错 / 回滚三条路径)
- 分区重排(16 MB,nvs 0x9000 不动):ota_0 / ota_1 各 3 MB、otadata 0x610000、evlog 挪到 0x612000;
  flash 大小改正为 16 MB;开 CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
- ota.{h,c}(宿主机可测):URL / sha256 校验、判定(下载中 busy、待验证拒、同镜像不重下)、健康判据
- ota_esp.{h,c}:t_ota 任务经 HTTP 下载、边下边算 sha256、核 project_name 后切启动分区;
  新固件 120 s 内连不上 broker 就回滚(t_link 判 + esp_timer 兜底);重启前等回应发出、事件落盘
- 新命令 ota = 13(两条链路都收,reboot 默认 false:复位 = 五台手机断电一次);status 加 ota 一块;
  新事件 LOG_EV_OTA = 18;PROJECT_VER 取 PHONERAIL_FW_VERSION
- test_ota.c 13 例,全工程 442 例全绿;版本 0.1.0-c2
- 服务器:nginx 7765 只放行 /fw/<16 hex>.bin(不过门户,路由器转发已加);tools/ota_push.py 推送;
  rack protocol.py 加事件 18 / 命令 13(已部署)
- 板 B:推送到自证健康 19 s;错哈希不切分区;删掉 broker 账号后 120 s 回滚,bad 报出被回滚的版本

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 14:03:13 +08:00

566 行
38 KiB
Markdown

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 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` 只脉冲一次。<br>⭐ **先把启动序列从 `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` 直连有效;**然后拔掉**,确认固件判出不在位。<br>⚠️ **必须五个槽都走一遍,不能只试一片**: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:双分区 + 失败回滚(2026-09-27,见需求 §10.8「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 ✅**(同日,429 例,已上板:两路 NTC 读数合理;风扇待插上验 TACH)⇒ **A1~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 失联判据完全失灵**。<br>两条路:① 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 |