固件: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]>
这个提交包含在:
zhou yang
2026-09-27 10:26:24 +08:00
共同撰写人 Claude Opus 5.5
父节点 5a80d8a872
当前提交 b8ad69681f
共修改 51 个文件,包含 10882 行新增和 4068 行删除
+3
查看文件
@@ -6,10 +6,13 @@ idf_component_register(
"version.c"
"rail.c"
"rail_cfg.c"
"i2cbus.c"
"slot.c"
"sched.c"
"tasks.c"
"t_safety.c"
"t_actuator.c"
"t_thermal.c"
"evlog.c"
INCLUDE_DIRS "."
REQUIRES phonerail_hal drv)
+120 -12
查看文件
@@ -6,12 +6,17 @@
#include "evlog.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "hal_gpio.h"
#include "hal_i2c.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "i2cbus.h"
#include "ina226.h"
#include "pca9555.h"
#include "phonerail.h"
#include "rail_cfg.h"
#include "sched.h"
#include "slot.h"
#include "t_actuator.h"
#include "t_safety.h"
#include "tasks.h"
@@ -64,10 +69,19 @@ static safety_snap_t s_snap;
static bool act_pg_from_safety(void *ctx, int rail, bool *pg)
{
(void)ctx;
if (!s_snap.io_ok) {
/* ⭐ R2:PCA9555 变成**每路一片**(五片同构、同地址 0x20,靠 mux 分开),
所以快照里的输入也是每路一份 —— R1 那时全板只有一片,只有一份。
⚠️ 这里的 `rail - 1` 是**数组下标**,不是由路号算出来的器件编号
(rail_cfg.h 禁的是后者)。器件是哪一片由 `slot_*` 那一层查表决定。 */
if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) {
return false;
}
*pg = pca9555_rail_pg(&s_snap.io, rail); /* 极性只在驱动里出现一次 */
if (!s_snap.io_ok[rail - 1]) {
return false;
}
/* 极性只在驱动里出现一次。⚠️ R2 的判读函数**不带 rail** —— 五片逐格相同,
位号是板级常量(pca9555.h),传路号进去反而会让人以为五片位表不一样。 */
*pg = pca9555_efuse_pg(&s_snap.io[rail - 1]);
return true;
}
@@ -118,7 +132,10 @@ static void task_thermal(void *arg)
sched_periodic_t tick;
sched_periodic_init(&tick, task_desc(TASK_THERMAL)->period_ms);
for (;;) {
/* A16:两路 NTC + 载板 LM75 → 风扇 PWM 闭环(TR-6 的 TACH 采样窗口)。 */
/* A16:两路 NTC(IO4/IO5)→ 风扇 PWM 闭环(TR-6 的 TACH 采样窗口)。
⚠️ R2 没有 LM75 了(载板整个不存在,计划 §5.10)。
真正的任务体在 `main/t_thermal.c` 里,由 A16 那一包填;
**这里不 include 它**,免得两个人同时改到同一处。 */
sched_periodic_wait(&tick);
}
}
@@ -174,6 +191,33 @@ static void task_log(void *arg)
}
}
/* USB2517I 的复位脉冲 —— **整个固件里唯一碰 `HUB_RST#` 的地方,而且只在启动调一次**。
低有效(板上 R716 上拉到 +3V3_HUB),给 1 ms 足够:手册要的是几十微秒量级。
⛔⛔ **运行期一个字都不许再写它**(R2 D13 代价 2)。ESP32 自己的
USB-Serial-JTAG 就挂在这个 Hub 的第 6 个下行口上,所以复位 Hub =
**自己把自己的控制台拔了**,而且下一次 `idf.py flash` 也进不来
—— 到那时候唯一的退路是 J33 那个 5P 应急串口(UART0,IO43/IO44),
得拆机接线。写一行 "Hub 卡了就复位一下" 的自愈代码,代价是整机变砖。
⚠️ 这一下放在 `rail_en_init()` 之后、其它一切之前:Hub 复位会让 USB 重新枚举,
期间串口日志是丢的。越早做,后面越多的启动日志能被电脑收到。
失败**不 abort**:Hub 没起来只影响调试与手机的数据口,五路供电照常
(§10.2:失联 ≠ 断电)。 */
static void hub_reset_pulse_once(void)
{
hal_err_t err = hal_gpio_config(BOARD_PIN_HUB_RST_N, HAL_GPIO_OUTPUT);
if (err != HAL_OK) {
ESP_LOGE(TAG, "HUB_RST# 配置失败:%s —— 跳过复位脉冲", hal_err_str(err));
return;
}
(void)hal_gpio_write(BOARD_PIN_HUB_RST_N, 0); /* 低 = 复位 */
hal_time_delay_ms(1);
(void)hal_gpio_write(BOARD_PIN_HUB_RST_N, 1); /* 放开,之后再也不动 */
ESP_LOGI(TAG, "USB Hub 已复位一次(运行期不再动 HUB_RST#)");
}
static void spawn(task_id_t id, TaskFunction_t fn)
{
const task_desc_t *d = task_desc(id);
@@ -191,21 +235,32 @@ void app_main(void)
selftest_or_die();
/* 十根 EN 线配成输出并全部拉低。**先于一切外设**:DAC / eFuse 一上电就有
输出,这两根线这时必须已经确定是低的。 */
/* ⭐ R2:**五根** `EFUSE_EN` 配成输出并全部拉低(R1 是十根:另外五根 `VBUS_EN`
已经挪到刀片的 PCA9555 上了)。**先于一切外设**: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 全部关闭");
/* ⚠️ R2:这一步关掉的**只有五路直供**(`EFUSE_EN` 是 GPIO 直连)。
`VBUS_EN` 已经挪到刀片的 PCA9555 P0.4 上了(计划 §5.4),此刻还不受控 ——
它靠的是硬件:PCA9555 复位后全是输入(高阻)+ R701 100 kΩ 下拉 = VBUS 断。
真正"固件说了算"要等到下面的 `slot_init()`。 */
ESP_LOGI(TAG, "五路 eFuse 使能已全部拉低(VBUS 此刻靠下拉电阻关着)");
/* 两把锁必须早于任何一个用总线的任务。 */
/* USB Hub 复位脉冲 —— 只此一次,见函数头上那一段。 */
hub_reset_pulse_once();
/* 锁必须早于任何一个用总线的任务。
⚠️ R2 之后**全系统只剩一把** `HAL_LOCK_I2C`(595 那把随着移位链一起退役),
所以「两把锁嵌套」在物理上不可能了;代价是执行器与巡检开始争同一把锁
(计划 §5.6)。 */
(void)hal_lock_init();
const hal_i2c_cfg_t i2c = {
.sda_gpio = BOARD_PIN_I2C_SDA,
.scl_gpio = BOARD_PIN_I2C_SCL,
.sda_gpio = BOARD_PIN_I2C_SDA, /* ⭐ R2:IO41(R1 是 IO14) */
.scl_gpio = BOARD_PIN_I2C_SCL, /* ⭐ R2:IO42(R1 是 IO15) */
.freq_hz = 400000,
};
err = hal_i2c_init(&i2c);
@@ -214,9 +269,62 @@ void app_main(void)
abort();
}
/* ⚠️ 器件初始化(INA226 的 Config/Calibration、DAC、PCA9555 方向、595 安全值)
与**降级矩阵**是 A15 的启动顺序那一包。在那之前 t_safety 读回来的电流恒为 0
(Calibration 还没写),这是预期的,不是故障。 */
/* ⭐ R2 新增:带 mux 的总线门面。配 `MUX_RST#`、认 TCA9548A(0x70)、把通道全关。
**必须在 `hal_i2c_init()` 与 `hal_lock_init()` 之后**(i2cbus.h)。
⚠️ 失败**不 abort**:mux 认不到是**运行期**的降级情形(计划 §5.8 —— 判据会
先脉冲 `MUX_RST#` 再决定要不要五路全断),不是固件写错了。这与
`selftest_or_die()` 的分界线始终是同一条:常量表错 = abort,器件不在 = 降级。 */
err = i2cbus_init();
if (err != HAL_OK) {
ESP_LOGE(TAG, "TCA9548A(0x%02x) 初始化失败:%s —— 五路的电压电流这一刻全看不见",
(unsigned)TCA9548A_ADDR, hal_err_str(err));
}
/* ⭐ R2 新增:五根 `PRSNT#` 配成上拉输入并立刻读一遍(`slot_prsnt_init()` 自带
一次 poll)。**必须在 `slot_init()` 之前**:下面要拿它决定哪几槽值得初始化。
⚠️ 这一步失败就 abort:它只可能因为引脚号非法而失败,而那正是
`rail_selftest()` 的 `PINS` 位刚刚查过的东西 —— 走到这里还失败 = 固件写错了。
而且降级的方向是**不安全**的那一侧:`slot_present()` 读不到时返回"在位",
于是空槽也会被允许上电 = 金手指裸露带 12V(计划 §5.8)。 */
err = slot_prsnt_init();
if (err != HAL_OK) {
ESP_LOGE(TAG, "PRSNT# 引脚初始化失败:%s", hal_err_str(err));
abort();
}
/* ⭐⭐ R2 必须有的一行(计划 §5.9):底板输入侧那片 INA226(U13, 0x43)的
`IN+`/`IN−` **在板上是对调接的**(pin9 = `VIN_SW` 上游、pin10 = `+12V` 下游)。
漏了这一行,正常供电时它读回来的电流是负的,SW-8 会把「板子在好好工作」
判成**反灌**,于是一上电就断 VBUS —— 而电压、功率、温度全都正常,
日志里只会看到反复的 `LOG_EV_REVERSE`。
⚠️ 这是纯软件状态(驱动里一个标志位),不碰总线,所以放在认件之前也没关系;
但必须早于 `t_safety` 起来,那才是读它的人。 */
ina226_set_reversed(INA226_ADDR_INPUT, true);
/* ⭐ R2 新增:逐片刀片的 PCA9555 初始化(自带 `i2cbus_xact`,各走各的 mux 通道)。
驱动里那个框保证了顺序 **输出锁存器先清零、方向最后改** —— 反过来的那一刻
P0.0~P0.4 全输出 1 = 三个键一起按下 + EDL 短接 + VBUS 上电(计划 §5.3)。
这一步之后 `VBUS_EN` 才真的由固件说了算。
⚠️ 失败**不 abort**(同 `i2cbus_init()`:这是运行期降级)。代价写在 §5.4:
PCA9555 失联 ⇒ 该路 VBUS **关不掉**,兜底只剩关 `EFUSE_EN`。 */
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
if (!slot_present(rail)) {
/* 空槽不去初始化:那一片器件本来就不在,只会白等五次 NAK。
这一路也不许上电 —— 判据在 `t_safety` 里每圈查(计划 §5.8)。 */
ESP_LOGW(TAG, "槽 %d 没插刀片(PRSNT# 为高):跳过初始化,且不许上电", rail);
continue;
}
const hal_err_t e = slot_init(rail);
if (e != HAL_OK) {
ESP_LOGE(TAG, "槽 %d 的 PCA9555(0x%02x) 初始化失败:%s —— 这一路的 VBUS "
"关不掉了,兜底只剩 EFUSE_EN",
rail, (unsigned)PCA9555_ADDR, hal_err_str(e));
}
}
/* ⚠️ 还没做的:INA226 的 Config/Calibration 与 DAC 的上电值(A15 那一包)。
在那之前 t_safety 读回来的电流恒为 0(Calibration 还没写),
这是预期的,不是故障。 */
/* 执行器:PG 从巡检的快照里取,本任务自己不碰 I²C(t_actuator.h 开头那一段)。
⚠️ 必须在 `spawn(TASK_SAFETY, ...)` 之前:判据要关路时会先 `t_actuator_abort()`,
+71 -22
查看文件
@@ -1,16 +1,25 @@
/* ESP32-S3 引脚分配 —— 全固件唯一一份(设计文档 §8.1,2026-09-06 按 PCB 几何
重排的那一版,§26.18)。
/* 底板 ESP32-S3 引脚分配 —— 全固件唯一一份(**R2 底板**,计划 §5.1)。
⚠️ **引脚号是从「板上要去哪儿」倒推的,不是逻辑顺序。** 和 INA226 地址、
595 位号一样:查表,不要算。哪怕看上去像顺排(IO9~IO14 那一段),
也是巧合 —— `EFUSE_EN` 那五根就一个规律都没有(39 / 48 / 6 / 41 / 8)。
PCA9555 位号一样:查表,不要算。
五路各自的 `EFUSE_ENn` / `VBUS_ENn` **不在这里**,在 `rail_cfg.h` 的那张五路表里
(它们是「每路一个」的东西,和 INA 地址、DAC 通道、按键位号是同一类)。
本文件只放**全板独一份**的那些脚。`rail_selftest()` 会把两边合起来查重。
⚠️ **模组脚号 ≠ GPIO 号。** 下面每一行的注释里都写着模组脚号,那是给对着
原理图 / PCB 核对用的;`#define` 的值是 **GPIO 号**,那是给 `hal_gpio_*` 用的。
两者在 WROOM-1 上没有任何规律(脚 17 = IO9,脚 34 = IO41)。
⚠️ 这里的值与 `.claude/pcb1-parts.json` 里 U20(WROOM-1 模组)的真实焊盘网络
逐脚核对过(2026-09-07)。改动之前先回去核一遍,别照着记忆改。 */
五路各自的 `EFUSE_EN` / `PRSNT#` **不在这里**,在 `rail_cfg.h` 的那张五路表里
(它们是「每路一个」的东西)。本文件只放**全板独一份**的那些脚。
`rail_selftest()` 会把两边合起来查重。
⚠️ 这里的值与 `.claude/r2/_base_pcb_nets.json` 里 `U20`(WROOM-1 模组)的真实
焊盘网络逐脚核对过(2026-09-16),并与 `.claude/_esp32_pinmap.json` 的
`r2_base` 对上。改动之前先回去核一遍,别照着记忆改。
---- R1 → R2 变了什么(别拿老记忆改这个文件)----------------------------
· `VBUS_EN1~5` 五根 GPIO **没有了** —— 挪到刀片的 PCA9555 P0.4(计划 §5.4);
· 74HC595 的四根线**没有了** —— 整条链退役(R2 D9);
· 新增 `PRSNT#×5` / `MUX_RST#` / `INA_ALERT` / `HUB_RST` / `FAN_EN` / 第二路 TACH;
· I²C 从 IO14/15 挪到 IO41/42,NTC 从 IO1/2 挪到 IO4/5。 */
#ifndef BOARD_PINS_H
#define BOARD_PINS_H
@@ -20,28 +29,62 @@ extern "C" {
/* ---- 全板独一份的信号(模组脚号 → GPIO) -------------------------------- */
#define BOARD_PIN_I2C_SDA 14 /* 模组脚 22 —— 6×INA226 + 2×DAC + PCA9555(+ 载板 LM75) */
#define BOARD_PIN_I2C_SCL 15 /* 模组脚 8 */
#define BOARD_PIN_FAN_TACH 16 /* 模组脚 9 → R731/R732 分压 */
#define BOARD_PIN_FAN_PWM 18 /* 模组脚 11 → R733 */
#define BOARD_PIN_UP_VBUS_DET 47 /* 模组脚 24 → J24.25,上游 VBUS 有没有插 */
#define BOARD_PIN_NTC1 1 /* 模组脚 39,ADC1_CH0 —— 必须是 IO1~IO10 */
#define BOARD_PIN_NTC2 2 /* 模组脚 38,ADC1_CH1 */
/* I²C 主干:TCA9548A(0x70) + 输入侧 INA226(0x43) + 两片 DAC63204(0x4A/0x4B)。
五片刀片的 INA226/PCA9555 在 mux 的分支上,不直接挂在这条线上。 */
#define BOARD_PIN_I2C_SDA 41 /* 模组脚 34 */
#define BOARD_PIN_I2C_SCL 42 /* 模组脚 35 */
/* USB-Serial-JTAG 的 D±。**S3 上是硬连线,动不了**(模组脚 13/14 → J22)。
下载 / 调试口就是它,板上没有 JTAG 排针、也没有 USB 转串口芯片。
/* TCA9548A 的 `RESET#`:板上 R611 上拉到 +3V3 ⇒ **低有效**。
ESP32 复位期间高阻 = mux 正常工作。 */
#define BOARD_PIN_MUX_RST_N 40 /* 模组脚 33 → U35.3 */
/* 五片刀片 PCA9555 的 `INT#` **线或**(R604 上拉)⇒ 低 = 有某一片报中断,
**看不出是哪一片**。中断只是加速路径:`t_safety` 每 20 ms 无条件读一圈,
因为 PCA9555 的 INT# 要读走输入端口才释放(漏掉一次就再也等不到下一个沿)。 */
#define BOARD_PIN_IOEXP_INT_N 39 /* 模组脚 32 → 五个插槽的 B11 */
/* 底板输入侧 INA226(U13, 0x43)的 ALERT,R605 上拉 ⇒ 低有效。
⚠️ R1 里这根是「六片线或进 PCA9555」,R2 只有这一片,而且直连 GPIO。 */
#define BOARD_PIN_INA_ALERT_N 38 /* 模组脚 31 → U13.3 */
/* USB2517I 的复位,上拉到 +3V3_HUB ⇒ 低有效。
⛔⛔ **运行期一个字都不许写它**(R2 D13 代价 2):ESP32 自己的控制台与烧录口
就挂在这个 Hub 的第 6 个下行口上,误复位 = 自断控制台,而且下一次烧录也进不来。
只在启动自检时脉冲一次;真出事的退路是 J33 那个 5P 应急串口。 */
#define BOARD_PIN_HUB_RST_N 8 /* 模组脚 12 → U32.43 */
#define BOARD_PIN_UP_VBUS_DET 6 /* 模组脚 6 → R721/R722 分压,上游 USB-C 插没插 */
/* 两路 NTC。**必须落在 ADC1(IO1~IO10)**:ADC2 在 WiFi 开着时读不了,
而本板 WiFi 常开。挪到 ADC2 不会报错,只会在连上 WiFi 之后开始读失败。 */
#define BOARD_PIN_NTC1 4 /* 模组脚 4,ADC1_CH3 → RT601 */
#define BOARD_PIN_NTC2 5 /* 模组脚 5,ADC1_CH4 → RT602 */
/* 风扇:一路 PWM 喂两个座(J26/J27),一个 12V 总开关,两路独立测速。
⚠️ `FAN_EN` 复位时高阻 + 下拉 = **风扇不转**,固件必须主动开(Q8 → Q7 两级)。
⚠️ 两路 TACH 各经一级开集三极管(Q9/Q10)**反相**后上拉 ⇒ 数边沿没问题,
但任何「按电平判它转没转」的写法都是反的。 */
#define BOARD_PIN_FAN_PWM 47 /* 模组脚 24 → R733 */
#define BOARD_PIN_FAN_EN 48 /* 模组脚 25 → R748 → Q8 */
#define BOARD_PIN_FAN_TACH_IN 21 /* 模组脚 23 → Q9 集电极(进风扇) */
#define BOARD_PIN_FAN_TACH_OUT 14 /* 模组脚 22 → Q10 集电极(出风扇) */
/* USB-Serial-JTAG 的 D±。**S3 上是硬连线,动不了**(模组脚 13/14)。
R2 里它接的是 **Hub 的第 6 个下行口**(D13:全机只剩一根线到电脑),
不再是一个独立的 USB-C 座。下载 / 调试 / 上位机 CDC 都走它。
⚠️ 永远别烧 `DIS_USB_JTAG` eFuse —— 烧了引脚 JTAG 和 USB 两边都没有。 */
#define BOARD_PIN_USB_DM 19
#define BOARD_PIN_USB_DP 20
/* ---- 不许占用的脚 -------------------------------------------------------- */
/* strapping:挂负载会影响上电模式(S3 只有这四个是)。 */
/* strapping:挂负载会影响上电模式(S3 只有这四个是)。
⚠️ IO0 在 R2 上接着 J33 应急口的下载脚,**更加不能当普通 GPIO 用**。 */
#define BOARD_PINS_STRAPPING \
{ \
0, 3, 45, 46 \
}
/* WROOM-1 的 **R8 版(八线 PSRAM)占用**,留空以便换料。 */
/* WROOM-1-**N16R8** 的八线 PSRAM 占用(2026-09-13 定的料号)。 */
#define BOARD_PINS_RESERVED \
{ \
35, 36, 37 \
@@ -55,9 +98,15 @@ extern "C" {
26, 27, 28, 29, 30, 31, 32 \
}
/* ESP32-S3 的 GPIO 编号上限(IO48 **存在**,`EFUSE_EN2` 就在上面)。
/* UART0(J33 应急口)。**不许当 GPIO 用**:它是 Hub 挂掉之后唯一进得来的路。 */
#define BOARD_PINS_UART0 \
{ \
43, 44 \
}
/* ESP32-S3 的 GPIO 编号上限(IO48 **存在**,`FAN_EN` 就在上面)。
⚠️ HAL 两侧的边界都写成 `pin <= BOARD_PIN_NUM_MAX`;A7 之前那里写的是
`pin < 48`,正好把 IO48 关在门外 —— 现象是 CH2 的 eFuse 既开不了也**关不掉**。 */
`pin < 48`,正好把 IO48 关在门外。 */
#define BOARD_PIN_NUM_MAX 48
#ifdef __cplusplus
+575 -570
查看文件
文件差异内容过多而无法显示 加载差异
+166 -160
查看文件
@@ -1,160 +1,166 @@
/* 事件日志(需求 §10.7)——「RAM 环形缓冲 + 批量落盘 + 按序号增量拉取」。
§10.7 的第一句话是这一节全部设计的来源:**记录路径不能依赖上位机在线**。
排线接触不良、手机把电压顶回来、eFuse 断了 —— 这些事发生时,
最可能同时发生的就是「WiFi 也断了」。所以事件先进 RAM,再由最低优先级的
`t_log` 攒够一批写进 flash;上位机什么时候连上来,什么时候按序号拉走。
---- 三层,各自只干一件事 ------------------------------------------------
1. **入队**(`evlog_put*`):谁产生事件谁调。几条指令,临界区里做完,
**绝不阻塞、绝不碰 flash**。t_safety 是在断电之后调它的(t_safety.h)。
2. **落盘**(`evlog_flush`):只有 `t_log` 调。它是唯一碰 flash 的人。
3. **读回**(`evlog_read`):按全局序号取,给 A12 的 `events 起始序号` 用。
---- 为什么序号不在入队时分配 --------------------------------------------
序号是**记录在 flash 里的位置**算出来的(`epoch × 每扇区条数 + 槽号`),
不是一个自增计数器。这样一来「序号」和「flash 里第几条」永远是同一件事,
重启之后接着数也不用把计数器存起来。代价是**没落盘的事件还没有序号**:
`evlog_read()` 只服务已经落盘的部分,要看最新的就先 `evlog_flush(0)`。
反过来做(入队时发号)会在 RAM 缓冲溢出丢事件时留下一段永远补不上的空号,
而上位机那边的现象是「拉日志卡在某个序号上不动」。
---- 丢事件是可以的,**悄悄丢**不行 --------------------------------------
RAM 缓冲满了就覆盖最旧的(§10.7 的 4096 条同理:满了盖最旧,最新的一条
永远进得去)。被盖掉的那些会在下一次落盘时留下一条 `LOG_EV_LOST`,
`bus_mv` 字段记着丢了多少条。一份会**安静地**少掉几行的日志,
比没有日志更糟 —— 排查的人会拿它当完整的时间线看。 */
#ifndef EVLOG_H
#define EVLOG_H
#include <stdbool.h>
#include <stdint.h>
#include "hal_err.h"
#include "t_safety.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- 事件类型 ------------------------------------------------------------
⚠️⚠️ **这些数字是 flash 里的格式,不是一个内部枚举。** 板子上跑了半年的
日志是按数字存的,改一个值就等于把历史记录全部重新解释了一遍,
而且**读出来仍然是合法的记录**,没有任何一处会报错。
只许在末尾追加,不许插入、不许重排、不许删除。
`test_flash_format_is_frozen()` 把每一个数字都钉死了。 */
typedef enum {
LOG_EV_NONE = 0,
LOG_EV_BOOT = 1, /* 每次上电的第一条。§10.7「上电从 0 计」⇒ 时间戳会跳回去,
没有这一条的话,重启前后的记录混在一起没法读 */
LOG_EV_LOST = 2, /* RAM 缓冲溢出,丢了 bus_mv 条(见文件头) */
/* A10 的四条判据事件。⚠️ 这里的数字与 `safety_ev_t` 的**不一样**,
两边靠 `evlog_from_safety()` 里那个显式 switch 对应 —— 不许写成加个偏移。 */
LOG_EV_OVER_VOLT = 3, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */
LOG_EV_UNDER_VOLT = 4, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */
LOG_EV_REVERSE = 5, /* SW-8:电流转负 → 断这一路 VBUS */
LOG_EV_PUSHED_UP = 6, /* SW-8:VBUS 期间电压被顶高 → 断这一路 VBUS */
/* §10.7 必记事件里**还没人产生**的那几类。号先占住(见上面那条警告),
产生它们的代码各归各的包:括号里是包号。 */
LOG_EV_EFUSE_FAULT = 7, /* eFuse FLT# 拉低(A15) */
LOG_EV_PG_CHANGE = 8, /* PGOOD 变化,arg = 新电平(A15) */
LOG_EV_I2C_LOST = 9, /* I²C 器件失联,arg = 地址(A15) */
LOG_EV_THERMAL_DERATE = 10,/* 过温降载,bus_mv = 温度 m°C(A16) */
LOG_EV_FAN_STALL = 11, /* 风扇停转(A16) */
LOG_EV_REBOOT = 12, /* 每次重启序列,arg = 软/硬 + 谁下的命令(A9/A12) */
LOG_EV_CMD_REJECT = 13, /* 越界命令被拒,arg = 命令号(A12) */
LOG_EV_COUNT,
} log_ev_t;
/* §10.7 的「单条」:时间戳 + 路号 + 事件类型 + 当时的 V/I,正好 16 B。
⚠️ 这个结构体的**内存布局就是 flash 布局**,直接按字节写下去的。
加字段 = 换格式(见上),改字段顺序同理。 */
typedef struct {
uint32_t t_ms; /* 本次上电起算的毫秒(§10.7:上位机连上后下发墙钟做偏移) */
uint8_t rail; /* 1~5;0 = 整板级事件(BOOT / LOST / 风扇 …) */
uint8_t type; /* log_ev_t */
uint8_t arg; /* 事件相关的小参数,没有就 0 */
uint8_t chk; /* 另外 15 个字节的 CRC-8。见 evlog.c「为什么要校验」 */
int32_t bus_mv; /* 出事那一刻的实测值,不是设定值 */
int32_t current_ua;
} evlog_rec_t;
/* RAM 环形缓冲的条数。§10.7 写的是「≥256 条」。
256 × 16 B = 4 KB —— 在 512 KB 的内部 RAM 里可以忽略,而它换来的是
「t_log 被饿住 4 秒(按 §10.7 的量级远不止)也不丢事件」。 */
#define EVLOG_RAM_CAP 256
/* 攒够这么多条就落盘一次。一次 flash 写事务的开销与条数几乎无关,
16 条一批 ⇒ 每条事件的擦写摊薄到 1/16。 */
#define EVLOG_BATCH 16
/* 但最老的那一条最多等这么久 —— 攒批不能把「掉电前记下来」这件事攒没了。
1 s 是 §10.3 里 t_log「空闲时」的量级,也是掉电丢失窗口的上界。 */
#define EVLOG_MAX_AGE_MS 1000
/* 一次 `evlog_flush()` 最多写这么多条。t_log 是最低优先级,长时间占着 flash
本身没有危害,但一次写 4000 条会让**这个函数**变成一个几百毫秒的黑洞,
而它是要被 A12 的命令处理器同步调用的(拉日志之前先 flush)。 */
#define EVLOG_FLUSH_MAX 64
typedef struct {
uint32_t queued; /* 累计入队几条 */
uint32_t stored; /* 累计落盘几条(含补记的 LOST) */
uint32_t dropped; /* 累计被覆盖掉几条(RAM 缓冲溢出) */
uint32_t pending; /* 此刻还在 RAM 里没落盘的条数 */
uint32_t io_errors;/* flash 写/擦失败次数 */
uint32_t oldest_seq; /* flash 里还留着的最老一条的序号 */
uint32_t next_seq; /* 下一条落盘的记录会拿到的序号 */
} evlog_stats_t;
/* 扫一遍 flash 找到接着写的位置,并**入队一条 `LOG_EV_BOOT`**。
幂等:重复调等于「又重启了一次」,会再记一条 BOOT。
返回 HAL_ERR_STATE = 没有那块分区(ESP 上是分区表漏了 `evlog`)——
⚠️ 调用方**不要因此 abort**:日志是排查手段,不是安全链的一环(§10.2)。
这种情况下入队照常工作(事件留在 RAM 里),只是落不了盘。 */
hal_err_t evlog_init(void);
/* 入队一条。**不阻塞、不碰 flash、不会失败** —— 缓冲满了就盖掉最旧的一条
并记一次 `dropped`(下一次落盘时会补一条 LOST)。
任何任务、任何优先级都可以调,包括 t_safety 断电之后的那一下。 */
void evlog_put(uint8_t rail, log_ev_t type, uint8_t arg, int32_t bus_mv, int32_t ua);
/* A10 的事件出口。直接 `t_safety_set_event_sink(evlog_put_safety, NULL)`。
⚠️ 时间戳用的是**事件里带的那个**(判据记的是断电那一刻),不是现在。 */
void evlog_put_safety(void *ctx, const safety_event_t *ev);
/* `safety_ev_t` → `log_ev_t`。落盘格式与判据的枚举是两件事,见 log_ev_t 上的警告。
认不出来的返回 LOG_EV_NONE(不会发生;发生了也不该把这条记录扔掉)。 */
log_ev_t evlog_from_safety(uint8_t safety_ev);
/* 该落盘了吗:攒够 `EVLOG_BATCH` 条,或者最老的一条等了 `EVLOG_MAX_AGE_MS`。
t_log 每一圈问一次,false 就接着睡。 */
bool evlog_due(void);
/* 把 RAM 里的写进 flash。max = 0 表示「现在有多少写多少」(上界就是缓冲的
256 条);t_log 每圈传的是 `EVLOG_FLUSH_MAX`。返回**真的写进 flash 几条**
—— 含一条补记的 LOST,所以它可能比预算多 1(那条是上次欠下的账)。
只有 t_log 与 A12 的拉日志命令调它 —— 它是**唯一**碰 flash 的地方。 */
uint32_t evlog_flush(uint32_t max);
/* 从 from_seq 起,按序号读回**已落盘**的记录(没落盘的先 flush,见文件头)。
返回真的读到几条。from_seq 比 `oldest_seq` 小时从 `oldest_seq` 开始
—— 上位机据此就能发现「中间那一段已经被环形缓冲盖掉了」。 */
uint32_t evlog_read(uint32_t from_seq, evlog_rec_t *out, uint32_t max);
void evlog_stats(evlog_stats_t *st);
/* flash 里最多能留几条(§10.7 要求 ≥4096)。没有分区时返回 0。 */
uint32_t evlog_capacity(void);
/* 只用于日志与测试失败信息,返回静态字符串,不会是 NULL。 */
const char *evlog_ev_str(log_ev_t ev);
#ifdef __cplusplus
}
#endif
#endif /* EVLOG_H */
/* 事件日志(需求 §10.7)——「RAM 环形缓冲 + 批量落盘 + 按序号增量拉取」。
§10.7 的第一句话是这一节全部设计的来源:**记录路径不能依赖上位机在线**。
排线接触不良、手机把电压顶回来、eFuse 断了 —— 这些事发生时,
最可能同时发生的就是「WiFi 也断了」。所以事件先进 RAM,再由最低优先级的
`t_log` 攒够一批写进 flash;上位机什么时候连上来,什么时候按序号拉走。
---- 三层,各自只干一件事 ------------------------------------------------
1. **入队**(`evlog_put*`):谁产生事件谁调。几条指令,临界区里做完,
**绝不阻塞、绝不碰 flash**。t_safety 是在断电之后调它的(t_safety.h)。
2. **落盘**(`evlog_flush`):只有 `t_log` 调。它是唯一碰 flash 的人。
3. **读回**(`evlog_read`):按全局序号取,给 A12 的 `events 起始序号` 用。
---- 为什么序号不在入队时分配 --------------------------------------------
序号是**记录在 flash 里的位置**算出来的(`epoch × 每扇区条数 + 槽号`),
不是一个自增计数器。这样一来「序号」和「flash 里第几条」永远是同一件事,
重启之后接着数也不用把计数器存起来。代价是**没落盘的事件还没有序号**:
`evlog_read()` 只服务已经落盘的部分,要看最新的就先 `evlog_flush(0)`。
反过来做(入队时发号)会在 RAM 缓冲溢出丢事件时留下一段永远补不上的空号,
而上位机那边的现象是「拉日志卡在某个序号上不动」。
---- 丢事件是可以的,**悄悄丢**不行 --------------------------------------
RAM 缓冲满了就覆盖最旧的(§10.7 的 4096 条同理:满了盖最旧,最新的一条
永远进得去)。被盖掉的那些会在下一次落盘时留下一条 `LOG_EV_LOST`,
`bus_mv` 字段记着丢了多少条。一份会**安静地**少掉几行的日志,
比没有日志更糟 —— 排查的人会拿它当完整的时间线看。 */
#ifndef EVLOG_H
#define EVLOG_H
#include <stdbool.h>
#include <stdint.h>
#include "hal_err.h"
#include "t_safety.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- 事件类型 ------------------------------------------------------------
⚠️⚠️ **这些数字是 flash 里的格式,不是一个内部枚举。** 板子上跑了半年的
日志是按数字存的,改一个值就等于把历史记录全部重新解释了一遍,
而且**读出来仍然是合法的记录**,没有任何一处会报错。
只许在末尾追加,不许插入、不许重排、不许删除。
`test_flash_format_is_frozen()` 把每一个数字都钉死了。 */
typedef enum {
LOG_EV_NONE = 0,
LOG_EV_BOOT = 1, /* 每次上电的第一条。§10.7「上电从 0 计」⇒ 时间戳会跳回去,
没有这一条的话,重启前后的记录混在一起没法读 */
LOG_EV_LOST = 2, /* RAM 缓冲溢出,丢了 bus_mv 条(见文件头) */
/* A10 的四条判据事件。⚠️ 这里的数字与 `safety_ev_t` 的**不一样**,
两边靠 `evlog_from_safety()` 里那个显式 switch 对应 —— 不许写成加个偏移。 */
LOG_EV_OVER_VOLT = 3, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */
LOG_EV_UNDER_VOLT = 4, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */
LOG_EV_REVERSE = 5, /* SW-8:电流转负 → 断这一路 VBUS */
LOG_EV_PUSHED_UP = 6, /* SW-8:VBUS 期间电压被顶高 → 断这一路 VBUS */
/* §10.7 必记事件里**还没人产生**的那几类。号先占住(见上面那条警告),
产生它们的代码各归各的包:括号里是包号。 */
LOG_EV_EFUSE_FAULT = 7, /* eFuse FLT# 拉低(A15) */
LOG_EV_PG_CHANGE = 8, /* PGOOD 变化,arg = 新电平(A15) */
LOG_EV_I2C_LOST = 9, /* I²C 器件失联,arg = 地址(A15) */
LOG_EV_THERMAL_DERATE = 10,/* 过温降载,bus_mv = 温度 m°C(A16) */
LOG_EV_FAN_STALL = 11, /* 风扇停转(A16) */
LOG_EV_REBOOT = 12, /* 每次重启序列,arg = 软/硬 + 谁下的命令(A9/A12) */
LOG_EV_CMD_REJECT = 13, /* 越界命令被拒,arg = 命令号(A12) */
/* ⭐ R2 新增(计划 §5.8 / §5.9)。**加在末尾**,前面 13 个的数字一个都没动 ——
flash 里跑过的历史记录因此仍然读得对。 */
LOG_EV_MUX_LOST = 14, /* TCA9548A 连续选不中 ⇒ 五路全断(rail = 0,整板级) */
LOG_EV_SLOT_EMPTY = 15,/* `PRSNT#` 为高:刀片不在位,这一路不许带电 */
LOG_EV_PD_LIMIT = 16, /* PD 档位换了 / 触发限功率降载,bus_mv = 上限 mW/100(R-6) */
LOG_EV_COUNT,
} log_ev_t;
/* §10.7 的「单条」:时间戳 + 路号 + 事件类型 + 当时的 V/I,正好 16 B。
⚠️ 这个结构体的**内存布局就是 flash 布局**,直接按字节写下去的。
加字段 = 换格式(见上),改字段顺序同理。 */
typedef struct {
uint32_t t_ms; /* 本次上电起算的毫秒(§10.7:上位机连上后下发墙钟做偏移) */
uint8_t rail; /* 1~5;0 = 整板级事件(BOOT / LOST / 风扇 …) */
uint8_t type; /* log_ev_t */
uint8_t arg; /* 事件相关的小参数,没有就 0 */
uint8_t chk; /* 另外 15 个字节的 CRC-8。见 evlog.c「为什么要校验」 */
int32_t bus_mv; /* 出事那一刻的实测值,不是设定值 */
int32_t current_ua;
} evlog_rec_t;
/* RAM 环形缓冲的条数。§10.7 写的是「≥256 条」。
256 × 16 B = 4 KB —— 在 512 KB 的内部 RAM 里可以忽略,而它换来的是
「t_log 被饿住 4 秒(按 §10.7 的量级远不止)也不丢事件」。 */
#define EVLOG_RAM_CAP 256
/* 攒够这么多条就落盘一次。一次 flash 写事务的开销与条数几乎无关,
16 条一批 ⇒ 每条事件的擦写摊薄到 1/16。 */
#define EVLOG_BATCH 16
/* 但最老的那一条最多等这么久 —— 攒批不能把「掉电前记下来」这件事攒没了。
1 s 是 §10.3 里 t_log「空闲时」的量级,也是掉电丢失窗口的上界。 */
#define EVLOG_MAX_AGE_MS 1000
/* 一次 `evlog_flush()` 最多写这么多条。t_log 是最低优先级,长时间占着 flash
本身没有危害,但一次写 4000 条会让**这个函数**变成一个几百毫秒的黑洞,
而它是要被 A12 的命令处理器同步调用的(拉日志之前先 flush)。 */
#define EVLOG_FLUSH_MAX 64
typedef struct {
uint32_t queued; /* 累计入队几条 */
uint32_t stored; /* 累计落盘几条(含补记的 LOST) */
uint32_t dropped; /* 累计被覆盖掉几条(RAM 缓冲溢出) */
uint32_t pending; /* 此刻还在 RAM 里没落盘的条数 */
uint32_t io_errors;/* flash 写/擦失败次数 */
uint32_t oldest_seq; /* flash 里还留着的最老一条的序号 */
uint32_t next_seq; /* 下一条落盘的记录会拿到的序号 */
} evlog_stats_t;
/* 扫一遍 flash 找到接着写的位置,并**入队一条 `LOG_EV_BOOT`**。
幂等:重复调等于「又重启了一次」,会再记一条 BOOT。
返回 HAL_ERR_STATE = 没有那块分区(ESP 上是分区表漏了 `evlog`)——
⚠️ 调用方**不要因此 abort**:日志是排查手段,不是安全链的一环(§10.2)。
这种情况下入队照常工作(事件留在 RAM 里),只是落不了盘。 */
hal_err_t evlog_init(void);
/* 入队一条。**不阻塞、不碰 flash、不会失败** —— 缓冲满了就盖掉最旧的一条
并记一次 `dropped`(下一次落盘时会补一条 LOST)。
任何任务、任何优先级都可以调,包括 t_safety 断电之后的那一下。 */
void evlog_put(uint8_t rail, log_ev_t type, uint8_t arg, int32_t bus_mv, int32_t ua);
/* A10 的事件出口。直接 `t_safety_set_event_sink(evlog_put_safety, NULL)`。
⚠️ 时间戳用的是**事件里带的那个**(判据记的是断电那一刻),不是现在。 */
void evlog_put_safety(void *ctx, const safety_event_t *ev);
/* `safety_ev_t` → `log_ev_t`。落盘格式与判据的枚举是两件事,见 log_ev_t 上的警告。
认不出来的返回 LOG_EV_NONE(不会发生;发生了也不该把这条记录扔掉)。 */
log_ev_t evlog_from_safety(uint8_t safety_ev);
/* 该落盘了吗:攒够 `EVLOG_BATCH` 条,或者最老的一条等了 `EVLOG_MAX_AGE_MS`。
t_log 每一圈问一次,false 就接着睡。 */
bool evlog_due(void);
/* 把 RAM 里的写进 flash。max = 0 表示「现在有多少写多少」(上界就是缓冲的
256 条);t_log 每圈传的是 `EVLOG_FLUSH_MAX`。返回**真的写进 flash 几条**
—— 含一条补记的 LOST,所以它可能比预算多 1(那条是上次欠下的账)。
只有 t_log 与 A12 的拉日志命令调它 —— 它是**唯一**碰 flash 的地方。 */
uint32_t evlog_flush(uint32_t max);
/* 从 from_seq 起,按序号读回**已落盘**的记录(没落盘的先 flush,见文件头)。
返回真的读到几条。from_seq 比 `oldest_seq` 小时从 `oldest_seq` 开始
—— 上位机据此就能发现「中间那一段已经被环形缓冲盖掉了」。 */
uint32_t evlog_read(uint32_t from_seq, evlog_rec_t *out, uint32_t max);
void evlog_stats(evlog_stats_t *st);
/* flash 里最多能留几条(§10.7 要求 ≥4096)。没有分区时返回 0。 */
uint32_t evlog_capacity(void);
/* 只用于日志与测试失败信息,返回静态字符串,不会是 NULL。 */
const char *evlog_ev_str(log_ev_t ev);
#ifdef __cplusplus
}
#endif
#endif /* EVLOG_H */
+152
查看文件
@@ -0,0 +1,152 @@
/* 带 mux 的 I²C 门面。契约与三条规矩见 i2cbus.h。 */
#include "i2cbus.h"
#include <stddef.h>
#include "board_pins.h"
#include "hal_gpio.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "tca9548a.h"
/* `MUX_RST#` 的低电平宽度。datasheet 只要求 6 ns,这里给的是「至少 1 ms」。
⚠️ **实际会比 1 ms 长**:ESP 侧 `hal_time_delay_ms()` 是 `vTaskDelay()`,
在 `CONFIG_FREERTOS_HZ=100` 下一个 tick 就是 10 ms(而且它按「至少睡一个
tick」写死)。对这颗器件绰绰有余,但要知道**调用者会被挡住这么久** ——
`i2cbus_recover()` 是 `t_safety` 在放锁之后调的,所以触发恢复的那一圈
巡检会晚一拍。这是可以接受的:走到这条路径时 mux 已经连续失联了,
比起「五路电压电流全看不见还在继续跑」,晚一圈不算什么。 */
#define I2CBUS_RST_MS 1
static bool s_inited;
static bool s_in_xact; /* 嵌套检测。单核上够用,双核上外面还有锁挡着 */
static int s_ch = I2CBUS_CH_UNKNOWN;
static uint32_t s_selects;
static uint32_t s_select_fails;
static uint32_t s_fail_streak;
static uint32_t s_lock_timeouts;
static uint32_t s_recoveries;
int i2cbus_channel(void) { return s_ch; }
uint32_t i2cbus_selects(void) { return s_selects; }
uint32_t i2cbus_select_fails(void) { return s_select_fails; }
uint32_t i2cbus_select_fail_streak(void) { return s_fail_streak; }
uint32_t i2cbus_lock_timeouts(void) { return s_lock_timeouts; }
uint32_t i2cbus_recoveries(void) { return s_recoveries; }
void i2cbus_forget_channel(void) { s_ch = I2CBUS_CH_UNKNOWN; }
/* 选通道。**成功才更新影子**;失败一律把影子作废(规矩 3)。 */
static hal_err_t select_ch(int ch)
{
if (ch == s_ch) {
return HAL_OK; /* 影子命中:一个字节都不发(计划 §5.5 的账就靠这个) */
}
s_selects++;
const hal_err_t err = tca9548a_select(TCA9548A_ADDR, ch);
if (err != HAL_OK) {
s_ch = I2CBUS_CH_UNKNOWN;
s_select_fails++;
if (s_fail_streak < 0xFFFFFFFFu) {
s_fail_streak++;
}
return err;
}
s_ch = ch;
s_fail_streak = 0;
return HAL_OK;
}
hal_err_t i2cbus_init(void)
{
s_inited = false;
s_in_xact = false;
s_ch = I2CBUS_CH_UNKNOWN;
s_selects = 0;
s_select_fails = 0;
s_fail_streak = 0;
s_lock_timeouts = 0;
s_recoveries = 0;
/* `MUX_RST#` 低有效、板上 R611 上拉。先写 1 再配输出会有一个「配成输出时
默认输出 0」的窗口,那就是一次意外复位 —— 所以顺序是:配输出(HAL 两侧
都保证配完是 0)→ 立刻写 1。窗口仍在,但只有几个指令,且此刻还没人用总线。 */
hal_err_t err = hal_gpio_config(BOARD_PIN_MUX_RST_N, HAL_GPIO_OUTPUT);
if (err != HAL_OK) {
return err;
}
err = hal_gpio_write(BOARD_PIN_MUX_RST_N, 1);
if (err != HAL_OK) {
return err;
}
s_inited = true;
/* 认件顺便把通道全关。失败不 abort —— 见头文件。 */
err = hal_lock_take(HAL_LOCK_I2C, HAL_LOCK_FOREVER);
if (err != HAL_OK) {
return err;
}
const hal_err_t probe = tca9548a_probe(TCA9548A_ADDR);
if (probe == HAL_OK) {
s_ch = I2CBUS_CH_TRUNK; /* probe 末尾就是「全关」 */
} else {
s_ch = I2CBUS_CH_UNKNOWN;
}
hal_lock_give(HAL_LOCK_I2C);
return probe;
}
hal_err_t i2cbus_xact(int ch, uint32_t timeout_ms, i2cbus_fn_t fn, void *arg)
{
if (!s_inited || fn == NULL) {
return HAL_ERR_STATE;
}
if (s_in_xact) {
return HAL_ERR_STATE; /* 嵌套:回调里又调了一次(头文件末尾那条警告) */
}
if (ch < I2CBUS_CH_TRUNK || ch >= TCA9548A_CH_COUNT) {
return HAL_ERR_ARG;
}
hal_err_t err = hal_lock_take(HAL_LOCK_I2C, timeout_ms);
if (err != HAL_OK) {
s_lock_timeouts++;
return HAL_ERR_TIMEOUT; /* 这一次什么也没做 —— 调用方决定重试还是跳过 */
}
s_in_xact = true;
err = select_ch(ch);
if (err == HAL_OK) {
err = fn(arg);
}
s_in_xact = false;
hal_lock_give(HAL_LOCK_I2C);
return err;
}
hal_err_t i2cbus_recover(void)
{
if (!s_inited) {
return HAL_ERR_STATE;
}
if (s_in_xact) {
return HAL_ERR_STATE; /* 持着锁去睡 1 ms,会把巡检钉住 */
}
s_recoveries++;
hal_err_t err = hal_gpio_write(BOARD_PIN_MUX_RST_N, 0);
if (err != HAL_OK) {
i2cbus_forget_channel();
return err;
}
hal_time_delay_ms(I2CBUS_RST_MS);
err = hal_gpio_write(BOARD_PIN_MUX_RST_N, 1);
/* ⚠️ **无论成功与否都作废影子**:复位之后器件里的通道是全关的,
而影子还记着上一次选的那一路。不作废 = 下一次事务跳过选通、
发到一个断开的支路上,现象是「所有刀片一起失联」。 */
i2cbus_forget_channel();
return err;
}
+92
查看文件
@@ -0,0 +1,92 @@
/* `i2cbus` —— 带 mux 的 I²C 总线门面(R2 新增,计划 §4.3)。
R2 之后,一次「读 CH3 的电压」在总线上其实是两件事:
**先写 TCA9548A 选通道 2,再读 0x40 的寄存器**。
这两件事之间如果被另一个任务插进来选了别的通道,读回来的是**另一台手机的
数据** —— 而且量纲、范围、变化趋势全部正常,没有任何一层会报错。
这是 R2 引入的、最难查的一类静默错误(C-d)。
所以这一层只做一件事:**把「选通道 + 事务」变成一个不可分的动作。**
---- 三条规矩 ------------------------------------------------------------
1. **应用层不许直接调 `hal_i2c_*`。** 驱动照旧只调 `hal_i2c_*`(它们要能单独
测),但驱动的**调用方**必须经 `i2cbus_xact()`。
2. **`i2cbus_select()` 不是公开接口。** 对外只有 `i2cbus_xact(ch, ...)`:
拿锁 → 必要时选通道 → 跑回调 → 放锁。
3. **通道影子在任何一次选通失败之后作废**(置 `I2CBUS_CH_UNKNOWN`)。
不作废的话,下一次「通道相同、跳过选通」会把事务发到一个其实没切过去的
通道上 —— 这正好是第 1 条要防的那种错,只是由自己制造。
---- 为什么要有通道影子 --------------------------------------------------
`t_safety` 每 20 ms 跑一圈五路,如果每次 `xact` 都无条件发一遍选通字节,
一圈就多 5 次事务。影子让「通道没变就不重发」,代价是上面第 3 条那个
必须小心处理的不变量。这笔账在计划 §5.5 里算过。
⚠️ **回调里不许再调 `i2cbus_xact()`**(会嵌套拿同一把锁,直接 HAL_ERR_STATE),
也不许在里面睡 —— 它是持着总线锁跑的。 */
#ifndef I2CBUS_H
#define I2CBUS_H
#include <stdbool.h>
#include <stdint.h>
#include "hal_err.h"
#include "tca9548a.h"
#ifdef __cplusplus
extern "C" {
#endif
/* 主干:所有支路断开。DAC63204 ×2 与输入侧 INA226 挂在这里。 */
#define I2CBUS_CH_TRUNK TCA9548A_CH_NONE /* -1 */
/* 影子失效 —— 下一次 `xact` 无条件重发选通字节。 */
#define I2CBUS_CH_UNKNOWN (-2)
/* 一次事务的正文。**持着 I²C 锁、通道已经选好**的情况下被调一次。
返回值原样成为 `i2cbus_xact()` 的返回值(选通道失败时它根本不会被调)。 */
typedef hal_err_t (*i2cbus_fn_t)(void *arg);
/* 配 `MUX_RST#` 为输出并拉高(不复位)、认件、把通道全关。
⚠️ 必须在 `hal_i2c_init()` 与 `hal_lock_init()` 之后调。
认件失败照样返回错误码,**但不 abort**:mux 挂了是运行期的降级情形
(计划 §5.8),不是固件写错了。 */
hal_err_t i2cbus_init(void);
/* 三件事一气呵成:拿锁(等 `timeout_ms`)→ 必要时选通道 → 跑 `fn` → 放锁。
返回:
HAL_ERR_STATE —— 没 init,或者**嵌套调用**(回调里又调了一次);
HAL_ERR_TIMEOUT —— 没拿到锁(这一次什么也没做,调用方自己决定重试还是跳过);
选通道的错误码 —— `fn` 一次都没被调;
否则 = `fn` 的返回值。 */
hal_err_t i2cbus_xact(int ch, uint32_t timeout_ms, i2cbus_fn_t fn, void *arg);
/* ---- 观测与恢复 ---------------------------------------------------------- */
int i2cbus_channel(void); /* 当前影子:0~7 / `I2CBUS_CH_TRUNK` / `I2CBUS_CH_UNKNOWN` */
/* **连续**选通失败次数(成功一次就清零)。计划 §5.8 的「mux 失联」判据读它。
⚠️ 判据用「连续」而不是「累计」:一次总线毛刺不该停掉五台手机。 */
uint32_t i2cbus_select_fail_streak(void);
/* 累计量,给遥测与测试用。 */
uint32_t i2cbus_selects(void); /* 真正发出去的选通字节数(影子命中不算) */
uint32_t i2cbus_select_fails(void); /* 累计失败次数 */
uint32_t i2cbus_lock_timeouts(void);
uint32_t i2cbus_recoveries(void);
/* `MUX_RST#` 拉低一下再放开,然后把通道影子作废。
判据在「五路全断」之前必须先试这一次(计划 §5.8)。
⚠️ 自己拿锁,**不许在 `xact` 的回调里调**。 */
hal_err_t i2cbus_recover(void);
/* 影子作废。给 `i2cbus_recover()` 与测试用;正常路径不需要。 */
void i2cbus_forget_channel(void);
#ifdef __cplusplus
}
#endif
#endif /* I2CBUS_H */
+105 -92
查看文件
@@ -1,4 +1,4 @@
/* 五路配置表 + 启动自检 + 两根 EN 线。契约与来历见 rail_cfg.h。 */
/* 五路配置表 + 启动自检 + `EFUSE_EN`。契约与来历见 rail_cfg.h。 */
#include "rail_cfg.h"
#include <assert.h>
@@ -9,27 +9,31 @@
#include "hal_gpio.h"
#include "ina226.h"
#include "pca9555.h"
#include "sr595.h"
#include "slot.h"
#include "tca9548a.h"
/* ---- 那张表 --------------------------------------------------------------
六列的出处:INA 地址 §8.2;DAC 片/通道 §8.2 + §26.18;按键位与 EDL 位
§17.2 + §26.18 C(从 `.claude/pcb1-parts.json` 的真实焊盘网络逐脚核过);
两根 EN 的 GPIO §8.1(同样按 U20 的焊盘网络核过,2026-09-07)。
六列的出处(全部 2026-09-16 从板上缓存逐格核过):
· INA 地址 —— `.claude/r2/_blade_pcb_nets.json` 的 `U3` pin1/pin2 都接 GND ⇒ 0x40
· mux 通道 —— `.claude/r2/_base_pcb_nets.json` 的 `U35`:SD0/SC0=SDA1/SCL1 → 槽 1,
一路到 SD4/SC4=SDA5/SCL5 → 槽 5(通道 5 给 CH224A,通道 6/7 悬空)
· DAC 片/通道 —— R1 §8.2 + §26.18,**D11b 之后一个字没改**
· `EFUSE_EN` / `PRSNT#` —— `_base_pcb_nets.json` 的 `U20`(模组脚 → GPIO 见 board_pins.h)
⚠️ **一列都不许改成算式。** 六列里没有一列是顺排的。 */
⚠️ 顺排的那几列也不许改成算式,`PRSNT#` 那一列就是现成的反例。 */
static const rail_cfg_t k_cfg[PHONERAIL_RAIL_COUNT] = {
/* INA DAC片 ch EFUSE VBUS PWR VUP VDN EDL FLT PG */
/* CH1 */ { 0x40, 0x4A, 2, 39, 40, { 1, 2, 3 }, 16, 0, 0 },
/* CH2 */ { 0x41, 0x4A, 0, 48, 10, { 19, 17, 18 }, 7, 1, 1 },
/* CH3 */ { 0x44, 0x4B, 0, 6, 11, { 22, 21, 20 }, 6, 2, 2 },
/* CH4 */ { 0x45, 0x4A, 1, 41, 42, { 9, 10, 11 }, 0, 3, 3 },
/* CH5 */ { 0x42, 0x4B, 1, 8, 17, { 15, 14, 13 }, 5, 4, 4 },
/* INA mux DAC片 ch EFUSE_EN PRSNT# */
/* CH1 */ { INA226_ADDR_RAIL, 0, 0x4A, 2, 9, 15 },
/* CH2 */ { INA226_ADDR_RAIL, 1, 0x4A, 0, 10, 16 },
/* CH3 */ { INA226_ADDR_RAIL, 2, 0x4B, 0, 11, 17 },
/* CH4 */ { INA226_ADDR_RAIL, 3, 0x4A, 1, 12, 18 },
/* CH5 */ { INA226_ADDR_RAIL, 4, 0x4B, 1, 13, 7 }, /* ⚠️ PRSNT5# 不接着顺排 */
};
_Static_assert(PHONERAIL_RAIL_COUNT == INA226_RAIL_COUNT, "五路数对不上 INA226");
_Static_assert(PHONERAIL_RAIL_COUNT == DAC63204_RAIL_COUNT, "五路数对不上 DAC63204");
_Static_assert(PHONERAIL_RAIL_COUNT == PCA9555_RAIL_COUNT, "五路数对不上 PCA9555");
_Static_assert(PHONERAIL_RAIL_COUNT == PCA9555_SLOT_COUNT, "五路数对不上 PCA9555 实例数");
const rail_cfg_t *rail_cfg(int rail)
{
@@ -37,9 +41,8 @@ const rail_cfg_t *rail_cfg(int rail)
return &k_cfg[rail - 1];
}
/* ---- 自检之一:本表与四个驱动的表逐格对账 --------------------------------
分家的现象是「命令发给了另一路」,功能测试测不出来,所以这里是**逐格**比,
不是抽查。四个驱动的取值函数越界会 abort,但这里的 rail 恒在 1~5。 */
/* ---- 自检之一:本表与驱动的表逐格对账 ------------------------------------
分家的现象是「命令发给了另一路」,功能测试测不出来,所以这里是**逐格**比。 */
static bool table_matches_drivers(const rail_cfg_t *t)
{
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
@@ -51,27 +54,36 @@ static bool table_matches_drivers(const rail_cfg_t *t)
if (c->dac_addr != dac63204_rail_addr(rail) || c->dac_ch != dac63204_rail_ch(rail)) {
return false;
}
for (int k = 0; k < SR595_KEY_COUNT; k++) {
if (c->key_bit[k] != (uint8_t)sr595_key_bit(rail, (sr595_key_t)k)) {
return false;
}
return true;
}
/* ---- 自检之二:mux 通道(R2 新增) ---------------------------------------
三条:都在 0~7 之内、五路互不重复、**都不等于 PD 那一路**。
第三条不是洁癖:通道 5 上是 CH224A,选到它再去读 0x40 会读不到任何东西,
而现象是「这一路的电压电流突然全是 0」—— 看上去像刀片拔了。 */
static bool mux_ok(const rail_cfg_t *t)
{
for (int i = 0; i < PHONERAIL_RAIL_COUNT; i++) {
const uint8_t ch = t[i].mux_ch;
if (ch >= TCA9548A_CH_COUNT) {
return false;
}
if (ch == TCA9548A_CH_PD) {
return false;
}
for (int j = i + 1; j < PHONERAIL_RAIL_COUNT; j++) {
if (t[j].mux_ch == ch) {
return false; /* 两路指向同一片刀片 */
}
}
if (c->edl_bit != (uint8_t)sr595_edl_bit(rail)) {
return false;
}
if (c->flt_bit != (uint8_t)pca9555_flt_bit(rail)) {
return false;
}
if (c->pg_bit != (uint8_t)pca9555_pg_bit(rail)) {
return false;
}
}
return true;
}
/* ---- 自检之二:引脚无重复、无越界 ----------------------------------------
/* ---- 自检之三:引脚无重复、无越界 ----------------------------------------
查的是**全板**,不只是五路那十个脚:重复的两个脚一定分属两个模块,
只查自己那十个的话,「`VBUS_EN2` 写成了 IO14」这种错(撞上 SDA)查不出来。 */
只查自己那十个的话,「`PRSNT3#` 写成了 IO41」这种错(撞上 SDA)查不出来。 */
static bool pin_in(const int *list, size_t n, int pin)
{
@@ -88,6 +100,7 @@ static bool pin_usable(int pin)
static const int absent[] = BOARD_PINS_ABSENT;
static const int strapping[] = BOARD_PINS_STRAPPING;
static const int reserved[] = BOARD_PINS_RESERVED;
static const int uart0[] = BOARD_PINS_UART0;
if (pin < 0 || pin > BOARD_PIN_NUM_MAX) {
return false;
@@ -99,7 +112,10 @@ static bool pin_usable(int pin)
return false;
}
if (pin_in(reserved, sizeof(reserved) / sizeof(reserved[0]), pin)) {
return false; /* R8 版八线 PSRAM 占着,留空以便换料 */
return false; /* N16R8 的八线 PSRAM 占着 */
}
if (pin_in(uart0, sizeof(uart0) / sizeof(uart0[0]), pin)) {
return false; /* J33 应急口:Hub 挂掉之后唯一进得来的路(R2 D13) */
}
return true;
}
@@ -107,28 +123,24 @@ static bool pin_usable(int pin)
static bool pins_ok(const rail_cfg_t *t)
{
/* 全板用掉的每一个脚。顺序无所谓,这里只查「都能用」和「不重复」。 */
int used[2 * PHONERAIL_RAIL_COUNT + 14];
int used[2 * PHONERAIL_RAIL_COUNT + 11];
size_t n = 0;
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
used[n++] = t[rail - 1].efuse_en;
used[n++] = t[rail - 1].vbus_en;
used[n++] = t[rail - 1].prsnt_n;
}
used[n++] = BOARD_PIN_I2C_SDA;
used[n++] = BOARD_PIN_I2C_SCL;
used[n++] = BOARD_PIN_FAN_TACH;
used[n++] = BOARD_PIN_FAN_PWM;
used[n++] = BOARD_PIN_MUX_RST_N;
used[n++] = BOARD_PIN_IOEXP_INT_N;
used[n++] = BOARD_PIN_INA_ALERT_N;
used[n++] = BOARD_PIN_HUB_RST_N;
used[n++] = BOARD_PIN_UP_VBUS_DET;
used[n++] = BOARD_PIN_NTC1;
used[n++] = BOARD_PIN_NTC2;
used[n++] = BOARD_PIN_USB_DM;
used[n++] = BOARD_PIN_USB_DP;
/* 四根移位链的线与 PCA9555 的中断脚:**引用驱动里的宏,不重抄一遍数字**。 */
used[n++] = SR595_PIN_SER;
used[n++] = SR595_PIN_SRCLK;
used[n++] = SR595_PIN_RCLK;
used[n++] = SR595_PIN_OE_N;
used[n++] = PCA9555_PIN_INT_N;
used[n++] = BOARD_PIN_FAN_PWM;
used[n++] = BOARD_PIN_FAN_EN;
assert(n == sizeof(used) / sizeof(used[0]));
for (size_t i = 0; i < n; i++) {
@@ -142,6 +154,29 @@ static bool pins_ok(const rail_cfg_t *t)
}
}
/* 两路 TACH 与 USB 的 D± 单独查:TACH 会与上面那些撞,而 D± 是硬连线,
它们只要不和五路那十个脚撞就行。 */
const int more[] = { BOARD_PIN_FAN_TACH_IN, BOARD_PIN_FAN_TACH_OUT };
for (size_t k = 0; k < sizeof(more) / sizeof(more[0]); k++) {
if (!pin_usable(more[k])) {
return false;
}
for (size_t i = 0; i < n; i++) {
if (used[i] == more[k]) {
return false;
}
}
}
if (BOARD_PIN_FAN_TACH_IN == BOARD_PIN_FAN_TACH_OUT) {
return false;
}
/* 原生 USB 的两个脚是硬连线(IO19/IO20),谁也不能占。 */
for (size_t i = 0; i < n; i++) {
if (used[i] == BOARD_PIN_USB_DM || used[i] == BOARD_PIN_USB_DP) {
return false;
}
}
/* 两路 NTC 必须在 ADC1(IO1~IO10)上:ADC2 在 WiFi 开着的时候读不了,
而本板 WiFi 是常开的(§10.8 传输 A)。挪到 ADC2 上不会报错,
只会在连上 WiFi 之后开始读失败 —— 台架上很难复现。 */
@@ -158,7 +193,7 @@ uint32_t rail_selftest_table(const rail_cfg_t *t)
uint32_t bad = 0;
if (t == NULL) {
return RAIL_SELFTEST_TABLE | RAIL_SELFTEST_PINS;
return RAIL_SELFTEST_TABLE | RAIL_SELFTEST_PINS | RAIL_SELFTEST_MUX;
}
if (!table_matches_drivers(t)) {
bad |= RAIL_SELFTEST_TABLE;
@@ -166,6 +201,9 @@ uint32_t rail_selftest_table(const rail_cfg_t *t)
if (!pins_ok(t)) {
bad |= RAIL_SELFTEST_PINS;
}
if (!mux_ok(t)) {
bad |= RAIL_SELFTEST_MUX;
}
return bad;
}
@@ -173,17 +211,14 @@ uint32_t rail_selftest(void)
{
uint32_t bad = 0;
/* 四个驱动各自的表(A3~A6 都留了这个谓词,A7 是它们的归宿)。 */
/* 三个驱动各自的表(A3/A4/R-2 都留了这个谓词)。 */
if (!ina226_addr_table_ok()) {
bad |= RAIL_SELFTEST_INA;
}
if (!dac63204_map_ok()) {
bad |= RAIL_SELFTEST_DAC;
}
if (!sr595_map_ok()) {
bad |= RAIL_SELFTEST_SR595;
}
if (!pca9555_map_ok() || !pca9555_addr_ok()) {
if (!pca9555_map_ok() || !pca9555_addr_ok() || !tca9548a_addr_ok()) {
bad |= RAIL_SELFTEST_PCA9555;
}
return bad | rail_selftest_table(k_cfg);
@@ -196,10 +231,10 @@ const char *rail_selftest_str(uint32_t bit)
return "INA226 地址表";
case RAIL_SELFTEST_DAC:
return "DAC63204 映射表";
case RAIL_SELFTEST_SR595:
return "74HC595 位表";
case RAIL_SELFTEST_MUX:
return "mux 通道重复或越界";
case RAIL_SELFTEST_PCA9555:
return "PCA9555 位表/地址";
return "PCA9555/TCA9548A 位表或地址";
case RAIL_SELFTEST_TABLE:
return "五路表与驱动分家";
case RAIL_SELFTEST_PINS:
@@ -209,50 +244,39 @@ const char *rail_selftest_str(uint32_t bit)
}
}
/* ---- 两根 EN 线 ---------------------------------------------------------- */
/* ---- `EFUSE_EN` ---------------------------------------------------------- */
/* 高有效。**全固件唯一一处极性**(和 `sr595_set_hub_reset()` 里那个反相、
`rail_mv_to_code()` 里那个减法同一个道理:多一处就多一处写反的机会)。 */
/* 高有效。**全固件唯一一处极性**(和 `rail_mv_to_code()` 里那个减法、
`slot.c` 里 VBUS 那一处同一个道理:多一处就多一处写反的机会)。 */
static hal_err_t en_write(int pin, bool on)
{
return hal_gpio_write(pin, on ? 1 : 0);
}
/* 两根线的影子(契约见 rail_cfg.h)。写失败时**不更新** —— 那一次的线上电平
是未知的,而「保留上一次成功的意图」在两个方向上都倒向安全的那一侧。 */
/* 影子(契约见 rail_cfg.h)。写失败时**不更新**。 */
static bool s_efuse_on[PHONERAIL_RAIL_COUNT];
static bool s_vbus_on[PHONERAIL_RAIL_COUNT];
bool rail_efuse_is_on(int rail)
{
return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT && s_efuse_on[rail - 1];
}
bool rail_vbus_is_on(int rail)
{
return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT && s_vbus_on[rail - 1];
}
hal_err_t rail_en_init(void)
{
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
const rail_cfg_t *c = &k_cfg[rail - 1];
const int pins[2] = { c->efuse_en, c->vbus_en };
const int pin = k_cfg[rail - 1].efuse_en;
for (int i = 0; i < 2; i++) {
hal_err_t err = hal_gpio_config(pins[i], HAL_GPIO_OUTPUT);
if (err != HAL_OK) {
return err;
}
/* 配成输出的那一刻两边的 HAL 都已经在输出 0 了,这一句是把「关」
写成明文 —— 别让「五路上电即全关」依赖某个默认值。 */
err = en_write(pins[i], false);
if (err != HAL_OK) {
return err;
}
hal_err_t err = hal_gpio_config(pin, HAL_GPIO_OUTPUT);
if (err != HAL_OK) {
return err;
}
/* 配成输出的那一刻两边的 HAL 都已经在输出 0 了,这一句是把「关」
写成明文 —— 别让「五路上电即全关」依赖某个默认值。 */
err = en_write(pin, false);
if (err != HAL_OK) {
return err;
}
s_efuse_on[rail - 1] = false;
s_vbus_on[rail - 1] = false;
}
return HAL_OK;
}
@@ -269,26 +293,15 @@ hal_err_t rail_efuse_enable(int rail, bool on)
return err;
}
hal_err_t rail_vbus_enable(int rail, bool on)
{
if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) {
return HAL_ERR_ARG;
}
const hal_err_t err = en_write(k_cfg[rail - 1].vbus_en, on);
if (err == HAL_OK) {
s_vbus_on[rail - 1] = on;
}
return err;
}
hal_err_t rail_all_off(void)
{
hal_err_t first = HAL_OK;
/* 先断五路 VBUS,再关五路 eFuse:反过来的话中间有一小段「手机只靠 USB
供着电」,那既不是开也不是关。十个都要试,不许中途 return。 */
/* 先断五路 VBUS(走 I²C,可能失败),再关五路 eFuse(GPIO,永远成功)。
反过来的话中间有一小段「手机只靠 USB 供着电」,那既不是开也不是关。
十次都要试,不许中途 return —— 关电路径上「剩下的也别关了」是最坏的选择。 */
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
hal_err_t err = rail_vbus_enable(rail, false);
hal_err_t err = slot_vbus_enable(rail, false);
if (err != HAL_OK && first == HAL_OK) {
first = err;
}
+65 -59
查看文件
@@ -1,35 +1,40 @@
/* 五路一张表(需求 §10.4)—— **禁止用通道号算任何东西**。
/* 五路一张表(需求 §10.4,**R2 版**)—— **禁止用通道号算任何东西**。
板上和「第几路」有关的编号有六组,**没有一组是顺排的,也没有两组是一致的**:
---- R1 → R2:这条规矩换了对象,没有作废 --------------------------------
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
R1 的板子上,和「第几路」有关的编号有六组,没有一组是顺排的。
R2 把五路做成了**五片完全相同的刀片**,于是:
六组编号各有各的来历(I²C 地址按布线难度、595 位号按 PCB 几何、GPIO 按出线
方向),三条都在设计文档里:§8.2 / §17.2 + §26.18 C / §8.1。
· INA226 地址:五路**全是 0x40**(五片同构,靠 mux 区分)
· PCA9555 地址与位号:五路**逐格相同**(所以根本不进这张表,在 pca9555.h)
· DAC 片/通道:**一个字没改**,仍然是 R1 那张不顺排的表
(⚠️ CH3 在 `U7` 而不是 `U6`,这个坑原样还在)
· mux 通道:0 1 2 3 4,**这次真的是顺排的**
· `EFUSE_EN`:IO9 IO10 IO11 IO12 IO13,也是顺排的(R2 §29.5 为布线重排过)
· `PRSNT#`:IO15 IO16 IO17 IO18 **IO7** —— **到第五路就断了**(方案 B 重排)
顺排的那三列仍然写进表里查,理由是:**顺排是当下的事实,不是不变量**。
`PRSNT#` 那一列就是现成的反例 —— 四路顺排、第五路不是。
⚠️ **这一片写错了,板子照样正常工作** —— 只是「重启 CH1」重启了 CH4、
「读 CH3 电流」读的是 CH5。功能测试测不出来,五台手机跑起来也看不出来,
要等到某一路真出故障、而你关掉了另一路的时候才暴露。所以:
「读 CH3 电流」读的是 CH5。功能测试测不出来,五台手机跑起来也看不出来。
**除了本文件里这张表,任何地方都不许出现 `0x40 + rail`、`rail - 1`
这类由通道号算出来的器件编号。**
**除了本文件里这张表,任何地方都不许出现 `rail - 1`、`0x40 + rail`
这类由通道号算出来的器件编号 / 通道号。**
---- 这张表和四个驱动里那四张表是什么关系 ----------------------------------
---- 不在这张表里的东西 --------------------------------------------------
A3~A6 四个驱动各自带着自己那一张表(`ina226_rail_addr()` 等),因为驱动要能
单独测。本表是**把它们横着拼起来**的那一张,多出来的只有两列 GPIO
(`EFUSE_EN` / `VBUS_EN` 是 GPIO 直连,没有驱动可挂)。
| 东西 | 在哪 | 为什么不在这里 |
|---|---|---|
| 三个按键位 / EDL / VBUS 位 | `pca9555.h` | 五路逐格相同,是「板级常量」不是「每路一个」 |
| `VBUS_EN` 的 GPIO | **没有了** | R2 里它在刀片的 PCA9555 P0.4 上(计划 §5.4) |
| 24 位移位链 | **没有了** | R2 D9:整条链退役 |
两处写同一个数就有分家的风险,所以 `rail_selftest()` 会**逐格**拿本表和四个
驱动的取值函数对账,对不上直接判失败(`RAIL_SELFTEST_TABLE`)。
宿主机 `ctest` 每次都跑,上板 `app_main()` 开机也跑一次。
—— 之所以不做成「开机时从驱动现算」,是因为那样**按定义就查不出分家**,
而且表就不是 const 的了,用之前得先记着初始化。 */
---- 这张表和三个驱动里那三张表是什么关系 --------------------------------
驱动各自带着自己那一张表(`ina226_rail_addr()` 等),因为驱动要能单独测。
本表是**把它们横着拼起来**的那一张,多出来的是 mux 通道与两列 GPIO。
`rail_selftest()` 会**逐格**拿本表和驱动的取值函数对账,对不上直接判失败。 */
#ifndef RAIL_CFG_H
#define RAIL_CFG_H
@@ -38,28 +43,30 @@
#include "hal_err.h"
#include "phonerail.h"
#include "sr595.h" /* sr595_key_t:按键位那三格的下标就是它 */
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
uint8_t ina_addr; /* 0x40 0x41 0x44 0x45 0x42 —— 查表,不能算 */
uint8_t dac_addr; /* 0x4A: CH1/2/4 0x4B: CH3/5 —— 也不是「前三路在 U6」 */
/* 本路 INA226 的地址。**五路相同(0x40)** —— 区分是哪一路的是下面那个通道。 */
uint8_t ina_addr;
/* 本路在底板 TCA9548A 上的通道(0~7)。⚠️ **R2 里选错它 = 读到另一台手机**,
而量纲、范围、变化趋势全都正常(计划 §5.1 C-d)。 */
uint8_t mux_ch;
uint8_t dac_addr; /* 0x4A: CH1/2/4 0x4B: CH3/5 —— 不是「前三路在 U6」 */
uint8_t dac_ch; /* 该片上的 OUT 号(0~3) */
/* GPIO 直连,安全关键(§8.1「任何情况下都要能关电」)。
/* `EFUSE_EN`:ESP32 GPIO **直连**,安全关键(R2 §7.3 的框:保护链第三层
唯一的执行手段,任何时候都不许挪到 I²C 后面)。
类型是 `int`,因为 `hal_gpio_*()` 收的就是 int;本表是唯一的出处。 */
int efuse_en;
int vbus_en;
/* 24 位移位链上的位号。下标用 `SR595_KEY_PWR/VUP/VDN`,别用 0/1/2。 */
uint8_t key_bit[SR595_KEY_COUNT];
uint8_t edl_bit;
uint8_t flt_bit; /* PCA9555 **Port0** bit0~4 */
uint8_t pg_bit; /* PCA9555 **Port1** bit0~4 —— 两个端口长得一模一样,别搞反 */
/* `PRSNT#`:GPIO 输入,板上 3V3 上拉,**低 = 刀片在位**。
空槽上电 = 金手指裸露带 12V,所以这是一条安全判据(计划 §5.8)。 */
int prsnt_n;
} rail_cfg_t;
/* rail 是 **1~5**(不是 0~4)。越界直接 abort:那是调用方算错了路号,
@@ -73,56 +80,55 @@ const rail_cfg_t *rail_cfg(int rail);
typedef enum {
RAIL_SELFTEST_INA = 1u << 0, /* ina226_addr_table_ok() 不过 */
RAIL_SELFTEST_DAC = 1u << 1, /* dac63204_map_ok() 不过 */
RAIL_SELFTEST_SR595 = 1u << 2, /* sr595_map_ok() 不过 */
RAIL_SELFTEST_MUX = 1u << 2, /* mux 通道重复 / 越界 / 撞上 PD 那一路 */
RAIL_SELFTEST_PCA9555 = 1u << 3, /* pca9555_map_ok() / _addr_ok() 不过 */
RAIL_SELFTEST_TABLE = 1u << 4, /* 本表和四个驱动的表分家了 */
RAIL_SELFTEST_PINS = 1u << 5, /* EN 引脚重复 / 越界 / 撞上别的信号 */
RAIL_SELFTEST_TABLE = 1u << 4, /* 本表和驱动的表分家了 */
RAIL_SELFTEST_PINS = 1u << 5, /* GPIO 重复 / 越界 / 撞上别的信号 */
} rail_selftest_bit_t;
uint32_t rail_selftest(void);
/* 只查**一张给定的**五路表(`RAIL_SELFTEST_TABLE` / `RAIL_SELFTEST_PINS` 两位;
另外四位是四个驱动自己的表,和 t 无关)。`t` 要指向 PHONERAIL_RAIL_COUNT 行。
/* 只查**一张给定的**五路表(`TABLE` / `PINS` / `MUX` 三位;另外三位是驱动
自己的表,和 t 无关)。`t` 要指向 PHONERAIL_RAIL_COUNT 行。
存在的理由:**自检逻辑本身也得被测。** 拿真表跑一遍只能证明「现在没事」,
证明不了「真有事的时候查得出来」—— 而真表是 const 的,测试没法把它改坏。
所以判据单拎出来,测试喂几张故意写坏的表进去看它认不认。 */
证明不了「真有事的时候查得出来」—— 而真表是 const 的,测试没法把它改坏。 */
uint32_t rail_selftest_table(const rail_cfg_t *t);
/* 单个失败位的人话名字,用来打日志。传 0 或者多于一位返回 "?"。 */
const char *rail_selftest_str(uint32_t bit);
/* ---- 两根 EN 线(表驱动遍历) --------------------------------------------
`EFUSE_EN` / `VBUS_EN` 都是**高有效**,而且都在板上带 100 kΩ 下拉
(R114 / R701~705)—— ESP32 复位期间 GPIO 高阻,五路输出全关、VBUS 全断。
极性只在 rail_cfg.c 的 `en_write()` 里出现这一次,上层一律传 bool。 */
/* ---- `EFUSE_EN`(表驱动遍历) --------------------------------------------
**高有效**,板上带 100 kΩ 下拉 ⇒ ESP32 复位期间 GPIO 高阻、五路输出全关。
极性只在 rail_cfg.c 的 `en_write()` 里出现这一次,上层一律传 bool。
/* 十个脚配成输出并**全部拉低**(= 五路全关、VBUS 全断),然后才谈别的。
⚠️ 必须在 `rail_volt_init()` 之前调:DAC 上电那一刻输出就有电压了,
eFuse 的 EN 这时得已经确定是低的。任何一个脚配失败就返回 HAL_ERR_ARG。 */
⚠️ `VBUS_EN` **不在这里** —— 它在刀片的 PCA9555 上,走 `slot_vbus_enable()`。
这不只是换了个接口:I²C 失联时 VBUS **关不掉**,而 `EFUSE_EN` 永远关得掉
(计划 §5.4)。两者的可靠性等级从此不一样,别再把它们当成一对。 */
/* 五个脚配成输出并**全部拉低**(= 五路输出全关),然后才谈别的。
⚠️ 必须在 `rail_volt_init()` 与 `slot_init()` 之前调:DAC 上电那一刻输出就
有电压了,eFuse 的 EN 这时得已经确定是低的。任何一个脚配失败就返回错误码。 */
hal_err_t rail_en_init(void);
/* rail 越界 → HAL_ERR_ARG;没 `rail_en_init()` 过 → HAL_ERR_ARG(HAL 会拦)。 */
hal_err_t rail_efuse_enable(int rail, bool on);
hal_err_t rail_vbus_enable(int rail, bool on);
/* 这两根线**最后一次成功写进去**的值(上电与 `rail_en_init()` 之后都是 false)。
/* 这根线**最后一次成功写进去**的值(上电与 `rail_en_init()` 之后都是 false)。
存在的理由是 A10 的判据要知道「这一路现在该不该有电」:关着的路电压本来就是 0,
拿它去和设定值比,每一圈都会得出「偏低 3900 mV」,于是巡检会没完没了地
「再关一次已经关着的路」并刷事件。判据只在这两个影子为 true 时武装。
⚠️ **只在写成功时更新**,而且方向是有讲究的:关的那一次写失败 ⇒ 影子留在 on
⇒ 判据继续武装(还会再关一次);开的那一次写失败 ⇒ 影子留在 off
⇒ 判据不武装(不会对着一路没开成的电报偏差)。两边都倒向安全的那一侧。
拿它去和设定值比,每一圈都会得出「偏低 3900 mV」。判据只在这个影子为 true 时武装。
—— 不去读回引脚,是因为 `hal_gpio_read()` 读输出脚在两个目标上的语义不一样,
而这里要的本来就是「固件的意图」,不是「线上的电平」。 */
bool rail_efuse_is_on(int rail);
bool rail_vbus_is_on(int rail);
/* 五路全关:**先断 VBUS 再关 eFuse**,中间不留「只靠 USB 供着电」的窗口。
十个脚全部试一遍,不因为某一个失败就提前返回 —— 关电路径上「剩下九个也别关了」
是最坏的选择。返回第一个错误码(全成功就是 HAL_OK)。
十次操作全部试一遍,不因为某一次失败就提前返回。返回第一个错误码。
⚠️ R2 里前半段(断 VBUS)要走 I²C,**可能失败**(mux / PCA9555 失联时必然
失败);后半段(关 eFuse)是 GPIO,永远成功。所以这个函数在最坏情况下的
语义是「五路直供确实断了,但 VBUS 可能还通着」—— 这是 R2 用一根跨板针换来的
代价(计划 §5.4),不是 bug。
⚠️ 这是复位 / 收尾路径。A10 的故障快路径要直接调 `rail_efuse_enable(rail,false)`,
别绕这里:过压时该立刻断的是出事那一路,不是等着遍历五路。 */
hal_err_t rail_all_off(void);
+143
查看文件
@@ -0,0 +1,143 @@
/* 一片刀片的 I/O 门面。契约见 slot.h。 */
#include "slot.h"
#include <assert.h>
#include <stddef.h>
#include "hal_gpio.h"
#include "i2cbus.h"
#include "rail_cfg.h"
/* `PRSNT#` 的缓存(见 slot.h:读失败保留上一次的值)。 */
static bool s_present[PHONERAIL_RAIL_COUNT] = { true, true, true, true, true };
static bool rail_valid(int rail) { return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT; }
int slot_mux_ch(int rail)
{
assert(rail_valid(rail));
return (int)rail_cfg(rail)->mux_ch;
}
/* ---- PRSNT# -------------------------------------------------------------- */
hal_err_t slot_prsnt_init(void)
{
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
const hal_err_t err =
hal_gpio_config(rail_cfg(rail)->prsnt_n, HAL_GPIO_INPUT_PULLUP);
if (err != HAL_OK) {
return err;
}
}
slot_prsnt_poll();
return HAL_OK;
}
void slot_prsnt_poll(void)
{
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
int level = 0;
if (hal_gpio_read(rail_cfg(rail)->prsnt_n, &level) == HAL_OK) {
s_present[rail - 1] = (level == 0); /* 低 = 在位 */
}
/* 读失败:保留上一次的值(slot.h 里的理由)。 */
}
}
bool slot_present(int rail)
{
return rail_valid(rail) && s_present[rail - 1];
}
/* ---- 一次 xact 的正文们 --------------------------------------------------
每个都是「通道已选好、锁已持有」的状态下被调一次。 */
typedef struct {
int rail;
uint8_t mask;
uint8_t value;
} out0_arg_t;
static hal_err_t do_out0(void *arg)
{
const out0_arg_t *a = (const out0_arg_t *)arg;
return pca9555_write_out0(a->rail - 1, a->mask, a->value);
}
static hal_err_t do_init(void *arg)
{
const int rail = *(const int *)arg;
return pca9555_init(rail - 1);
}
/* ---- 输出 ---------------------------------------------------------------- */
/* 已持锁版本:影子下标与通道由调用方保证一致 —— 调用方只可能是
`t_safety`,而它是拿 `slot_mux_ch(rail)` 去开的 `xact`。 */
static hal_err_t out0_locked(int rail, uint8_t mask, uint8_t value)
{
if (!rail_valid(rail)) {
return HAL_ERR_ARG;
}
return pca9555_write_out0(rail - 1, mask, value);
}
static hal_err_t out0_xact(int rail, uint8_t mask, uint8_t value)
{
if (!rail_valid(rail)) {
return HAL_ERR_ARG;
}
out0_arg_t a = { rail, mask, value };
return i2cbus_xact(slot_mux_ch(rail), SLOT_LOCK_TIMEOUT_MS, do_out0, &a);
}
hal_err_t slot_init(int rail)
{
if (!rail_valid(rail)) {
return HAL_ERR_ARG;
}
int r = rail;
return i2cbus_xact(slot_mux_ch(rail), SLOT_LOCK_TIMEOUT_MS, do_init, &r);
}
hal_err_t slot_out0_write(int rail, uint8_t mask, uint8_t value)
{
return out0_xact(rail, mask, value);
}
uint8_t slot_shadow(int rail)
{
assert(rail_valid(rail));
return pca9555_shadow(rail - 1);
}
/* VBUS 的极性:**高 = 通**(TPS22918 的 ON 脚,板上 R701 100 kΩ 下拉 ⇒
PCA9555 复位为高阻时 VBUS 必断)。极性只在这两个函数里各出现一次。 */
#define SLOT_VBUS_BIT ((uint8_t)(1u << PCA9555_BIT_VBUS_EN))
hal_err_t slot_vbus_enable(int rail, bool on)
{
return out0_xact(rail, SLOT_VBUS_BIT, on ? SLOT_VBUS_BIT : 0u);
}
hal_err_t slot_vbus_enable_locked(int rail, bool on)
{
return out0_locked(rail, SLOT_VBUS_BIT, on ? SLOT_VBUS_BIT : 0u);
}
bool slot_vbus_is_on(int rail)
{
/* 影子就在驱动里(写成功才更新),这里不另存一份 —— 两处影子迟早会分家。 */
return rail_valid(rail) && (pca9555_shadow(rail - 1) & SLOT_VBUS_BIT) != 0;
}
/* ---- 输入 ---------------------------------------------------------------- */
hal_err_t slot_read_inputs_locked(int rail, pca9555_in_t *out)
{
if (!rail_valid(rail) || out == NULL) {
return HAL_ERR_ARG;
}
return pca9555_read_inputs(out);
}
+100
查看文件
@@ -0,0 +1,100 @@
/* `slot` —— 一片刀片的 I/O 门面(R2 新增)。
R2 之后,「按一下 CH3 的电源键」在总线上是:
**选 mux 通道 2 → 改 0x20 那片 PCA9555 的 Port0 输出锁存器**。
路号 → 通道 → 影子下标这条链只在本文件里出现一次,别处一律调 `slot_*`。
---- 为什么要有这一层(而不是让 t_actuator 直接调驱动)------------------
`pca9555_write_out0(slot, ...)` 的 `slot` 参数**只用来记影子**,驱动没法验证
它与当前 mux 通道是不是同一路。传错的后果是影子记在了另一片上,
下一次写会把那一片的键"顺手"改掉 —— 而五路的电压电流全部正常。
本文件把「选通道」和「记影子」绑在同一个函数里,让这件事**不可能**传错。
---- 两个版本的接口 ------------------------------------------------------
带 `_locked` 后缀的**必须**在对应那一路的 `i2cbus_xact()` 回调里调
(通道已选好、锁已持有);不带后缀的自己开一次 `xact`。
`t_safety` 用前者(SW-8 要在同一圈、同一把锁里断 VBUS,计划 §5.4),
`t_actuator` 与命令处理器用后者。
⚠️ 在回调里调不带后缀的版本 = 嵌套 = `HAL_ERR_STATE`(`i2cbus.h` 的警告)。 */
#ifndef SLOT_H
#define SLOT_H
#include <stdbool.h>
#include <stdint.h>
#include "hal_err.h"
#include "pca9555.h"
#include "phonerail.h"
#ifdef __cplusplus
extern "C" {
#endif
/* 不带 `_locked` 的那些函数等 I²C 锁最多等这么久。
比 `t_safety` 的 5 ms 长:巡检等不到就跳过一圈(20 ms 后重来),
而执行器等不到就得重试一整个状态(`ACT_ST_CLEANUP` 那条「不放弃」)。 */
#define SLOT_LOCK_TIMEOUT_MS 20
/* ---- `PRSNT#`:纯 GPIO,不经总线(计划 §5.8) ---------------------------- */
/* 五根 `PRSNT#` 配成**上拉输入**。板上另有 R606~R610 上拉,这里再配一次内部上拉
是为了空槽时电平确定 —— 悬空的输入脚读回来是随机的,而「随机地认为刀片在位」
正是这条判据要防的事。 */
hal_err_t slot_prsnt_init(void);
/* 刀片在不在位。**低 = 在位**(刀片把金手指 B31 短到 A1 的 GND)。
⚠️ 读失败时返回**上一次成功读到的值**(初值 true = 在位)。
理由:GPIO 读在两个目标上都只可能因为「没 config」失败,那时系统还没起来;
而让一次读失败把五台正在跑的手机判成"拔掉了",代价比漏判大得多。 */
bool slot_present(int rail);
/* 把 `PRSNT#` 读一遍并更新缓存。`t_safety` 每圈调一次(不碰总线,很便宜)。 */
void slot_prsnt_poll(void);
/* ---- 初始化 -------------------------------------------------------------- */
/* 该路 PCA9555 的初始化(自带 `xact`)。顺序是驱动里那个框保证的:
**输出锁存器先清零,方向最后改**。
⚠️ 必须在 `rail_en_init()` 之后:这一步之后 VBUS 才真的受控。 */
hal_err_t slot_init(int rail);
/* ---- VBUS(PCA9555 P0.4 → TPS22918) ------------------------------------- */
hal_err_t slot_vbus_enable(int rail, bool on);
hal_err_t slot_vbus_enable_locked(int rail, bool on);
/* 最后一次**成功**写进去的值(上电与 `slot_init()` 之后都是 false)。
不去读回器件,因为这里要的本来就是「固件的意图」(`rail_cfg.h` 里同一条理由)。
⚠️ 写失败时不更新:关的那次失败 ⇒ 影子留在 on ⇒ 判据继续武装(还会再关一次);
开的那次失败 ⇒ 影子留在 off ⇒ 判据不武装。两边都倒向安全的那一侧。 */
bool slot_vbus_is_on(int rail);
/* ---- 三个侧键 + EDL(PCA9555 P0.0~P0.3) --------------------------------- */
/* 改 `mask` 指定的那几位。组合键的两位在**同一次写**里(§10.6)。
`mask` 只许出现 `PCA9555_P0_OUT_MASK` 里的位。 */
hal_err_t slot_out0_write(int rail, uint8_t mask, uint8_t value);
/* 该路 Port0 输出锁存器的影子。 */
uint8_t slot_shadow(int rail);
/* ---- 输入(PCA9555 Port1) ----------------------------------------------- */
/* 一次事务读回两个输入端口。**只有 `_locked` 版本** —— 读输入的唯一调用者是
`t_safety`,它本来就在 `xact` 里;别处要看状态请读巡检的快照
(`t_actuator.h` 里「PG 为什么是别人喂进来的」那一段)。 */
hal_err_t slot_read_inputs_locked(int rail, pca9555_in_t *out);
/* ---- 路号 → mux 通道(唯一换算点) --------------------------------------- */
/* 这一路在 TCA9548A 上的通道号。查 `rail_cfg()`,**不算**。 */
int slot_mux_ch(int rail);
#ifdef __cplusplus
}
#endif
#endif /* SLOT_H */
+27 -32
查看文件
@@ -6,10 +6,9 @@
#include <stdio.h>
#include <string.h>
#include "hal_lock.h"
#include "hal_time.h"
#include "rail_cfg.h"
#include "sr595.h"
#include "slot.h"
typedef struct {
act_state_t state;
@@ -17,7 +16,7 @@ typedef struct {
uint32_t t0_ms; /* 进入当前状态的时刻(绝对,回绕安全) */
uint32_t keys; /* KEY 序列:逻辑键位(ACT_KEYS_*) */
uint32_t hold_ms;
uint32_t release_mask; /* 收尾时要在链上清掉的位。0 = 这个序列没按下任何东西 */
uint8_t release_mask; /* 收尾时要在 Port0 上清掉的位。0 = 这个序列没按下任何东西 */
act_result_t result;
uint32_t done;
uint32_t cleanup_retries;
@@ -38,30 +37,26 @@ static act_rail_t *at(int rail)
return &s_rail[rail - 1];
}
/* 逻辑键位 → 链上位号。查 `sr595_key_bit()`,**不算**(sr595.h 的禁令)。 */
static uint32_t key_chain_mask(int rail, uint32_t keys)
/* 逻辑键位 → PCA9555 Port0 位号。查 `pca9555_key_bit()`,**不算**。
⚠️ R2 里位号与路号无关(五片刀片同构),所以这个函数不收 rail。 */
static uint8_t key_out_mask(uint32_t keys)
{
uint32_t m = 0;
for (int k = 0; k < SR595_KEY_COUNT; k++) {
uint8_t m = 0;
for (int k = 0; k < PCA9555_KEY_COUNT; k++) {
if ((keys & ((uint32_t)1u << k)) != 0) {
m |= (uint32_t)1u << sr595_key_bit(rail, (sr595_key_t)k);
m |= (uint8_t)(1u << pca9555_key_bit((pca9555_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)
/* 动这一路 Port0 的**唯一**入口:`slot_out0_write()` 自己开一次 `i2cbus_xact`
(拿锁 → 选通道 → 读改写一个字节 → 放锁)。
一次写就是一个字节 ⇒ 组合键的两位天然落在同一次写里(§10.6)。
⚠️ 这一步会拿 I²C 锁,所以**不许**在持锁的上下文里调它(R2 的新前提,见头文件)。 */
static hal_err_t out_write(int rail, uint8_t mask, uint8_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;
return slot_out0_write(rail, mask, value);
}
static bool pg_now(int rail, bool *pg)
@@ -152,7 +147,7 @@ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now)
if (el < ACT_EDL_SHORT_MS) {
return ACT_EDL_SHORT_MS - el;
}
if (rail_vbus_enable(rail, true) != HAL_OK) {
if (slot_vbus_enable(rail, true) != HAL_OK) {
finish(r, ACT_RES_IO, now); /* CLEANUP 会把 EDL_EN 清掉 */
return ACT_CLEANUP_RETRY_MS;
}
@@ -168,7 +163,7 @@ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now)
return ACT_CLEANUP_RETRY_MS;
case ACT_ST_CLEANUP:
if (chain_write(r->release_mask, 0) == HAL_OK) {
if (out_write(rail, r->release_mask, 0) == HAL_OK) {
r->release_mask = 0;
r->seq = ACT_SEQ_NONE;
enter(r, ACT_ST_IDLE, now);
@@ -227,9 +222,9 @@ hal_err_t t_actuator_hard_reset(int rail)
Fastboot 而不是正常起来。正常情况下这里一定已经是 0(有序列在跑的话
上面那一句就把请求挡了),所以影子不变 ⇒ 不脏 ⇒ 一个时钟都不会发。
只有上一次收尾没落地时才真的发一帧。 */
const uint32_t km = sr595_key_mask(rail);
if ((sr595_shadow() & km) != 0) {
(void)chain_write(km, 0); /* 失败也照断电:断电永远比不断电安全 */
const uint8_t km = pca9555_key_mask();
if ((slot_shadow(rail) & km) != 0) {
(void)out_write(rail, km, 0); /* 失败也照断电:断电永远比不断电安全 */
}
const hal_err_t err = rail_efuse_enable(rail, false);
@@ -259,11 +254,11 @@ hal_err_t t_actuator_key(int rail, uint32_t keys, uint32_t hold_ms)
return HAL_ERR_STATE;
}
const uint32_t mask = key_chain_mask(rail, keys);
const uint8_t mask = key_out_mask(keys);
/* 组合键的两位在**同一次 `chain_write()`** 里 ⇒ 同一帧 ⇒ 同一个 RCLK 沿。
/* 组合键的两位在**同一次写**里 ⇒ 输出锁存器一次更新。
分两次写会先单按一个键(§10.6)。 */
const hal_err_t err = chain_write(mask, mask);
const hal_err_t err = out_write(rail, mask, mask);
r->seq = ACT_SEQ_KEY;
r->keys = keys;
@@ -272,9 +267,9 @@ hal_err_t t_actuator_key(int rail, uint32_t keys, uint32_t hold_ms)
r->release_mask = mask;
if (err != HAL_OK) {
/* 影子可能已经改成「按下」了(拿到锁但整帧没发出去)。直接返回错误
就等于把一个「按下」的脏影子留在那儿,谁下一次 flush 谁就替我们把键
按下去 —— 而没有人会来松开它。所以照样进 CLEANUP 把它撤回来。 */
/* 写失败时驱动**不更新影子**,但器件里那个字节的状态是未知的
(事务可能发出去一半)。所以照样进 CLEANUP 把这几位显式清一遍 ——
「按下去的东西一定要松开」这条规矩不看我们以为发生了什么。 */
finish(r, ACT_RES_IO, hal_time_now_ms());
return err;
}
@@ -296,8 +291,8 @@ hal_err_t t_actuator_edl(int rail)
return HAL_ERR_STATE;
}
const uint32_t mask = (uint32_t)1u << sr595_edl_bit(rail); /* 查表,不算 */
const hal_err_t err = chain_write(mask, mask);
const uint8_t mask = (uint8_t)(1u << PCA9555_BIT_EDL_EN);
const hal_err_t err = out_write(rail, mask, mask);
r->seq = ACT_SEQ_EDL;
r->result = ACT_RES_NONE;
+214 -203
查看文件
@@ -1,203 +1,214 @@
/* `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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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 */
/* `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 的 P1.3 上,而 `t_safety` 已经每 20 ms 无条件读一圈
(§10.3)。执行器再去读一遍等于凭空多一份总线占用和一份两边不一致的可能,
所以 PG 从外面**喂**进来(`act_pg_fn`)。
---- ⚠️ R2 改了这一层的锁前提 ---------------------------------------------
R1 里执行器只碰 GPIO 与 74HC595,与 I²C 无交集,所以两把锁永远不会嵌套。
**R2 里按键 / EDL / VBUS 全在刀片的 PCA9555 上 ⇒ 本文件必须拿 I²C 锁**
(经 `slot_*`,它们自带 `i2cbus_xact`)。
好消息是 595 那把锁没了,全系统**只剩一把锁**,嵌套在物理上不可能。
代价是与巡检争用同一把锁,于是:
· 每一次动作都是一次短事务,**不许在持锁期间等时间**
(按键保持 100 ms 是状态机的事,不是持锁的事);
· `ACT_ST_CLEANUP` 的「不放弃」比 R1 更要紧:总线忙时写不进去必须一直重试。 */
#ifndef T_ACTUATOR_H
#define T_ACTUATOR_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "hal_err.h"
#include "phonerail.h"
#include "slot.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
/* 每次动作等 I²C 锁最多等多久(`slot.h` 的 `SLOT_LOCK_TIMEOUT_MS` 就是它)。
争用来自每 20 ms 一圈的巡检;等不到就下一拍再来(CLEANUP 会一直重试)。 */
#define ACT_LOCK_TIMEOUT_MS SLOT_LOCK_TIMEOUT_MS
/* CLEANUP 重试间隔,以及空闲时建议任务睡多久(A12 会换成「带超时地等队列」)。 */
#define ACT_CLEANUP_RETRY_MS 5
#define ACT_IDLE_WAIT_MS 100
/* ---- 按键:逻辑键位,不是器件上的位号 -----------------------------------
上层一律用这三个位(和它们的组合)表达「按哪几个键」,PCA9555 Port0 上的
位号由本文件经 `pca9555_key_bit()` 查表换算。
⚠️ R2 里位号**不再随路号变**(五片刀片同构),但仍然查表不算。 */
typedef enum {
ACT_KEYS_PWR = 1u << PCA9555_KEY_PWR,
ACT_KEYS_VUP = 1u << PCA9555_KEY_VUP,
ACT_KEYS_VDN = 1u << PCA9555_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,
/* 写 PCA9555 或写 EN 引脚失败(含拿不到 I²C 锁)。序列中止,但收尾照做。 */
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()`(GPIO)+ `slot_init()`(PCA9555 的输出锁存器先清零)
干的(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 */
+423 -279
查看文件
@@ -1,279 +1,423 @@
#include "t_safety.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "hal_i2c_acct.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "rail.h"
#include "rail_cfg.h"
#include "t_actuator.h"
#include "tasks.h"
/* ---- A10:两条判据 --------------------------------------------------------
§10.3 原话:「越界**直接**操作 `EFUSE_EN` / `VBUS_EN`(GPIO 直连,不经任何
队列或抽象层)」。所以下面这几个函数直接调 `rail_*_enable()`,不发消息给
t_actuator —— 发消息就等于把「关电」排在了一个中优先级任务的队列后面。
四条不能改的规矩:
1. **判据只在放锁之后跑。** 两根 EN 线是 GPIO,和 I²C 没有关系;拿着总线锁
去写引脚只会把别的任务多挡一会儿。而且 `t_actuator_abort()` 会去拿 595
那把锁,拿着 I²C 锁进去就是嵌套(`hal_lock_take()` 会直接判 HAL_ERR_STATE)。
2. **先 `t_actuator_abort()`,再断电。** 反过来有个真实的洞:硬重启的
`HR_OFF` 状态会在 4 s 后自己把 `EFUSE_EN` 抬回去,于是「关掉的路」几秒钟
之后又带电了。abort 之后那一路只会走收尾(只碰 595),再也够不到 EN 线。
代价是 abort 最坏会等 595 那把锁 20 ms(`ACT_LOCK_TIMEOUT_MS`),也就是
巡检可能丢一拍 —— 认了:SR-5 原文写着它「仅承担记录与告警,不承担实时保护」,
真正的过压保护是 eFuse 里的硬件 OVP,不靠这条路径的延迟。
3. **判据只在「这一路该有电」而且「这一圈的值是新鲜的」时武装。**
关着的路电压本来就是 0,不设这一条的话每一圈都会得出「偏低 3900 mV」;
而器件失联那一路的值是上一圈的,§10.10 明写着「保持供电,禁用该路的
SR-5 / SW-8 判据并持续告警」。
4. **一条判据也不许发 I²C 事务。** 判据要的三个量(设定值、两根 EN 的状态、
正在跑的序列)全部有影子可查。多读一次就把 §10.3 那 7.8 % 的预算顶穿,
`test_one_cycle_is_exactly_thirteen_transactions()` 会当场变红。 */
static safety_ev_fn s_sink;
static void *s_sink_ctx;
void t_safety_set_event_sink(safety_ev_fn fn, void *ctx)
{
s_sink = fn;
s_sink_ctx = ctx;
}
const char *t_safety_ev_str(safety_ev_t ev)
{
switch (ev) {
case SAFETY_EV_NONE:
return "无";
case SAFETY_EV_OVER_VOLT:
return "过压关路(SR-5)";
case SAFETY_EV_UNDER_VOLT:
return "欠压关路(SR-5)";
case SAFETY_EV_REVERSE:
return "反灌断VBUS(SW-8)";
case SAFETY_EV_PUSHED_UP:
return "被顶高断VBUS(SW-8)";
default:
return "?";
}
}
/* 记一条。**永远在动作之后调** —— 记录慢一步没关系,断电慢一步不行。 */
static void emit(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
{
const ina226_vi_t *vi = &s->rail[rail - 1];
s->last.t_ms = now;
s->last.rail = (uint8_t)rail;
s->last.type = (uint8_t)ev;
s->last.bus_mv = vi->bus_mv;
s->last.current_ua = vi->current_ua;
s->events++;
if (s_sink != NULL) {
s_sink(s_sink_ctx, &s->last); /* TODO(A11):这里接环形缓冲 */
}
}
/* SR-5:关这一路。**先断 VBUS 再关 eFuse** —— 只关 eFuse 的话,插着 USB 的那几路
会停在「手机只靠 VBUS 供着电」,那既不是开也不是关(`rail_all_off()` 里同一条
理由)。两个都要试,不因为第一个失败就不关第二个。 */
static void trip_rail(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
{
t_actuator_abort(rail); /* 规矩 2:必须在断电之前 */
(void)rail_vbus_enable(rail, false);
(void)rail_efuse_enable(rail, false);
emit(s, rail, ev, now);
}
/* SW-8:只断这一路的 VBUS,**不碰 eFuse** —— 反灌的能量是从 USB 那一侧来的,
把直供也一起关掉等于「因为充电器有问题就把手机关机」,而 §10.2 的整节都在讲
「范围可控的时候只动出事的那一处」。 */
static void cut_vbus(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
{
/* 三个序列里只有 EDL 会去写 `VBUS_EN`(`EDL_EN=1 → VBUS_EN=1 → 等枚举`)。
它正跑着的话必须打断,否则下一拍它就把 VBUS 又抬回来了。
硬重启 / 按键不碰 VBUS,没有理由为一次反灌把它们打断。 */
if (t_actuator_seq(rail) == ACT_SEQ_EDL) {
t_actuator_abort(rail);
}
(void)rail_vbus_enable(rail, false);
emit(s, rail, ev, now);
}
static void check_rail(safety_snap_t *s, int rail, uint32_t now)
{
safety_rail_t *st = &s->st[rail - 1];
const bool en = rail_efuse_is_on(rail);
/* `EFUSE_EN` 的上升沿:缓启动窗口从这一刻算起。 */
if (en && !st->en_on) {
st->en_ms = now;
}
st->en_on = en;
/* 规矩 3 的三道闸。任何一道不过就把圈数清零 —— 「关着的那几秒」
不能算进「持续 20 ms」里,不然一开机就跳闸。
(缓启动窗口是真按毫秒算的:它对应的是 tON + 斜坡那段物理时间,
不像「持续 20 ms」那样是在数样本。) */
if (!en || !s->rail_ok[rail - 1] || (now - st->en_ms) < T_SAFETY_SETTLE_MS) {
st->dev_n = 0;
return;
}
const int32_t mv = s->rail[rail - 1].bus_mv;
const int32_t ua = s->rail[rail - 1].current_ua;
const int32_t dev = mv - (int32_t)rail_setpoint_mv(rail);
/* ---- SW-8(先跑):只在 VBUS 通着的时候武装 -------------------------
反灌的能量只可能从 VBUS 那一侧来,断开之后判据自然就解除武装了 ——
这也是「关掉之后不会每 20 ms 再刷一条事件」的机制。
先跑它是因为过压这一种情况两条判据都成立:该先撤掉能量来源(VBUS),
20 ms 后 SR-5 再决定要不要连这一路一起关掉。 */
if (rail_vbus_is_on(rail)) {
if (ua < T_SAFETY_REV_UA) {
cut_vbus(s, rail, SAFETY_EV_REVERSE, now);
} else if (dev > T_SAFETY_DEV_MV) {
cut_vbus(s, rail, SAFETY_EV_PUSHED_UP, now);
}
}
/* ---- SR-5:±100 mV 连着 T_SAFETY_DEV_HOLD_CYCLES 圈(= 持续 20 ms)---- */
if (dev > T_SAFETY_DEV_MV || dev < -T_SAFETY_DEV_MV) {
if (st->dev_n < 0xFF) {
st->dev_n++;
}
if (st->dev_n >= T_SAFETY_DEV_HOLD_CYCLES) {
trip_rail(s, rail, dev > 0 ? SAFETY_EV_OVER_VOLT : SAFETY_EV_UNDER_VOLT, now);
/* ⚠️ 这里**故意不**把 `dev_n` 清零。关掉之后下一圈 `en` 就是 false,
上面那道闸自己会清;而万一那次写引脚失败了(影子留在 on),
下一圈 `dev_n` 已经够数,会**立刻再关一次** —— 清零的话得再等两圈。
关电路径上「重试快一点」永远是对的那一边。 */
}
} else {
st->dev_n = 0; /* 「持续」的意思是连续,中间回到范围内就从头数 */
}
}
void t_safety_init(safety_snap_t *s)
{
assert(s != NULL);
memset(s, 0, sizeof(*s));
/* 游标对齐到「现在」,否则第一圈会把启动期间(器件初始化)的事务算进来。 */
s->bus_cursor = hal_i2c_acct_bits();
}
hal_err_t t_safety_step(safety_snap_t *s)
{
assert(s != NULL);
s->cycles++;
hal_err_t err = hal_lock_take(HAL_LOCK_I2C, T_SAFETY_LOCK_TIMEOUT_MS);
if (err != HAL_OK) {
/* 规矩 1:不死等。这一圈作废,20 ms 后重来。 */
s->skipped++;
s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor);
return err;
}
hal_err_t first = HAL_OK;
/* 五路 —— 地址查表,**不许算**(rail_cfg.h 那条禁令)。 */
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
ina226_vi_t vi;
const hal_err_t e = ina226_read_vi(rail_cfg(rail)->ina_addr, &vi);
s->rail_ok[rail - 1] = (e == HAL_OK);
if (e == HAL_OK) {
s->rail[rail - 1] = vi; /* 规矩 2:失败就留着上一圈的值,别清零 */
} else {
s->i2c_errors++;
if (first == HAL_OK) {
first = e;
}
}
}
/* 第六片:输入侧。 */
{
ina226_vi_t vi;
const hal_err_t e = ina226_read_vi(INA226_ADDR_INPUT, &vi);
s->input_ok = (e == HAL_OK);
if (e == HAL_OK) {
s->input = vi;
} else {
s->i2c_errors++;
if (first == HAL_OK) {
first = e;
}
}
}
/* PCA9555 的两个输入端口一次事务读完(自增指针)。 */
{
pca9555_in_t in;
const hal_err_t e = pca9555_read_inputs(&in);
s->io_ok = (e == HAL_OK);
if (e == HAL_OK) {
s->io = in;
} else {
s->i2c_errors++;
if (first == HAL_OK) {
first = e;
}
}
}
hal_lock_give(HAL_LOCK_I2C);
/* 判据:放锁之后跑,直接写 EFUSE_EN / VBUS_EN(上面那四条规矩)。
`now` 只取一次 —— 五路要拿同一个时刻去算「持续了多久」。 */
{
const uint32_t now = hal_time_now_ms();
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
check_rail(s, rail, now);
}
}
s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor);
return first;
}
uint32_t t_safety_budget_us(void)
{
/* 上面那一圈真正发出去的事务,逐条数:
6 片 INA226 × 2 次寄存器读(母线电压 + 电流)
1 次 PCA9555 读(一次事务读回两个输入端口)
每次都是「写 1 字节寄存器号 + 读 2 字节」的形状。 */
const uint32_t txns = INA226_COUNT * 2u + 1u;
return (uint32_t)hal_i2c_bits_to_us((uint64_t)txns * hal_i2c_txn_bits(1, 2));
}
uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms)
{
if (period_ms == 0) {
return 0;
}
return (uint32_t)(((uint64_t)bus_us * 1000ull) / ((uint64_t)period_ms * 1000ull));
}
int t_safety_format(const safety_snap_t *s, char *buf, size_t len)
{
assert(s != NULL && buf != NULL && len > 0);
const uint32_t period = task_desc(TASK_SAFETY)->period_ms;
const uint32_t pm = t_safety_load_permille(s->bus_us, period);
return snprintf(buf, len,
"t_safety: cycles=%u skipped=%u i2c_err=%u bus=%u us/%u ms load=%u.%u%% "
"events=%u",
(unsigned)s->cycles, (unsigned)s->skipped, (unsigned)s->i2c_errors,
(unsigned)s->bus_us, (unsigned)period, (unsigned)(pm / 10u),
(unsigned)(pm % 10u), (unsigned)s->events);
}
#include "t_safety.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "hal_i2c_acct.h"
#include "hal_time.h"
#include "i2cbus.h"
#include "rail.h"
#include "rail_cfg.h"
#include "slot.h"
#include "t_actuator.h"
#include "tasks.h"
/* 判据与四条规矩见 t_safety.h。这里只补一句 R2 最容易写错的:
**断 VBUS 在锁内(它是 I²C 上的 PCA9555),关 eFuse 在锁外(它是 GPIO)。**
两者以前是一对 GPIO,现在不是了。 */
static safety_ev_fn s_sink;
static void *s_sink_ctx;
void t_safety_set_event_sink(safety_ev_fn fn, void *ctx)
{
s_sink = fn;
s_sink_ctx = ctx;
}
const char *t_safety_ev_str(safety_ev_t ev)
{
switch (ev) {
case SAFETY_EV_NONE:
return "无";
case SAFETY_EV_OVER_VOLT:
return "过压关路(SR-5)";
case SAFETY_EV_UNDER_VOLT:
return "欠压关路(SR-5)";
case SAFETY_EV_REVERSE:
return "反灌断VBUS(SW-8)";
case SAFETY_EV_PUSHED_UP:
return "被顶高断VBUS(SW-8)";
case SAFETY_EV_MUX_LOST:
return "mux失联全断";
case SAFETY_EV_SLOT_EMPTY:
return "刀片不在位";
default:
return "?";
}
}
/* 记一条。**永远在动作之后调** —— 记录慢一步没关系,断电慢一步不行。
`rail` 传 0 表示整板级事件(mux 失联),那时 V/I 记的是输入侧的。 */
static void emit(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
{
const ina226_vi_t *vi = (rail >= 1) ? &s->rail[rail - 1] : &s->input;
s->last.t_ms = now;
s->last.rail = (uint8_t)rail;
s->last.type = (uint8_t)ev;
s->last.bus_mv = vi->bus_mv;
s->last.current_ua = vi->current_ua;
s->events++;
if (s_sink != NULL) {
s_sink(s_sink_ctx, &s->last);
}
}
/* ---- 锁内:一路的读 + 判据 ----------------------------------------------- */
typedef struct {
safety_snap_t *s;
int rail;
uint32_t now; /* 本圈统一的时刻:五路要拿同一个时刻去算「持续了多久」 */
} cycle_arg_t;
/* SW-8:只断这一路的 VBUS,**不碰 eFuse** —— 反灌的能量是从 USB 那一侧来的,
把直供也一起关掉等于「因为充电器有问题就把手机关机」。
⚠️ 就在锁内、通道已选好的此刻断(规矩 3)。 */
static void cut_vbus_locked(safety_snap_t *s, int rail, safety_ev_t ev)
{
safety_rail_t *st = &s->st[rail - 1];
(void)slot_vbus_enable_locked(rail, false);
/* 三个序列里只有 EDL 会去写 `VBUS_EN`,它正跑着的话必须打断,否则下一拍
它就把 VBUS 又抬回来了。abort 会走 I²C(收尾要写 Port0),所以**留到锁外**。 */
if (t_actuator_seq(rail) == ACT_SEQ_EDL) {
st->pend_abort = true;
}
st->pend_ev = (uint8_t)ev;
}
/* SR-5:关这一路。**先断 VBUS(这里、锁内)再关 eFuse(锁外)** ——
只关 eFuse 的话,插着 USB 的那几路会停在「手机只靠 VBUS 供着电」,
那既不是开也不是关。 */
static void trip_rail_locked(safety_snap_t *s, int rail, safety_ev_t ev)
{
safety_rail_t *st = &s->st[rail - 1];
(void)slot_vbus_enable_locked(rail, false);
st->pend_abort = true;
st->pend_efuse = true;
st->pend_ev = (uint8_t)ev;
}
static void judge_locked(safety_snap_t *s, int rail, uint32_t now)
{
safety_rail_t *st = &s->st[rail - 1];
const bool en = rail_efuse_is_on(rail);
/* `EFUSE_EN` 的上升沿:缓启动窗口从这一刻算起。 */
if (en && !st->en_on) {
st->en_ms = now;
}
st->en_on = en;
/* 三道闸。任何一道不过就把圈数清零 —— 「关着的那几秒」不能算进
「持续 20 ms」里,不然一开机就跳闸。 */
if (!en || !s->rail_ok[rail - 1] || (now - st->en_ms) < T_SAFETY_SETTLE_MS) {
st->dev_n = 0;
return;
}
const int32_t mv = s->rail[rail - 1].bus_mv;
const int32_t ua = s->rail[rail - 1].current_ua;
const int32_t dev = mv - (int32_t)rail_setpoint_mv(rail);
/* ---- SW-8(先跑):只在 VBUS 通着的时候武装 -------------------------
反灌的能量只可能从 VBUS 那一侧来,断开之后判据自然就解除武装了。
先跑它是因为过压那种情况两条判据都成立:该先撤掉能量来源(VBUS),
20 ms 后 SR-5 再决定要不要连这一路一起关掉。 */
if (slot_vbus_is_on(rail)) {
if (ua < T_SAFETY_REV_UA) {
cut_vbus_locked(s, rail, SAFETY_EV_REVERSE);
} else if (dev > T_SAFETY_DEV_MV) {
cut_vbus_locked(s, rail, SAFETY_EV_PUSHED_UP);
}
}
/* ---- SR-5:±100 mV 连着 T_SAFETY_DEV_HOLD_CYCLES 圈(= 持续 20 ms)---- */
if (dev > T_SAFETY_DEV_MV || dev < -T_SAFETY_DEV_MV) {
if (st->dev_n < 0xFF) {
st->dev_n++;
}
if (st->dev_n >= T_SAFETY_DEV_HOLD_CYCLES) {
trip_rail_locked(s, rail, dev > 0 ? SAFETY_EV_OVER_VOLT : SAFETY_EV_UNDER_VOLT);
/* ⚠️ 这里**故意不**把 `dev_n` 清零。关掉之后下一圈 `en` 就是 false,
上面那道闸自己会清;而万一那次写引脚失败了(影子留在 on),
下一圈 `dev_n` 已经够数,会**立刻再关一次**。 */
}
} else {
st->dev_n = 0; /* 「持续」的意思是连续,中间回到范围内就从头数 */
}
}
/* 一路的锁内正文:读 V/I → 读两个输入端口 → 跑判据。
⚠️ 器件读失败**不提前返回**:PCA9555 读不到不代表 INA226 也读不到,
而判据只要有新鲜的电压就能出(规矩 2)。 */
static hal_err_t cycle_rail(void *arg)
{
cycle_arg_t *a = (cycle_arg_t *)arg;
safety_snap_t *s = a->s;
const int rail = a->rail;
hal_err_t first = HAL_OK;
ina226_vi_t vi;
hal_err_t e = ina226_read_vi(rail_cfg(rail)->ina_addr, &vi);
s->rail_ok[rail - 1] = (e == HAL_OK);
if (e == HAL_OK) {
s->rail[rail - 1] = vi; /* 失败就留着上一圈的值,别清零 */
} else {
s->i2c_errors++;
first = e;
}
pca9555_in_t in;
e = slot_read_inputs_locked(rail, &in);
s->io_ok[rail - 1] = (e == HAL_OK);
if (e == HAL_OK) {
s->io[rail - 1] = in;
} else {
s->i2c_errors++;
if (first == HAL_OK) {
first = e;
}
}
/* 判据就在这里跑:断 VBUS 要在本路通道选好、锁还持着的此刻(规矩 3)。 */
judge_locked(s, rail, a->now);
return first;
}
static hal_err_t cycle_input(void *arg)
{
safety_snap_t *s = (safety_snap_t *)arg;
ina226_vi_t vi;
const hal_err_t e = ina226_read_vi(INA226_ADDR_INPUT, &vi);
s->input_ok = (e == HAL_OK);
if (e == HAL_OK) {
s->input = vi;
} else {
s->i2c_errors++;
}
return e;
}
void t_safety_init(safety_snap_t *s)
{
assert(s != NULL);
memset(s, 0, sizeof(*s));
for (int i = 0; i < PHONERAIL_RAIL_COUNT; i++) {
s->present[i] = true; /* 与 slot.c 的初值一致:读到之前当它在位 */
}
/* 游标对齐到「现在」,否则第一圈会把启动期间(器件初始化)的事务算进来。 */
s->bus_cursor = hal_i2c_acct_bits();
s->txn_cursor = hal_i2c_acct_txns();
}
/* ---- 锁外:把锁内攒下的动作做掉 ------------------------------------------ */
static void apply_pending(safety_snap_t *s, uint32_t now)
{
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
safety_rail_t *st = &s->st[rail - 1];
if (st->pend_ev == (uint8_t)SAFETY_EV_NONE) {
continue;
}
/* 顺序:先 abort 再关 eFuse。反过来有个真实的洞 —— 硬重启的 `HR_OFF`
状态会在 4 s 后自己把 `EFUSE_EN` 抬回去,于是「关掉的路」几秒钟之后
又带电了。abort 之后那一路只会走收尾(只写 Port0),再也够不到 EN 线。 */
if (st->pend_abort) {
t_actuator_abort(rail);
}
if (st->pend_efuse) {
(void)rail_efuse_enable(rail, false);
}
emit(s, rail, (safety_ev_t)st->pend_ev, now);
st->pend_ev = (uint8_t)SAFETY_EV_NONE;
st->pend_efuse = false;
st->pend_abort = false;
}
}
/* ⭐ R2:刀片不在位就不许带电(计划 §5.8)。空槽上电 = 金手指裸露带 12V。
只在「本来是开着的」时候动作,否则每圈都会对着五个空槽刷事件。 */
static void check_present(safety_snap_t *s, uint32_t now)
{
/* ⚠️ 先把五根脚重读一遍再判。`slot_present()` 读的是缓存(读失败时要能
保留上一次的值),不 poll 的话缓存停在 `slot_prsnt_init()` 那一刻,
现象是**拔掉刀片永远判不出来**,而且一个错误码都不会有。
这一步是纯 GPIO,不占总线,所以每圈都做。 */
slot_prsnt_poll();
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
s->present[rail - 1] = slot_present(rail);
if (s->present[rail - 1] || !rail_efuse_is_on(rail)) {
continue;
}
t_actuator_abort(rail);
(void)rail_efuse_enable(rail, false);
/* VBUS 也尽力断一次。⚠️ 刀片**真的**被拔了的话这一步必然 NAK
(PCA9555 跟着刀片一起走了),那没关系;它挡的是另一种情形:
`PRSNT#` 接触不良 / 金手指 B31 虚接 —— 刀片还在、PCA9555 还应答,
这时只关 eFuse 会留下「直供断了但 USB 还灌着」的半开状态。
放在关 eFuse **之后**:断电优先,而这一步要走 I²C、可能等锁。 */
(void)slot_vbus_enable(rail, false);
emit(s, rail, SAFETY_EV_SLOT_EMPTY, now);
}
}
/* ⭐ R2:mux 是单点(计划 §5.8)。它一挂,五路的电压电流**全部看不见**,
SR-5 的整条监控链断了 —— 这时「继续供电但看不见」比「停机」危险。
⚠️ 先脉冲一次 `MUX_RST#` 再判:一次总线毛刺就停五台机器,代价太大。 */
static void check_mux(safety_snap_t *s, uint32_t now)
{
if (i2cbus_select_fail_streak() < T_SAFETY_MUX_FAIL_N) {
s->mux_step = 0; /* 选中过一次 ⇒ 这一轮结束(判据数的是「连续」,不是「累计」) */
return;
}
if (s->mux_step == 0) {
/* 这一轮还没试过复位 —— 先脉冲一次 `MUX_RST#`,下一圈再看。
⚠️ 这一步走到哪了要记在 `mux_step` 里,**不能拿 `i2cbus_recoveries()` 和
`s->mux_recovers` 比**:复位之后把后者同步成前者,两个数就永远相等,
于是每一圈都走这条分支 —— `MUX_RST#` 每 20 ms 脉冲一次、五路永远断不掉。 */
(void)i2cbus_recover();
s->mux_recovers++;
s->mux_step = 1;
return;
}
if (s->mux_step >= 2) {
/* 这一轮已经全断过了。再断一次没有任何用处,而每 20 ms 刷一条 MUX_LOST
会把事件日志那块 flash 写穿 —— 判据「一直在响」和判据「不灵」一样糟。
等某一次选通成功(streak 归零)之后才重新武装。 */
return;
}
/* 复位过了还是连续失败:认定失联。 */
s->mux_step = 2;
s->mux_trips++;
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
t_actuator_abort(rail);
}
(void)rail_all_off(); /* 前半段(断 VBUS)多半也会失败,那没关系 —— eFuse 是 GPIO */
emit(s, 0, SAFETY_EV_MUX_LOST, now);
}
/* 本圈还允许在等锁上花多久:单次上限与「整圈预算的余额」取小的那个。
⚠️ 这里量的是**墙钟**,所以事务本身花掉的时间也算在内 —— 那是对的:
预算要挡住的是「这一圈把整个周期占满」,而不只是「等锁等太久」。 */
static uint32_t lock_budget_left(uint32_t t0)
{
const uint32_t spent = hal_time_now_ms() - t0;
if (spent >= T_SAFETY_LOCK_BUDGET_MS) {
return 0;
}
const uint32_t left = T_SAFETY_LOCK_BUDGET_MS - spent;
return left < T_SAFETY_LOCK_TIMEOUT_MS ? left : T_SAFETY_LOCK_TIMEOUT_MS;
}
hal_err_t t_safety_step(safety_snap_t *s)
{
assert(s != NULL);
s->cycles++;
const uint32_t now = hal_time_now_ms();
hal_err_t first = HAL_OK;
bool skipped = false;
/* 本圈还能在等锁上花多久(`T_SAFETY_LOCK_BUDGET_MS` 那一段的理由)。
返回 0 表示预算用完了 —— 剩下的路这一圈不读了。 */
const uint32_t t0 = now;
/* 五路,一路一次 `xact`(通道查表,**不许算** —— rail_cfg.h 那条禁令)。 */
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
const uint32_t budget = lock_budget_left(t0);
if (budget == 0) {
skipped = true; /* 整圈预算用完:剩下的路本圈作废,下一拍重来 */
break;
}
cycle_arg_t a = { s, rail, now };
const hal_err_t e = i2cbus_xact(slot_mux_ch(rail), budget, cycle_rail, &a);
if (e == HAL_ERR_TIMEOUT) {
skipped = true; /* 规矩 1:不死等。这一路这一圈作废 */
}
if (e != HAL_OK && first == HAL_OK) {
first = e;
}
}
/* 第六片:输入侧,挂在主干上(不在任何通道后面)。 */
{
const uint32_t budget = lock_budget_left(t0);
if (budget == 0) {
skipped = true;
} else {
const hal_err_t e = i2cbus_xact(I2CBUS_CH_TRUNK, budget, cycle_input, s);
if (e == HAL_ERR_TIMEOUT) {
skipped = true;
}
if (e != HAL_OK && first == HAL_OK) {
first = e;
}
}
}
if (skipped) {
s->skipped++;
}
/* 放锁之后:关 eFuse(GPIO)、打断序列、记事件,再跑两条整板级判据。 */
apply_pending(s, now);
check_present(s, now);
check_mux(s, now);
s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor);
{
const uint32_t t = hal_i2c_acct_txns();
s->txns = t - s->txn_cursor;
s->txn_cursor = t;
}
return first;
}
uint32_t t_safety_cycle_txns(void)
{
/* 每路:选通道(1) + INA226 读 V(1) + 读 I(1) + PCA9555 读两个输入端口(1) = 4
主干:选通道(1) + INA226 读 V(1) + 读 I(1) = 3 */
return (uint32_t)PHONERAIL_RAIL_COUNT * 4u + 3u;
}
uint32_t t_safety_budget_us(void)
{
/* 逐条数,按事务形状分两类:
mux 选通 —— 纯写 1 字节(**没有寄存器指针**,见 tca9548a.h)
寄存器读 —— 写 1 字节寄存器号 + 读 2 字节 */
const uint32_t selects = (uint32_t)PHONERAIL_RAIL_COUNT + 1u; /* 五路 + 回主干 */
const uint32_t reads = t_safety_cycle_txns() - selects;
const uint64_t bits = (uint64_t)selects * hal_i2c_txn_bits(1, 0) +
(uint64_t)reads * hal_i2c_txn_bits(1, 2);
return (uint32_t)hal_i2c_bits_to_us(bits);
}
uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms)
{
if (period_ms == 0) {
return 0;
}
return (uint32_t)(((uint64_t)bus_us * 1000ull) / ((uint64_t)period_ms * 1000ull));
}
int t_safety_format(const safety_snap_t *s, char *buf, size_t len)
{
assert(s != NULL && buf != NULL && len > 0);
const uint32_t period = task_desc(TASK_SAFETY)->period_ms;
const uint32_t pm = t_safety_load_permille(s->bus_us, period);
return snprintf(buf, len,
"t_safety: cycles=%u skipped=%u i2c_err=%u txns=%u bus=%u us/%u ms "
"load=%u.%u%% events=%u mux(rst=%u trip=%u)",
(unsigned)s->cycles, (unsigned)s->skipped, (unsigned)s->i2c_errors,
(unsigned)s->txns, (unsigned)s->bus_us, (unsigned)period,
(unsigned)(pm / 10u), (unsigned)(pm % 10u), (unsigned)s->events,
(unsigned)s->mux_recovers, (unsigned)s->mux_trips);
}
+217 -158
查看文件
@@ -1,158 +1,217 @@
/* `t_safety` —— 20 ms 巡检任务的**一个周期**(需求 §10.3 第一行)。
一个周期干四件事:拿 I²C 锁 → 读 6 片 INA226 的 V+I 与 PCA9555 两个输入端口
→ 放锁 → **跑判据**(A10:SR-5 / SW-8,见本文件下半段)。
分成「一个周期是个函数」而不是直接写成 `while(1)`,是为了让它在宿主机上跑得起来:
宿主机没有 FreeRTOS,任务体是死循环就一行也测不了。ESP 侧的 `while(1)`
在 app_main.c 里,只有三行(拿节拍 / 调这个 / 睡到下一拍)。
---- 三条不能改的规矩 ----------------------------------------------------
1. **不许死等锁。** 拿不到就跳过这一周期并计数(`skipped`)。死等的现象是
巡检整个停摆,而它是优先级最高的那个任务 —— 停摆了没有任何人会发现。
2. **一个器件读失败不能中断这一圈。** 六片里坏一片,剩下五路的判据照常要出。
失联 ≠ 断电(§10.2),失联更不等于「别的路也不看了」。
3. **越界要断电时直接写 `EFUSE_EN` / `VBUS_EN` 引脚**(§10.3 原话:不经任何队列
或抽象层)。所以判据就写在这个函数里,不是发个消息给 t_actuator。 */
#ifndef T_SAFETY_H
#define T_SAFETY_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "hal_err.h"
#include "ina226.h"
#include "pca9555.h"
#include "phonerail.h"
#ifdef __cplusplus
extern "C" {
#endif
/* 等 I²C 锁最多等这么久。取周期的 1/4:等过头这一周期就废了,
而**废一个周期**(20 ms 后重来)比**赖着不走**(把最高优先级任务钉死)好得多。 */
#define T_SAFETY_LOCK_TIMEOUT_MS 5
/* ---- A10:两条判据的阈值。**每一个都有出处,改了就是改需求** ------------
`test_thresholds_match_the_documents()` 会拦。 */
/* SR-5 / SW-2:「电压偏离设定 >±100 mV」。比的是 INA226 读回来的母线电压与
**该路 DAC 的设定值**(`rail_setpoint_mv()`),不是 3900 这个常数 ——
SW-4 允许运维单路微调,拿常数比会把一台调到 4.0 V 的机器当成过压。 */
#define T_SAFETY_DEV_MV 100
/* SR-5 的「且持续 20 ms」——「持续」在这里数的是**圈**,不是墙钟毫秒:
连着两圈新鲜样本都越界才算,而成立的那一刻就是断电的那一刻。
⚠️ **不要改回按墙钟算**(`now - 第一次看见 >= 20 ms`)。巡检的采样时刻带着
亚毫秒残差:一圈自己要占 1.56 ms 总线,而「睡到下一拍」是按整毫秒算的,
于是相邻两次判据的时刻实测是 **21 / 19 ms 交替**。拿 `>= 20 ms` 去判,
有一半的机会 19 ms 那一次不算数、要等到第三圈 —— 也就是 40 ms 才关路,
直接超了 §12.1 的验收,而且是**间歇性**的:同一个故障注两次,一次过一次不过。
数圈没有这个毛病,而「两圈」在 20 ms 的周期下就是 20 ms。
⚠️ SR-5 说「持续 20 ms 才算」,§12.1 的验收说「20 ms 内关路」——
字面上互相顶着,只有「从第一次看见算起、下一圈就断电」同时满足两条。
注意这是从**第一次看见**算的:物理事件最早可能发生在上一圈之后,
所以对着外部世界的最坏延迟是 2 个周期。这不要紧 —— SR-5 原文就写着
「仅承担记录与告警,不承担实时保护」,真正的过压保护是 eFuse 里的硬件 OVP。 */
#define T_SAFETY_DEV_HOLD_CYCLES 2
/* 上面那个圈数折算成毫秒 = (圈数 − 1) × 巡检周期。需求 SR-5 写的就是这个 20 ms,
`test_thresholds_match_the_documents()` 拿它和任务表里的周期对账 ——
哪天巡检周期改了,这两个数会当场对不上,而不是悄悄把判据变成 40 ms。 */
#define T_SAFETY_DEV_HOLD_MS 20
/* SW-8 的反灌判据:设计文档 §15.2 对策 2 原话「if I < −50 mA → 立即拉低 VBUS_EN」。
离噪声很远:INA226 电流 LSB 是 0.5 mA,分流失调 ±10 µV / 5 mΩ ≈ ±2 mA,
−50 mA 是它的 25 倍。§12.1 的验收注入 −60 mA,落在阈值外侧。 */
#define T_SAFETY_REV_UA (-50000)
/* 反灌**不做确认窗口**(一圈看见就断),这和上面 20 ms 的过压判据是有意不对称的:
· 误断 VBUS 的代价是丢一次 USB 数据,手机还由直供电着,没有任何风险;
· 漏判的代价是能量泵回 12V 母线,**连累另外四台**(设计文档 §15.2 末尾)。
一圈就断 ⇒ 最坏 40 ms(事件发生 + 一圈),SW-8 给的预算是 200 ms,5 倍余量。 */
#define T_SAFETY_REV_BUDGET_MS 200
/* `EFUSE_EN` 拉高之后多久才开始判这一路的电压。tON 5.1 ms + 缓启动斜坡 11.5 ms
≈ 17 ms(设计文档 §5 / §7.4),取 3 个巡检周期。
没有这个窗口的现象是**每次上电都自己把自己关掉**:斜坡走到一半时
实测电压离设定值差着好几百 mV,判据不认识「正在爬」和「掉下去了」的区别。 */
#define T_SAFETY_SETTLE_MS 60
/* ---- A10:事件(§10.7 的「单条」形状:时间戳 + 路号 + 类型 + 当时的 V/I)----
A11 会把这条记录塞进环形缓冲,这里只负责**产生**它。故意先把记录类型定下来,
A11 就只剩「缓冲 + 落盘」,不用回头再动判据这一层。 */
typedef enum {
SAFETY_EV_NONE = 0,
SAFETY_EV_OVER_VOLT, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */
SAFETY_EV_UNDER_VOLT, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */
SAFETY_EV_REVERSE, /* SW-8:电流转负(I < −50 mA)→ 断这一路 VBUS */
SAFETY_EV_PUSHED_UP, /* SW-8:VBUS 期间电压被顶高 >+100 mV → 断这一路 VBUS */
} safety_ev_t;
typedef struct {
uint32_t t_ms; /* 上电起算的毫秒(§10.7:上位机连上后下发墙钟做偏移) */
uint8_t rail; /* 1~5 */
uint8_t type; /* safety_ev_t */
int32_t bus_mv; /* 出事那一刻的实测值,不是设定值 */
int32_t current_ua;
} safety_event_t; /* 16 B —— §10.7「单条 16 B 量级」 */
/* 事件出口。A11 在这里接环形缓冲;不接就只有快照里的 `last` 与计数。
⚠️ 回调是在**断电之后**才调的(先保命,再记账),而且此刻两把锁都没拿着 ——
接进来的东西不许阻塞,也不许回头调 t_safety_*。 */
typedef void (*safety_ev_fn)(void *ctx, const safety_event_t *ev);
void t_safety_set_event_sink(safety_ev_fn fn, void *ctx);
const char *t_safety_ev_str(safety_ev_t ev);
/* 每路的判据状态。全在快照里,`t_safety_init()` 一并清干净。 */
typedef struct {
bool en_on; /* 上一圈看到的 `EFUSE_EN` 影子,用来认它的上升沿 */
uint32_t en_ms; /* 上升沿的时刻,缓启动窗口从这里算(这个是真按毫秒的) */
uint8_t dev_n; /* 连着几圈越界了。回到范围内 / 这一路被关掉就清零 */
} safety_rail_t;
typedef struct {
/* 这一圈读回来的原始量。`ok` 为 false 时对应的值是**上一圈的**,不是 0 ——
判据必须先看 ok 再看值,拿一个没更新的电压去判过压是最难查的那种错。 */
ina226_vi_t rail[PHONERAIL_RAIL_COUNT];
bool rail_ok[PHONERAIL_RAIL_COUNT];
ina226_vi_t input; /* 输入侧 U13(0x43),整板功率预算 */
bool input_ok;
pca9555_in_t io; /* 五路 FLT# / PGOOD + Buck PG 或 + INA ALERT 或 */
bool io_ok;
safety_rail_t st[PHONERAIL_RAIL_COUNT]; /* 每路的判据状态(内部用) */
safety_event_t last; /* 最近一条事件;`last.type == SAFETY_EV_NONE` = 还没出过 */
uint32_t events; /* 出过几条事件 */
uint32_t cycles; /* 跑过几圈(含被跳过的) */
uint32_t skipped; /* 其中因为拿不到 I²C 锁而整圈跳过的 */
uint32_t i2c_errors; /* 累计器件级失败次数(一圈里可能不止一次) */
uint32_t bus_us; /* **上一圈**在 I²C 上真正花掉的时间 */
uint64_t bus_cursor; /* 内部:位数游标,别在外面改 */
} safety_snap_t;
void t_safety_init(safety_snap_t *s);
hal_err_t t_safety_step(safety_snap_t *s);
/* 按 §10.3 的算式静态算出来的「一圈该占总线多久」,单位 µs。
它和 `s->bus_us` 应当**一模一样** —— 不一样只有两种可能:巡检里多读/少读了东西,
或者有别的任务在同一圈里插了事务进来。测试拿这两个数对账。 */
uint32_t t_safety_budget_us(void);
/* 占用率(千分之)。20 ms 周期、1560 µs → 78‰。§10.3 手算的是「约 8 %」。 */
uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms);
/* 一行人话,给启动日志和遥测用。返回写进去的字符数(不含结尾 0)。 */
int t_safety_format(const safety_snap_t *s, char *buf, size_t len);
#ifdef __cplusplus
}
#endif
#endif /* T_SAFETY_H */
/* `t_safety` —— 20 ms 巡检任务的**一个周期**(需求 §10.3 第一行)。
---- R2 之后一个周期是什么样 ----------------------------------------------
R1:一把 I²C 锁包住整圈,读 6 片 INA226 + 1 片 PCA9555,放锁,再跑判据。
R2:五路各在**自己的 mux 通道**上,所以一圈是**五次 `i2cbus_xact` + 一次主干**:
for 每一路: xact(本路通道) { 读 INA226 的 V/I → 读 PCA9555 两个输入端口
→ 跑本路判据(必要时**就在这里**断 VBUS) }
xact(主干) { 读输入侧 INA226 }
放锁之后: PRSNT# 轮询 → 关 eFuse / abort 序列 / 记事件 → mux 失联判据
每路一次 `xact` 而不是一把大锁包住整圈,是为了给 `t_actuator` 留得进来的缝
(R2 之后按键也走 I²C,计划 §5.6)。
分成「一个周期是个函数」而不是直接写成 `while(1)`,是为了让它在宿主机上跑得起来。
---- 四条不能改的规矩 ----------------------------------------------------
1. **不许死等锁。** 拿不到就跳过这一路并计数(`skipped`)。死等的现象是
巡检整个停摆,而它是优先级最高的那个任务 —— 停摆了没有任何人会发现。
2. **一个器件读失败不能中断这一圈。** 某一路坏了,剩下四路的判据照常要出。
失联 ≠ 断电(§10.2),失联更不等于「别的路也不看了」。
3. **断 VBUS 必须在锁内、在本路的 `xact` 里做。** ⚠️ 这条是 R2 新的:
`VBUS_EN` 从 GPIO 变成了刀片 PCA9555 的 P0.4(计划 §5.4),
放锁之后再拿一次锁去断,中间隔着一次锁争用 —— 而且那时通道可能已经被
别人切走了。**`EFUSE_EN` 相反**:它是 GPIO 直连,放锁之后关才对
(拿着总线锁去写引脚只会把别人多挡一会儿)。
4. **判据一条也不许额外发 I²C 事务。** 判据要的三个量(设定值、EN 的状态、
正在跑的序列)全部有影子可查。多读一次就把预算顶穿,测试会当场变红。 */
#ifndef T_SAFETY_H
#define T_SAFETY_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "hal_err.h"
#include "ina226.h"
#include "pca9555.h"
#include "phonerail.h"
#ifdef __cplusplus
extern "C" {
#endif
/* 每一路等 I²C 锁最多等这么久。等过头这一路就废了,而**废一路一圈**
(20 ms 后重来)比**赖着不走**(把最高优先级任务钉死)好得多。 */
#define T_SAFETY_LOCK_TIMEOUT_MS 5
/* ⭐ **整圈等锁的总预算**,R2 必须有的一条。
R1 一圈只拿一次锁,所以「单次超时 5 ms」就是全部的账。**R2 一圈要拿六次**
(五路各一次 + 主干一次),单看每次 5 ms 没问题,六次加起来是 **30 ms —— 比
20 ms 的周期还长**。总线长期繁忙时的现象不是「偶尔丢一拍」,而是**巡检成片
地丢拍、周期整个失准**,而每一次 `xact` 都老老实实在 5 ms 就返回了,
哪一层都不会报错。
所以除了单次超时,还要有一条整圈的闸:本圈在等锁上花掉的时间超过这个数,
**剩下的路这一圈就不读了**(记 `skipped`)。取周期的一半,留另一半给判据、
给 `t_actuator`、给别的任务。
⚠️ 已知的不公平:预算是按顺序花的,所以长期繁忙时**后面几路会比前面几路
更常被跳过**。没有做轮转起点,因为 SR-5 判据数的是「连续两圈都越界」,
某一路偶尔漏一圈只让它晚一圈跳闸;而轮转会让「第几次拿锁」这种可断言的
不变量消失,代价比收益大。真正该看的是 `skipped` 有没有持续增长。 */
#define T_SAFETY_LOCK_BUDGET_MS 10
/* ---- A10:判据阈值。**每一个都有出处,改了就是改需求** ------------------
`test_thresholds_match_the_documents()` 会拦。 */
/* SR-5 / SW-2:「电压偏离设定 >±100 mV」。比的是 INA226 读回来的母线电压与
**该路 DAC 的设定值**(`rail_setpoint_mv()`),不是 3900 这个常数 ——
SW-4 允许运维单路微调,拿常数比会把一台调到 4.0 V 的机器当成过压。 */
#define T_SAFETY_DEV_MV 100
/* SR-5 的「且持续 20 ms」——「持续」在这里数的是**圈**,不是墙钟毫秒:
连着两圈新鲜样本都越界才算,而成立的那一刻就是断电的那一刻。
⚠️ **不要改回按墙钟算**(`now - 第一次看见 >= 20 ms`)。巡检的采样时刻带着
亚毫秒残差:一圈自己要占 2.34 ms 总线,而「睡到下一拍」是按整毫秒算的,
于是相邻两次判据的时刻实测是 **21 / 19 ms 交替**。拿 `>= 20 ms` 去判,
有一半的机会 19 ms 那一次不算数、要等到第三圈 —— 也就是 40 ms 才关路,
直接超了 §12.1 的验收,而且是**间歇性**的。数圈没有这个毛病。
⚠️ SR-5 说「持续 20 ms 才算」,§12.1 的验收说「20 ms 内关路」——
字面上互相顶着,只有「从第一次看见算起、下一圈就断电」同时满足两条。 */
#define T_SAFETY_DEV_HOLD_CYCLES 2
/* 上面那个圈数折算成毫秒 = (圈数 − 1) × 巡检周期。需求 SR-5 写的就是这个 20 ms,
`test_thresholds_match_the_documents()` 拿它和任务表里的周期对账。 */
#define T_SAFETY_DEV_HOLD_MS 20
/* SW-8 的反灌判据:设计文档 §15.2 对策 2 原话「if I < −50 mA → 立即拉低 VBUS_EN」。
离噪声很远:INA226 电流 LSB 是 0.5 mA,分流失调 ±10 µV / 5 mΩ ≈ ±2 mA,
−50 mA 是它的 25 倍。§12.1 的验收注入 −60 mA,落在阈值外侧。 */
#define T_SAFETY_REV_UA (-50000)
/* 反灌**不做确认窗口**(一圈看见就断),这和 20 ms 的过压判据是有意不对称的:
· 误断 VBUS 的代价是丢一次 USB 数据,手机还由直供电着,没有任何风险;
· 漏判的代价是能量泵回 12V 母线,**连累另外四台**。
一圈就断 ⇒ 最坏 40 ms + 一次 I²C 事务(≈0.16 ms),SW-8 给的预算是 200 ms。 */
#define T_SAFETY_REV_BUDGET_MS 200
/* `EFUSE_EN` 拉高之后多久才开始判这一路的电压。tON 5.1 ms + 缓启动斜坡 11.5 ms
≈ 17 ms(设计文档 §5 / §7.4),取 3 个巡检周期。
没有这个窗口的现象是**每次上电都自己把自己关掉**。 */
#define T_SAFETY_SETTLE_MS 60
/* ⭐ R2 新增:**连续**多少次选不中 mux 通道才认定它失联(计划 §5.8 与 §11)。
⚠️ 这个数是「先按 3 写、上板实测总线误码率之后再定」的待定项,不是算出来的。
太小(1):一次总线毛刺就停掉五台手机;太大(10):失联之后还有 200 ms
在拿旧数据跑判据。判定成立之后**先脉冲一次 `MUX_RST#`**,再不行才全断。 */
#define T_SAFETY_MUX_FAIL_N 3
/* ---- A10:事件(§10.7 的「单条」形状) -----------------------------------
A11 把这条记录塞进环形缓冲,这里只负责**产生**它。 */
typedef enum {
SAFETY_EV_NONE = 0,
SAFETY_EV_OVER_VOLT, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */
SAFETY_EV_UNDER_VOLT, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */
SAFETY_EV_REVERSE, /* SW-8:电流转负(I < −50 mA)→ 断这一路 VBUS */
SAFETY_EV_PUSHED_UP, /* SW-8:VBUS 期间电压被顶高 >+100 mV → 断这一路 VBUS */
/* ⭐ R2 新增两条(计划 §5.8)。放在**末尾**:`evlog` 那边的数字是 flash 格式。 */
SAFETY_EV_MUX_LOST, /* TCA9548A 连续选不中 ⇒ 五路全部断电(单点) */
SAFETY_EV_SLOT_EMPTY, /* `PRSNT#` 为高(刀片不在位)⇒ 这一路不许带电 */
} safety_ev_t;
typedef struct {
uint32_t t_ms; /* 上电起算的毫秒(§10.7:上位机连上后下发墙钟做偏移) */
uint8_t rail; /* 1~5;**0 = 整板级**(mux 失联就是 0) */
uint8_t type; /* safety_ev_t */
int32_t bus_mv; /* 出事那一刻的实测值,不是设定值 */
int32_t current_ua;
} safety_event_t; /* 16 B —— §10.7「单条 16 B 量级」 */
/* 事件出口。A11 在这里接环形缓冲。
⚠️ 回调是在**动作之后**才调的(先保命,再记账),而且此刻锁已经放掉了 ——
接进来的东西不许阻塞,也不许回头调 t_safety_*。 */
typedef void (*safety_ev_fn)(void *ctx, const safety_event_t *ev);
void t_safety_set_event_sink(safety_ev_fn fn, void *ctx);
const char *t_safety_ev_str(safety_ev_t ev);
/* 每路的判据状态。全在快照里,`t_safety_init()` 一并清干净。 */
typedef struct {
bool en_on; /* 上一圈看到的 `EFUSE_EN` 影子,用来认它的上升沿 */
uint32_t en_ms; /* 上升沿的时刻,缓启动窗口从这里算(这个是真按毫秒的) */
uint8_t dev_n; /* 连着几圈越界了。回到范围内 / 这一路被关掉就清零 */
/* 本圈锁内判据留给锁外的两件事(规矩 3:VBUS 锁内断,eFuse 锁外关)。 */
uint8_t pend_ev; /* safety_ev_t,0 = 没有 */
bool pend_efuse; /* 锁外要把 `EFUSE_EN` 拉低 */
bool pend_abort; /* 锁外要打断这一路正在跑的序列 */
} safety_rail_t;
typedef struct {
/* 这一圈读回来的原始量。`ok` 为 false 时对应的值是**上一圈的**,不是 0 ——
判据必须先看 ok 再看值,拿一个没更新的电压去判过压是最难查的那种错。 */
ina226_vi_t rail[PHONERAIL_RAIL_COUNT];
bool rail_ok[PHONERAIL_RAIL_COUNT];
/* ⭐ R2:**每路一份** PCA9555 输入(R1 是全板一片,所以只有一份)。 */
pca9555_in_t io[PHONERAIL_RAIL_COUNT];
bool io_ok[PHONERAIL_RAIL_COUNT];
/* ⭐ R2:`PRSNT#` 快照(纯 GPIO,不占总线)。 */
bool present[PHONERAIL_RAIL_COUNT];
ina226_vi_t input; /* 输入侧 U13(0x43,主干),整板功率预算 */
bool input_ok;
safety_rail_t st[PHONERAIL_RAIL_COUNT];
safety_event_t last; /* 最近一条事件;`last.type == SAFETY_EV_NONE` = 还没出过 */
uint32_t events;
uint32_t cycles; /* 跑过几圈(含被跳过的) */
uint32_t skipped; /* 其中至少有一路因为拿不到锁被跳过的圈数 */
uint32_t i2c_errors; /* 累计器件级失败次数(一圈里可能不止一次) */
uint32_t mux_recovers; /* 脉冲过几次 `MUX_RST#` */
uint32_t mux_trips; /* 因 mux 失联全断过几次 */
/* mux 失联判据走到哪一步了:0 = 没事 / 1 = 这一轮复位过了 / 2 = 这一轮已经全断。
⚠️ 少了这个「走到哪一步」,两段式就退化成**死循环**:每一圈都满足
「连续失败够数」,于是每 20 ms 脉冲一次 `MUX_RST#`(永远断不掉),
或者每 20 ms 全断一次并刷一条事件(把 evlog 那块 flash 写穿)。
两种现象都不是「判据不灵」,而是「判据一直在响」。
选中过一次通道(streak 归零)就回到 0,下次失联重新走两段。 */
uint8_t mux_step;
uint32_t bus_us; /* **上一圈**在 I²C 上真正花掉的时间 */
uint32_t txns; /* **上一圈**真正发出去的事务数(拿它和预算对账) */
uint64_t bus_cursor; /* 内部:位数游标,别在外面改 */
uint32_t txn_cursor; /* 内部:事务数游标 */
} safety_snap_t;
void t_safety_init(safety_snap_t *s);
hal_err_t t_safety_step(safety_snap_t *s);
/* 按上面那一圈静态算出来的「一圈该占总线多久」,单位 µs。
⚠️ R2 里它含 **6 次 mux 选通**(五路各一次 + 回主干一次)。
它和 `s->bus_us` 应当**一模一样** —— 不一样只有两种可能:巡检里多读/少读了
东西,或者有别的任务在同一圈里插了事务进来。测试拿这两个数对账。 */
uint32_t t_safety_budget_us(void);
/* 一圈的名义事务数(R2 = 5×(选通+2+1) + (选通+2) = 23)。 */
uint32_t t_safety_cycle_txns(void);
/* 占用率(千分之)。20 ms 周期、2340 µs → 117‰。R1 是 78‰(计划 §5.5)。 */
uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms);
/* 一行人话,给启动日志和遥测用。返回写进去的字符数(不含结尾 0)。 */
int t_safety_format(const safety_snap_t *s, char *buf, size_t len);
#ifdef __cplusplus
}
#endif
#endif /* T_SAFETY_H */
+233
查看文件
@@ -0,0 +1,233 @@
/* `t_thermal` 的实现。判据与出处全部写在 `t_thermal.h` 的文件头,这里只放代码。 */
#include "t_thermal.h"
#include <string.h>
#include "board_pins.h"
#include "evlog.h"
#include "hal_gpio.h"
#include "hal_pwm.h"
#include "hal_time.h"
/* 占空比有下限 ⇒ 窗口的墙钟长度有上界 = `TACH_ON_MS × 1000 / DUTY_MIN`。
这两条断言钉的是同一件事:**下限为 0 时导通时间永远攒不满,停转判据会静默失效**
(不报错、不告警,只是再也不报停转)。所以下限只能在这里被改小一点点,
改到让窗口超过 30 s 就编译不过。 */
_Static_assert(T_THERMAL_DUTY_MIN > 0, "占空比下限为 0:TACH 窗口永远攒不满,停转判据会静默失效");
_Static_assert((uint64_t)T_THERMAL_TACH_ON_MS * 1000u / T_THERMAL_DUTY_MIN <= T_THERMAL_TACH_MAX_MS,
"占空比下限太低:TACH 窗口会拖过 T_THERMAL_TACH_MAX_MS,风扇停了很久才报得出来");
static therm_temp_fn s_temp_fn;
static therm_tach_fn s_tach_fn;
static void *s_ctx;
static bool s_inited;
/* 风扇侧占空比 → 真正写进 `hal_pwm` 的值。Q6 是低边管,栅极高 = 把风扇的
PWM 线拉低 ⇒ 反相(`t_thermal.h` 硬件那三条的第 2 条)。 */
static uint16_t gpio_duty_of(uint16_t fan_duty)
{
#if T_THERMAL_PWM_INVERTED
return (uint16_t)(1000u - fan_duty);
#else
return fan_duty;
#endif
}
uint16_t t_thermal_duty_for(int32_t milli_c, bool valid)
{
/* 读不到温度 = 满速。需求 §10.10:**不要按「读不到温度 = 不热」处理**。 */
if (!valid) {
return 1000;
}
if (milli_c <= T_THERMAL_TEMP_LO_MC) {
return T_THERMAL_DUTY_MIN;
}
if (milli_c >= T_THERMAL_TEMP_HI_MC) {
return 1000;
}
/* 分段线性。分子最大 = 25000 × 700 = 1.75e7,int32 装得下(不用 64 位)。 */
const int32_t span_mc = T_THERMAL_TEMP_HI_MC - T_THERMAL_TEMP_LO_MC;
const int32_t span_duty = 1000 - T_THERMAL_DUTY_MIN;
return (uint16_t)(T_THERMAL_DUTY_MIN + (milli_c - T_THERMAL_TEMP_LO_MC) * span_duty / span_mc);
}
static hal_err_t apply_duty(thermal_snap_t *s, uint16_t fan_duty)
{
const uint16_t g = gpio_duty_of(fan_duty);
const hal_err_t e = hal_pwm_set_permille(T_THERMAL_PWM_CH, g);
if (e != HAL_OK) {
s->pwm_errors++;
return e;
}
s->duty = fan_duty;
s->gpio_duty = g;
return HAL_OK;
}
hal_err_t t_thermal_init(thermal_snap_t *s, therm_temp_fn temp, therm_tach_fn tach, void *ctx)
{
if (s == NULL) {
return HAL_ERR_ARG;
}
memset(s, 0, sizeof(*s));
s_temp_fn = temp;
s_tach_fn = tach;
s_ctx = ctx;
/* ① `FAN_EN` 先配成输出、先写 0 —— 设计文档 §22.6 的上电顺序。
先关一下不是多余的:复位态是「不转」靠的是 R749 下拉,而**配成输出的那一刻**
引脚就开始驱动了,不先写就是让上一次跑飞留下的电平决定 12V 通断。 */
hal_err_t e = hal_gpio_config(BOARD_PIN_FAN_EN, HAL_GPIO_OUTPUT);
if (e != HAL_OK) {
return e;
}
e = hal_gpio_write(BOARD_PIN_FAN_EN, 0);
if (e != HAL_OK) {
return e;
}
/* ② 配 PWM,并先按**满速**输出:还没读到第一个温度之前,宁可吵一秒。 */
e = hal_pwm_config(T_THERMAL_PWM_CH, BOARD_PIN_FAN_PWM, T_THERMAL_PWM_HZ);
if (e != HAL_OK) {
return e;
}
e = apply_duty(s, 1000);
if (e != HAL_OK) {
return e;
}
/* ③ 最后才给 12V。⚠️ 这一行就是「固件必须主动开」那件事本身:
删掉它之后温度闭环、PWM、遥测全部照常工作,只是风扇一动不动。 */
e = hal_gpio_write(BOARD_PIN_FAN_EN, 1);
if (e != HAL_OK) {
return e;
}
s->fan_en = true;
s->en_ms = hal_time_now_ms();
s->last_ms = s->en_ms;
s_inited = true;
return HAL_OK;
}
/* 一路 TACH 的窗口累加 + 结算。`dt_ms` 是本圈实测的时间,`duty` 是**这段时间里
实际在输出的**占空比(也就是上一圈定下来的那个),不是刚算出来的新值。 */
static void tach_step(thermal_snap_t *s, int idx, uint32_t dt_ms, uint16_t duty)
{
therm_tach_state_t *t = &s->fan[idx];
uint32_t now_edges = 0;
if (s_tach_fn == NULL || !s_tach_fn(s_ctx, idx, &now_edges)) {
/* 计数器这一刻读不到:本圈不累加也不判。失联 ≠ 停转。 */
return;
}
if (!t->have_last) {
t->last_edges = now_edges;
t->have_last = true;
return;
}
const uint32_t delta = now_edges - t->last_edges; /* uint32 相减,回绕安全 */
t->last_edges = now_edges;
/* 启动宽限期内一直重置窗口:这时候风扇还在起转,脉冲本来就没有或很稀。 */
if (s->fan_en && hal_time_elapsed_ms(s->en_ms) < T_THERMAL_SPINUP_MS) {
t->win_edges = 0;
t->win_on_ms = 0;
t->win_ms = 0;
return;
}
t->win_edges += delta;
t->win_ms += dt_ms;
/* **导通时间**:脉冲只可能出现在这段时间里,判据看的就是它(TR-6,见头文件)。 */
t->win_on_ms += (uint32_t)((uint64_t)dt_ms * duty / 1000u);
if (t->win_on_ms < T_THERMAL_TACH_ON_MS) {
return; /* 还没攒够导通时间,接着攒 —— 这期间**不判**,包括不判停转 */
}
/* ---- 结算 ---- */
t->windows++;
/* RPM 按**墙钟**窗口算(四线风扇的 TACH 是连续的,12V 常供)。
⚠️ 万一最终装的是三线低边调速风扇,脉冲只在导通期出现,这个数会按占空比
偏低 —— 那时可信的只有下面那条停转判据,RPM 要改用 `win_on_ms` 当分母。
风扇定型(§22.6 的 MPN 待补)之后回来改这一行。 */
if (t->win_ms > 0) {
t->rpm = (uint32_t)((uint64_t)t->win_edges * 60000u / ((uint64_t)T_THERMAL_TACH_PPR * t->win_ms));
t->rpm_valid = true;
}
const bool stalled_now = (t->win_edges < T_THERMAL_TACH_MIN_EDGES);
if (stalled_now && !t->stalled) {
t->stalls++;
/* 只在**上升沿**记一条:停转是个持续状态,每个窗口记一条会把 256 条的
RAM 缓冲刷光,把过压/反灌那些真正救命的事件挤掉(`evlog.h` 的 LOST)。
`rail = 0` 是整板级;`arg` 是风扇号;`bus_mv` 借位放算出来的 RPM。 */
evlog_put(0, LOG_EV_FAN_STALL, (uint8_t)idx, (int32_t)t->rpm, 0);
}
t->stalled = stalled_now;
t->win_edges = 0;
t->win_on_ms = 0;
t->win_ms = 0;
}
hal_err_t t_thermal_step(thermal_snap_t *s)
{
if (s == NULL) {
return HAL_ERR_ARG;
}
if (!s_inited) {
return HAL_ERR_STATE;
}
const uint32_t now_ms = hal_time_now_ms();
const uint32_t dt_ms = hal_time_elapsed_ms(s->last_ms);
s->last_ms = now_ms;
s->cycles++;
/* 这段 dt 里真正在输出的是**上一圈**定下的占空比。先记下来再去改它 ——
用新值去算刚过去的那段导通时间,就是在拿未来的事实解释过去的脉冲数。 */
const uint16_t prev_duty = s->duty;
/* ---- 温度 → 占空比(先做:散热是这一包的活,事件与转速都在它后面)---- */
s->temp_valid = false;
s->temp_max_mc = 0;
for (int i = 0; i < T_THERMAL_NTC_COUNT; i++) {
int32_t mc = 0;
s->temp_ok[i] = (s_temp_fn != NULL) && s_temp_fn(s_ctx, i, &mc);
if (!s->temp_ok[i]) {
continue;
}
s->temp_mc[i] = mc;
/* 两路取**高**:一路在电感区、一路在 MOS 区(设计 §8.2),
凉的那一路完全可能是「那半边没在用」。取平均会把一个 90 °C 的热点
和一个 30 °C 的凉点平成 60 °C —— 一个哪儿都不存在、但看着很健康的数。 */
if (!s->temp_valid || mc > s->temp_max_mc) {
s->temp_max_mc = mc;
s->temp_valid = true;
}
}
if (!s->temp_valid) {
s->temp_errors++;
}
(void)apply_duty(s, t_thermal_duty_for(s->temp_max_mc, s->temp_valid));
/* ---- 过温降载:本模块只**报**,关路是限功率那一包的事 ---- */
if (s->temp_valid) {
if (!s->derate && s->temp_max_mc >= T_THERMAL_DERATE_MC) {
s->derate = true;
s->derates++;
/* `bus_mv` = 温度 m°C(`evlog.h` 对这个类型的约定),`arg` = 占空比 %。 */
evlog_put(0, LOG_EV_THERMAL_DERATE, (uint8_t)(s->duty / 10u), s->temp_max_mc, 0);
} else if (s->derate && s->temp_max_mc <= T_THERMAL_DERATE_CLEAR_MC) {
s->derate = false; /* 解除不记事件:解除不是故障 */
}
}
/* ---- 两路 TACH ---- */
for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) {
tach_step(s, i, dt_ms, prev_duty);
}
return HAL_OK;
}
+243
查看文件
@@ -0,0 +1,243 @@
/* `t_thermal` —— 1 s 热管理任务的**一个周期**(需求 §10.3 第二行、计划 A16 / R-6)。
一个周期干四件事:读两路温度 → 算风扇占空比 → 结算 TACH 采样窗口 → 记事件。
和 `t_safety` 一样写成「一个周期是一个函数」而不是 `while(1)`:死循环在宿主机上
一行也跑不到,而这一包的全部价值就是下面那几条判据。
---- R2 的风扇硬件(**已按板上 PCB 网络逐脚核实**,设计文档 §22.6 FAN-R2)----
+12V ──Q7(P-MOS)── FAN_12V_SW ── J26.2 / J27.2(两只风扇的 12V)
Q7.G ←─ Q8(N-MOS) ←─ R748 ←─ FAN_EN(IO48)
FAN_PWM(IO47) ─R733─ Q6.G,Q6.D = FAN_PWM_L → J26.4 / J27.4(**一路喂两只**)
J26.3 / J27.3 ─→ Q9 / Q10 栅极,漏极上拉到 +3V3 → FAN_TACH_IN(IO21) / FAN_TACH_OUT(IO14)
由此来的**三件固件必须知道的事**,每一件写反了都不会报错:
1. **`FAN_EN` 复位态 = 风扇不转。** R749 把 Q8 栅极拉到地、R746 把 Q7 栅极拉回源极,
所以 MCU 高阻(复位、下载、跑飞)时 12V 是断的 —— 这是 SR-6「复位安全」要的,
代价是**固件必须主动把它拉高**。忘了拉高的现象是「一切正常,就是不散热」:
温度闭环照常算占空比、PWM 照常输出、TACH 读不到脉冲被启动宽限盖住,
功能测试全绿,只有在机器满载跑了半小时之后才以「手机降频」的形式暴露。
2. **PWM 是反相的。** Q6 是低边 N-MOS,栅极高 ⇒ 把风扇的 PWM 线**拉低**
⇒ `D_gpio = 1 − D_fan`。本模块对外一律用**风扇侧**占空比(`duty`),
只在写 `hal_pwm_set_permille()` 的那一行做反相(`gpio_duty`)。
写反了的现象是「越热转得越慢」—— 而遥测里占空比在跟着温度往上走,完全正常。
3. **TACH 是反相的。** 开集三极管 Q9/Q10 各接一路,风扇 TACH 拉低 ⇒ GPIO 读到高。
**数边沿没问题,任何「按电平判它转没转」的写法都是反的。** 所以本模块只吃
「累计边沿数」,一次电平也不读 —— 见 `therm_tach_fn`。
---- 温度从哪来:**没有 ADC 的 HAL,也不打算加**(R-6 的一个决定)----------
R2 把载板整个删了,LM75 跟着没了,温度只剩两路 NTC(IO4/IO5,ADC1)。
本项目的 HAL 只有 gpio / i2c / pwm / time / store / lock 六样,**没有 ADC**。
这里**不加第七样**,理由两条:
· ESP32-S3 的 ADC 校准(`esp_adc_cal` 曲线拟合 + eFuse 里的校准值)在没有板子
的时候一行也验不了,加进 HAL 只会得到一个「sim 里永远对、板上还没试过」的接口;
· HAL 每多一个概念,后面每一次会话都要多读一遍。
所以温度**由外部喂进来**(`therm_temp_fn`),和 `t_actuator` 的 PG 回调同一个形状。
ADC 采样 + NTC 查表留到上板那一包(Track B),那时它是一个几十行的
`ntc.c`,接进这个回调即可,本模块一个字不用改。TACH 计数器同理(板上是 PCNT)。
---- 采样窗口:这一包最容易写错的地方(需求 TR-6)------------------------
低边调速的三线风扇在 PWM 关断期间**根本没有回流**,TACH 那一路是死的;
于是「这个 1 s 窗口里没数到脉冲 ⇒ 停转」在占空比低的时候会**稳定误报**。
(FAN-R2 换成四线风扇之后 12V 是常供的、TACH 连续,但判据不能建立在
「买到的一定是四线风扇」上 —— 而且四线风扇的脉冲只会**更多**,不会更少。)
本模块选的办法是 **③「窗口长度与占空比挂钩」**:判据不看墙钟走了多久,
而是看这个窗口里**攒够了多少导通时间**(`T_THERMAL_TACH_ON_MS`)。
导通时间是脉冲真正可能出现的时间,所以「窗口里该有多少个脉冲」与占空比无关,
门限就能是一个常数。占空比 100 % 时窗口 3 s,30 % 时自动拉长到 10 s。
没选另外两个的理由:
· ①「窗口跨足够多个 PWM 周期」解决不了问题 —— 25 kHz 下 1 s 已经跨了 25000 个
周期,照样会误报。误报的根源不是周期数少,是**期望脉冲数随占空比变**。
· ②「只在导通期计数」要求固件跟着 PWM 相位走。25 kHz、一个周期 40 µs,
软件跟不上;真做只能上硬件(PCNT 的 control 脚接 PWM),那是板上那一包的事,
而且它解决的是同一个问题的硬件版本 —— 本模块的判据在那种硬件下照样成立。
⚠️ 与之配套的是**占空比下限** `T_THERMAL_DUTY_MIN`:占空比真掉到 0,
导通时间永远攒不满,这条判据就变成了「永远不判」。所以闭环的下限不是 0;
真要停风扇只能撤 `FAN_EN`(§22.6:0 % PWM 时四线风扇仍转 3300 RPM)。
---- 事件:入队,不落盘 --------------------------------------------------
`evlog_put()` 是几条指令的入队,任何任务都能调;`evlog_flush()` 是唯一碰 flash
的地方,只有 `t_log` 调(`evlog.h` 文件头)。本模块一次也不碰 flash。 */
#ifndef T_THERMAL_H
#define T_THERMAL_H
#include <stdbool.h>
#include <stdint.h>
#include "hal_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- 硬件常数(有出处的那些)-------------------------------------------- */
/* 风扇 PWM 走 `hal_pwm` 的 0 号通道 —— 全板只有这一路 PWM(`hal_pwm.h` 文件头)。 */
#define T_THERMAL_PWM_CH 0
/* 25 kHz:四线风扇 PWM 的通行规格(21~28 kHz),也是 `hal_pwm_esp.c` 与
`test_hal_sim.c` 里已经在用的数。落进音频带(<20 kHz)的现象是**啸叫**。 */
#define T_THERMAL_PWM_HZ 25000
/* ⚠️ Q6 反相(设计文档 §22.6「栅极高 = 风扇 PWM 线低」,已按板上网表核实:
`R733: FAN_PWM → Q6.G`、`Q6.D = FAN_PWM_L → J26.4/J27.4`)。 */
#define T_THERMAL_PWM_INVERTED 1
/* 每转几个 TACH 脉冲。⚠️ **待上板实测**:§22.6 原话「2PPR 也来自系列波形示例」,
而 2026-09-13 换成 60×60×10 薄风扇之后连 MPN 都还没定。
它只影响报出去的 RPM 数值,**不影响停转判据**(那条只看「有没有脉冲」)。 */
#define T_THERMAL_TACH_PPR 2
/* ---- 闭环阈值 ------------------------------------------------------------
⚠️ 下面四个**全部是「待上板实测后填」**,不是算出来的:
R2 把测温点从「进风侧空气」挪到了「两排手机之间的排母走廊」,设计文档明说
「风扇闭环(§22.6)的阈值要按新位置重新定 —— 这是挪件的代价,不是 bug」,
而新位置的温升曲线只有上板才有。这里给的是**形状**(分段线性 + 迟滞),
数值先按常识填、单列在这里,上板实测之后只改这四行。 */
/* 低于它就按下限转(安静优先)。 */
#define T_THERMAL_TEMP_LO_MC 40000 /* 40.000 °C —— 待实测 */
/* 高于它满速。 */
#define T_THERMAL_TEMP_HI_MC 65000 /* 65.000 °C —— 待实测 */
/* 过温降载门限(FR-13「超阈值先降流再关路」)。本模块只**报**,不动电源 ——
真降载要按整板功率优先级关路,那是限功率那一包的事(计划 §5.9)。 */
#define T_THERMAL_DERATE_MC 80000 /* 80.000 °C —— 待实测 */
/* 降载解除门限。⚠️ 必须**低于**上面那个:没有迟滞的现象是温度在门限上抖,
每秒记一条降载事件,把 256 条的 RAM 环形缓冲在 4 分钟内刷光(`evlog.h`),
真正有用的过压/反灌事件被挤掉 —— 而那时日志里看起来「记录得非常完整」。 */
#define T_THERMAL_DERATE_CLEAR_MC 70000 /* 70.000 °C —— 待实测 */
/* 占空比下限(千分比)。风扇侧口径。见文件头「与之配套的是占空比下限」。
⚠️ 待上板实测:低了 TACH 窗口拉得太长(判停转变慢),高了吵。 */
#define T_THERMAL_DUTY_MIN 300
/* ---- TACH 采样窗口 ------------------------------------------------------ */
/* 一个判据窗口要攒够这么多**导通时间**(不是墙钟时间)。见文件头。
3 s:占空比 100 % 时 3 s 判一次,30 % 下限时 10 s 判一次 —— 风扇停了之后
最坏 10 s 才报出来,而机箱的热时间常数是分钟级,够用。 */
#define T_THERMAL_TACH_ON_MS 3000
/* 窗口里少于这么多个边沿 ⇒ 判它停了。
余量巨大是有意的:§22.6 给的最慢转速是 0 % PWM 下的 3300 RPM,
3300 RPM × 2 PPR × 3 s 导通 = **330 个脉冲**,门限 4 是它的 1/80。
这条判据要答的是「有没有在转」,不是「转得够不够快」—— 转速不足是另一回事
(风扇老化/积灰),那要等 §22.6 把额定转速定下来才判得了。 */
#define T_THERMAL_TACH_MIN_EDGES 4
/* 窗口墙钟长度的**上界**。窗口按导通时间结算,所以它的实际长度是
`T_THERMAL_TACH_ON_MS × 1000 / duty`,最长出现在占空比压到下限的时候
(3 s ÷ 30 % = 10 s)。这个 30 s 是允许它拉到多长的红线:
拉过头 = 风扇停了很久才报出来。
⚠️ 这条不是运行期判断,是 `t_thermal.c` 里的**编译期断言**。
把占空比下限改小(比如将来限功率想让风扇让电)会同时拉长这个窗口,
改到 0 的时候停转判据就**静默失效**了 —— 不报错、不告警、再也不报停转。
写成运行期兜底反而更糟:那段代码在下限 >0 时永远走不到,测不了也就不可信。 */
#define T_THERMAL_TACH_MAX_MS 30000
/* `FAN_EN` 拉高之后的启动宽限:这段时间内不判停转,窗口一直重置。
设计文档 §22.6「使能后给启动宽限,关电后屏蔽停转告警」。
没有这个窗口的现象是**每次上电必报一条风扇停转**,然后大家就开始无视这个事件。
⚠️ 数值**待实测**(薄风扇的起转时间厂家没给),但它有一个下界,不能随便往小写:
**必须长于「播种一圈 + 一个完整判据窗口」**,也就是
`period_ms + T_THERMAL_TACH_ON_MS`(1 s 节拍下 = 4 s),否则第一个窗口在宽限
到期之前根本结算不了,这条宽限就是一段**永远走不到的死代码** ——
写成 3 s 时把整个宽限判断删掉,测试一条都不会红(本包实测过)。取 5 s。 */
#define T_THERMAL_SPINUP_MS 5000
/* ---- 外部喂进来的两样东西 ------------------------------------------------ */
#define T_THERMAL_NTC_COUNT 2 /* IO4 = RT601、IO5 = RT602 */
#define T_THERMAL_FAN_COUNT 2 /* 0 = 进风 J26(TACH → IO21)、1 = 排风 J27(IO14) */
typedef enum {
THERM_FAN_IN = 0, /* J26 进风,TACH 走 `BOARD_PIN_FAN_TACH_IN` */
THERM_FAN_OUT = 1, /* J27 排风,TACH 走 `BOARD_PIN_FAN_TACH_OUT` */
} therm_fan_t;
/* 温度取值回调,单位**毫摄氏度**(整数,不引浮点)。
返回 false = 这一刻读不到(ADC 还没起来 / NTC 开路)。
⚠️ 两路**都**读不到时本模块把风扇开到 100 %,不是维持原样 ——
需求 §10.10 对「载板温度读不到」给的处置就是「风扇转到 100 % 并告警,
不要按『读不到温度 = 不热』处理」。 */
typedef bool (*therm_temp_fn)(void *ctx, int idx, int32_t *milli_c);
/* TACH 取值回调:返回**累计**边沿数(自己回绕,uint32 相减是回绕安全的)。
板上这个数来自 PCNT / GPIO 中断,宿主机上来自假风扇。
返回 false = 计数器这一刻读不到(那就跳过这一路本圈的累加,不判停转)。
⚠️ 这里收的是**计数**不是**电平**:接收电路反相(Q9/Q10),
「电平高 = 在转」和「电平低 = 在转」都是错的,而且两种写法都有一半的时间蒙对。 */
typedef bool (*therm_tach_fn)(void *ctx, int idx, uint32_t *edges);
/* ---- 快照 ---------------------------------------------------------------- */
typedef struct {
/* 本窗口的三个累加量。窗口在「导通时间攒满」或「墙钟超时」时结算并清零。 */
uint32_t win_edges; /* 数到的边沿 */
uint32_t win_on_ms; /* **导通**时间:判据用的就是它 */
uint32_t win_ms; /* 墙钟时长:只用来算 RPM 和兜底超时 */
uint32_t last_edges; /* 上一圈读回来的累计值 */
bool have_last; /* 第一圈只播种,不作差(不然第一圈会白捡一大堆边沿) */
uint32_t rpm; /* 上一个**结算完**的窗口算出来的转速 */
bool rpm_valid; /* 还没结算过任何窗口时为 false —— 0 RPM 与「还不知道」不是一回事 */
bool stalled; /* 当前判定 */
uint32_t stalls; /* 判出过几次(只数上升沿,和事件条数一致) */
uint32_t windows; /* 结算过几个窗口 */
} therm_tach_state_t;
typedef struct {
int32_t temp_mc[T_THERMAL_NTC_COUNT];
bool temp_ok[T_THERMAL_NTC_COUNT];
int32_t temp_max_mc; /* 两路里**高的那一个**:一点凉不代表另一点不烫 */
bool temp_valid; /* 至少有一路读到了 */
uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径 */
uint16_t gpio_duty; /* 真写进 hal_pwm 的值(Q6 反相 ⇒ 1000 − duty) */
bool fan_en; /* `FAN_EN` 的命令值 */
uint32_t en_ms; /* 拉高的时刻,启动宽限从这里算 */
bool derate; /* 正在过温降载 */
uint32_t derates; /* 进入过几次(只数上升沿) */
therm_tach_state_t fan[T_THERMAL_FAN_COUNT];
uint32_t cycles; /* 跑过几圈 */
uint32_t last_ms; /* 上一圈的时刻,dt 从这里算 —— 不假设「一圈正好 1 s」 */
uint32_t pwm_errors; /* 写 PWM 失败次数(板上不该发生,发生了要报出去) */
uint32_t temp_errors; /* 两路温度**全部**读不到的圈数 */
} thermal_snap_t;
/* 把快照清干净 + 起硬件:
① `FAN_EN` 配成输出并**先拉低**、② 配 PWM 并写满速、③ 再把 `FAN_EN` 拉高。
顺序来自设计文档 §22.6「先 FAN_EN=0、配好 PWM 与两个计数器,再使能供电」。
`temp` / `tach` 传 NULL 就是「永远读不到」(温度读不到 ⇒ 满速,见上)。 */
hal_err_t t_thermal_init(thermal_snap_t *s, therm_temp_fn temp, therm_tach_fn tach, void *ctx);
/* 一个周期。周期取 `task_desc(TASK_THERMAL)->period_ms`(1 s,`tasks.c` 里定死),
但本函数**不假设**自己是按时被调的:dt 一律用 `hal_time_elapsed_ms()` 实测,
所以任务被挤晚了只会让窗口跟着变长,不会把导通时间算错。 */
hal_err_t t_thermal_step(thermal_snap_t *s);
/* 温度 → 风扇侧占空比。分段线性,纯函数(不碰硬件、不记事件),单独可测。
`valid == false` ⇒ 满速。单调**不降**是这条曲线唯一不能破的性质。 */
uint16_t t_thermal_duty_for(int32_t milli_c, bool valid);
#ifdef __cplusplus
}
#endif
#endif /* T_THERMAL_H */