From 9fb5bba3670e971f4d36ad17649adc87bd398730 Mon Sep 17 00:00:00 2001 From: zhou yang Date: Tue, 29 Sep 2026 08:16:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E4=BB=B6=200.1.5~0.1.13-c5=EF=BC=9A?= =?UTF-8?q?=E5=88=B7=E6=9C=BA=E6=A8=A1=E5=BC=8F=E3=80=81=E5=A4=9A=E7=BB=84?= =?UTF-8?q?=20WiFi=E3=80=81=E7=83=AD=E6=8F=92=E6=8B=94=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E3=80=81=E6=80=BB=E7=BA=BF=E9=94=81=E8=AF=AF=E5=88=A4?= =?UTF-8?q?=E3=80=81=E7=A1=AC=E9=87=8D=E5=90=AF=E6=96=AD=20VBUS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0.1.5/0.1.6:刷机模式(flash_mode,最多 2 路,60 分钟可加钟)——窗口内顶高按 4450 mV 绝对值判、不断 VBUS - 0.1.7/0.1.8:反灌断 VBUS 阈值放宽到 -300 mA(adb reboot bootloader 过渡实测 -208 mA) - 0.1.9:WiFi 最多存 4 组,连不上轮换;NVS v1 自动迁移并双写(OTA 回滚仍能上网) - 0.1.10:热插拔进来的刀片重新初始化 PCA9555 + INA226(否则 VBUS 常开、电流恒读 0) - 0.1.11:i2cbus 嵌套检测改看本任务持锁(原全局标志让 key 随机回 busy、vbus 随机回 io) - 0.1.12:硬重启先断 VBUS,开机校验通过 60 s 后再接回(否则假重启 / 误判没开机 / 进关机充电) - 0.1.13:这一路被安全判据断电时一并退出刷机模式(事件 21 arg 254) 26 ctest 全绿;新增用例均先在旧代码上确认变红。板 B 已 OTA 到 0.1.13-c5 并实测。 Co-Authored-By: Claude Opus 5.5 (1M context) --- firmware/HANDOFF.md | 90 +- firmware/host/CMakeLists.txt | 1 + firmware/main/CMakeLists.txt | 1 + firmware/main/app_main.c | 1 + firmware/main/chgmode.c | 110 ++ firmware/main/chgmode.h | 87 + firmware/main/cmd.c | 4 +- firmware/main/cmd.h | 5 + firmware/main/cmd_exec.c | 107 + firmware/main/cmd_net.c | 48 +- firmware/main/cmd_priv.h | 1 + firmware/main/cmd_report.c | 8 +- firmware/main/evlog.c | 1165 +++++------ firmware/main/evlog.h | 388 ++-- firmware/main/i2cbus.c | 304 +-- firmware/main/mlink_esp.c | 148 +- firmware/main/netcfg.c | 64 +- firmware/main/netcfg.h | 31 +- firmware/main/ota_esp.c | 2 +- firmware/main/phonerail.h | 2 +- firmware/main/slot.c | 8 + firmware/main/slot.h | 4 + firmware/main/t_actuator.c | 80 +- firmware/main/t_actuator.h | 13 + firmware/main/t_safety.c | 955 ++++----- firmware/main/t_safety.h | 493 ++--- firmware/test/test_cmd.c | 148 +- firmware/test/test_evlog.c | 3 +- firmware/test/test_i2cbus.c | 31 + firmware/test/test_net.c | 110 +- firmware/test/test_t_actuator.c | 110 ++ firmware/test/test_t_safety.c | 1415 ++++++------- firmware/test/test_t_safety_rules.c | 2844 ++++++++++++++------------- 五路直供电源板设计.md | 10 +- 需求文档.md | 4 +- 35 files changed, 5120 insertions(+), 3675 deletions(-) create mode 100644 firmware/main/chgmode.c create mode 100644 firmware/main/chgmode.h diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index 910150a..c87c006 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -1,5 +1,93 @@ # 交接 -更新:2026-09-28 固件 **0.1.4-c5**(整片刀片不应答 ⇒ 关路);板 B 经 OTA 推上 +更新:2026-09-28 23:20 固件 **0.1.13-c5**(断电自动退出刷机模式);板 B 经 OTA 推上;rack 已部署 + +## 0.1.13:这一路断了电 ⇒ 刷机模式一并退出(2026-09-28) +- 起因:第 2 槽刀片带电拔走后,刷机模式还挂了约 48 分钟、占着「最多 2 路」的名额。`check_present` / SR-5 / 刀片不应答 / + mux 失联全断都是 t_safety 直接关 eFuse,不经过 `power` 命令,而只有 `power off` 会顺带退刷机模式。 +- 修:`cmd_chgmode_tick()`(执行器任务,chgmode 的唯一写者)每拍看一遍:刷机模式开着 + eFuse 关着 + 执行器不忙 ⇒ + 退出,记事件 21 arg = **254 `CHG_EV_DROPPED`**。执行器忙(硬重启 HR_OFF 那几秒 eFuse 本来就关)不算。 +- ⚠️ 254 带 0x80 位:rack `api.py` 事件命名里「断电自动退出」必须排在「加钟」前面判(已改 + 测试)。 +- 测试:`test_cmd` +1(硬重启中不退 / 关电后下一拍退、只记一次、名额让出);反向验证:去掉判断 ⇒ 红。26 ctest 全绿;rack 48 例全绿并已部署。 +- ⏳ 真板未专门验证(需要带电拔一次开着刷机模式的刀片)。 + +## 0.1.11 / 0.1.12:命令随机 busy / io;硬重启带着 VBUS(2026-09-28 刷机时发现) +- **总线锁误判(0.1.11)**:`i2cbus_xact()` 拿锁前查全局 `s_in_xact` 防嵌套 —— 别的任务在事务里时它也是 true。 + 执行器撞上巡检那 ~2.3 ms(每 20 ms,≈12%)就被判成「嵌套」立刻回 STATE ⇒ `key` 随机回 **busy**、`vbus` 随机回 **io** + (反过来巡检撞上执行器也会白跑一圈)。改成 `hal_lock_held(HAL_LOCK_I2C)`(**本任务**持锁才算嵌套)。 + 真板:第 3 路连开关 VBUS 40 次,0 失败。测试 `test_i2cbus` +1(另一任务持锁 ⇒ TIMEOUT 而不是 STATE;旧代码红)。 +- **硬重启先断 VBUS(0.1.11)+ 延后接回(0.1.12)**:VBUS 通着做 `power_cycle` 的三个后果现场全见过 —— + 手机靠 USB 撑着没断电(假重启);直供电流 ≈0 ⇒ 开机校验判「没开起来」再按两下;断电那刻带着 USB ⇒ 进关机充电,一拔 USB 就关机。 + 现在 `t_actuator_hard_reset()` 先断 VBUS,**开机校验通过后再等 `ACT_HR_VBUS_BACK_MS` = 60 s** 才接回 + (0.1.11 是校验一过就接回 ⇒ 手机还在 bootloader、关充电补丁没生效 ⇒ 13 s 顶高 4394 mV、SW-8 断 VBUS)。 + 等待期间执行器空闲;`vbus` / `power` 命令、再次硬重启、EDL、abort、这一路被关电 ⇒ 作废。没开起来 / 出错 ⇒ 不接回。 + 真板:第 3 路 VBUS 通 → 硬重启:hr-off 时 vb=false、14 s 校验通过、72 s 接回,无顶高事件。 + 测试 `test_t_actuator` +3(先断后接回、没开机不接回 / abort 不接回 / 本来没通不打开、等待期间被取消的三种)。26 ctest 全绿。 +- ~~没修的:第 2 槽刀片被拔走时刷机模式不退出~~ ⇒ 0.1.13 已修。 + +## 0.1.10:热插拔进来的刀片重新初始化(2026-09-28) +- 现象(板 B,新主板 + 没刷补丁):刀片**带电插**进槽 ⇒ C67(VBUS)= 5 V,换 U5 也一样;`vb:false`。 + 没刷补丁的手机被充到 4.4 V ⇒ SR-5 过压关路(反复 7 次)。**插好再给底板上电就正常**。 +- 根因:`slot_init()` / `ina226_init()` 只在开机 bringup 做。热插拔时 PCA9555 上电复位不干净(SDA/SCL 经上拉寄生供电), + `VBUS_EN` 被配成输出高、影子却是 off ⇒ SW-8 不武装;INA226 CALIB = 0 ⇒ **电流恒读 0**(第 2 路那段数据正是整 0)。 +- 修:`t_safety` 认 `PRSNT#` 下降沿,在位连续 `T_SAFETY_INSERT_N` = 5 圈(100 ms)后在本路锁内 `slot_init_locked()` + `ina226_init()`; + 失败隔 5 圈重试;拔走作废;开机在位的不重做。status `safety.inits` = 成功次数。 +- 测试 +2(`test_t_safety`:乱寄存器插入 ⇒ 第 5 圈恢复、VBUS 断、键全松、CALIB 写回、只做一次;NAK 重试 + 插一半拔走不碰空槽); + 反向验证:不置 need_init ⇒ 两例红。26 ctest 全绿。 +- ✅ 真板验证(用户现场):带电插刀片后 C67 = 0 V、`safety.inits` 0 → 1。 + +## 0.1.9:多组 WiFi(2026-09-28,用户要求) +- 起因:板 B 只存一组 WiFi(CANBOX-5G),热点一关就离线;而改配置只收 USB。 +- `netcfg_t.wifi[NETCFG_WIFI_N=4]`。`wifi` 命令 = **同名改口令、否则追加**(回 `"n":组数`;满了 `state`); + `{"cmd":"wifi","ssid":"X","del":true}` 删(没有这组回 `range`)。仍然只收 USB。 +- `net` 回应:`ssid` / `pass_set` = 正在连的那一组(`net_status_t.wifi_idx`),新增 `"ssids":[…]` 整张表。 +- 轮换(mlink_esp.c):没连上 ⇒ 换下一组;连上过又掉 ⇒ 先重试同一组;退避按「轮完一圈」算(`attempt / n`)。 + 改配置时正连着的那一组没被删、口令没变、broker 没变 ⇒ **WiFi / MQTT 都不断**(加备用热点不掉线)。 +- NVS:新键 `net2`(ver 2);读不到就读旧键 `net`(ver 1)迁移。**写时两个键都写**,`net` 放表里第一组 —— + OTA 回滚到 ≤0.1.8 仍连得上网。 +- 测试 +3(`test_net` 10 → 13 例:加 / 改同名、删 + 满表、NVS 空位压实);26 ctest 全绿。 +- 真板:烧录后 `ssids:["CANBOX-5G"]`(迁移成功)→ 加 `AAAAAABBBCCC`(用户给的名字,周围扫不到)→ + 加 `AAABBBCCC`(本机扫到的实际热点,同口令)⇒ 连上 10.5.5.58、MQTT up、服务器显示在线。 +- ⚠️ USB esptool 烧录 = 非计划重启 ⇒ 五路全断(第 1 路手机掉电)。第一次连接报 `ClearCommError`,COM5 重新枚举后重试即成。 + +## 0.1.7 / 0.1.8:反灌断 VBUS 阈值 −50 → −150 → −300 mA(2026-09-28,用户要求) +- 起因:刷机时重启过渡的瞬间反灌误断 VBUS,fastboot 断连。0.1.6 下实测 −59 mA;0.1.7(−150 mA)下 `adb reboot bootloader` 实测 **−208.5 mA** 仍断。 +- `T_SAFETY_REV_UA` = −300000;判据本身(一圈就断、不做确认窗口、不碰 eFuse、刷机模式不豁免)不变。 +- 测试:注入 −60 → −310 mA,「小电流不算反灌」−40 → −250 mA;26 ctest 全绿。0.1.8 下 `adb reboot bootloader` 过渡没再断 USB。 + +## 0.1.6:刷机模式默认 30 → 60 分钟 + 加钟(2026-09-28,用户要求) +- `CHG_MODE_DEFAULT_MIN` = 60。 +- 加钟:`{"cmd":"flash_mode","rail":1,"add":30}`(1~120,与 on / min 互斥)—— `chgmode_extend()` 只给**开着的**窗口加 + (没开 / 宽限期里回 `state`:那是重新开,不是加钟),加完剩余截断到 120 分钟;回 `"left":秒`。 + 事件 21 arg = `CHG_EV_EXTEND`(0x80)| 分钟。rack:「更多 → 刷机模式加钟 30 分钟」(只在开着时出现)。 +- 测试 +2(`test_cmd` 加钟全路径、`test_t_safety_rules` 截断与宽限期拒绝),rack 48 例。真板:没开加钟被拒 / 开 3600 / 加钟后 5399 / 关。 + +## 0.1.5:刷机模式(2026-09-28,用户定「做,最多同时 2 路」) +- **起因**:fastboot / bootloader 阶段内核补丁还没起来,手机充电器把直供节点顶到浮充 ~4.4 V(板 B 实测 4395 mV), + SW-8 立刻断 VBUS ⇒ fastboot 刷到一半断连。历史 5 次顶高全是只断 VBUS、**0 次**关路。 +- **依据**:每路 eFuse TPS25940A 自带反向阻断(V(IN)−V(OUT) < −9.3 mV 断开),顶高时能量进不了 Buck / 12V 母线; + 板 B 五次顶高实测电流 0 ~ −0.5 mA。 +- **新模块 `chgmode.{h,c}`**:每路一个截止时刻。**只有执行器任务写**(命令 + `cmd_chgmode_tick()` 到期清理), + 巡检只读;tick 必须清标志,否则 32 位毫秒时钟 ~24.8 天后过期窗口会「复活」。 +- **判据(`t_safety.c judge_locked`)**:窗口内「顶高断 VBUS」与 SR-5 过压都按绝对值 `CHG_MODE_MAX_MV` = 4450 判; + 窗口后 1 s 宽限期里 SW-8 回到按设定值(立刻断 VBUS),SR-5 仍按绝对值(节点往回落的几 ms 不许误关路)。 + 反灌(< −50 mA)与欠压判据**不变**。 +- **命令 14 `flash_mode`**:`{"rail":1,"on":true,"min":60}`(默认 60、1~120 分钟;0.1.6 由 30 改 60),回 `"left":秒`; + **最多同时 2 路**(第 3 路回 `state`);关着电不许开(`state`);`power` 关电时一并退出。status 每路多 `"chg":秒`。 +- **事件 21 `LOG_EV_CHG_MODE`**:arg = 分钟(开)/ 0(手动关)/ 255(到期)。 +- rack:白名单 + 事件名 + 单板页「更多 → 刷机模式(1 小时)」(带确认),状态标签显示剩余分钟;模拟板同步。 +- 测试:`test_t_safety_rules` +10(绝对上限、反灌 / 欠压不变、按路、到期断 VBUS 不关路、宽限期后恢复、2 路上限、时钟回绕), + `test_cmd` +3;26 ctest 全绿;rack 46 例。反向验证:去掉窗口内豁免 → 4 例红;tick 不清标志 → 回绕用例红。 +- ✅ **台架实测(2026-09-28,板 B 第 1 路,0.1.6-c5)**,原始数据 `10.5.5.13:/root/phonerail-src/bench-flashmode-20260928.tsv`: + · 开模式 + VBUS 通 → `adb reboot bootloader`,6 s 进 fastboot,**fastboot 下确实在充电**:节点 3864~4395 mV 锯齿 + (充到浮充即停、手机耗下来再充,周期 3~4 s),**VBUS 全程没断**、fastboot 一直在线(`getvar` 正常); + · 该路电流 −1.0 ~ +1.5 mA(eFuse 反向阻断干净),12V 母线 12.029~12.031 V 不动,输入电流 160 → 230 mA(+0.8 W,5V 负担很小); + · **FLT 在反向阻断期间一直为 true**(与 RCB 同进同退,节点跌到 3864 仍为 true = 要 +100 mV 才重新导通), + 固件对 FLT 只上报不动作 ⇒ 无事件。是 TPS25940A 的 RCB 指示,不是故障。rack 已改:chg>0 时这一路显示蓝色「刷机模式 · 充电中」、FLT 标签变中性「反向阻断」(模式外 FLT 仍标红); + · 对照:充电中关模式 → 当圈 PUSHED_UP 断 VBUS(读数 4034 mV),**不关路**(宽限期),Buck 立刻接手 3917 mV / 240 mA;FLT 同时消失; + · 重开模式 + VBUS → 2 s 回 fastboot。`fastboot getvar unlocked: yes`(BL 确已解锁); + · ⚠️ **`fastboot reboot` 回 MIUI 的开机过程中出现 −59 mA 反灌 ⇒ SW-8 断 VBUS**(事件 290)。机理:开机大负载时 Buck 在连续导通, + 节点只比设定高几 mV、没到 eFuse 的 −9.3 mV 阻断门限,充电器灌进来的电被 Buck 反向吸收。12V 母线没动(12.029~12.031)。 + **反灌判据保留是对的**;实际影响只是「重启过渡时 USB 可能被断,进系统后再开 VBUS」。 ## 0.1.4:整片刀片不应答 ⇒ 关这一路(2026-09-28,用户拍板) - `t_safety` 每路锁内:eFuse 开着、本路 INA226 **和** PCA9555 同时 NAK,连续 `T_SAFETY_BLADE_MUTE_N` = 10 圈(200 ms) diff --git a/firmware/host/CMakeLists.txt b/firmware/host/CMakeLists.txt index 942794b..6054d29 100644 --- a/firmware/host/CMakeLists.txt +++ b/firmware/host/CMakeLists.txt @@ -28,6 +28,7 @@ set(PHONERAIL_CORE_SRCS ${FW_ROOT}/main/slot.c ${FW_ROOT}/main/sched.c ${FW_ROOT}/main/tasks.c + ${FW_ROOT}/main/chgmode.c ${FW_ROOT}/main/t_safety.c ${FW_ROOT}/main/t_actuator.c ${FW_ROOT}/main/t_thermal.c diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 1d4f092..a764136 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -12,6 +12,7 @@ idf_component_register( "slot.c" "sched.c" "tasks.c" + "chgmode.c" "t_safety.c" "t_actuator.c" "t_thermal.c" diff --git a/firmware/main/app_main.c b/firmware/main/app_main.c index c8b776a..bee2bda 100644 --- a/firmware/main/app_main.c +++ b/firmware/main/app_main.c @@ -230,6 +230,7 @@ static void task_actuator(void *arg) static char reply[CMD_OUT_MAX]; /* 静态:1.5 KB 放栈上会把 4 KB 的栈吃掉一大半 */ cmd_msg_t m; for (;;) { + cmd_chgmode_tick(hal_time_now_ms()); /* 刷机模式到期清理 + 记事件(chgmode.h:只在本任务写) */ const TickType_t t = pdMS_TO_TICKS(t_actuator_step()); /* ⚠️ `CONFIG_FREERTOS_HZ=100` ⇒ 一个 tick 是 10 ms,`pdMS_TO_TICKS(5)` 是 **0**,而「等 0 tick」只是查一眼就回来 —— 那会让这个中优先级任务 diff --git a/firmware/main/chgmode.c b/firmware/main/chgmode.c new file mode 100644 index 0000000..9931a6b --- /dev/null +++ b/firmware/main/chgmode.c @@ -0,0 +1,110 @@ +/* 刷机模式的窗口表。契约与线程约定在 chgmode.h。 */ +#include "chgmode.h" + +#include "phonerail.h" + +typedef struct { + volatile uint32_t until; /* 窗口截止时刻(ms,回绕安全比较) */ + volatile uint8_t set; /* 1 = 窗口或宽限期还没被 tick 清掉 */ + uint8_t by_cmd; /* 1 = 命令关掉的(已记过事件,tick 不再报「到期」);只有执行器任务读写 */ +} chg_t; + +static chg_t s_chg[PHONERAIL_RAIL_COUNT]; + +static bool rail_ok(int rail) { return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT; } + +/* `a` 在 `b` 之前(回绕安全)。 */ +static bool before(uint32_t a, uint32_t b) { return (int32_t)(a - b) < 0; } + +bool chgmode_active(int rail, uint32_t now) +{ + return rail_ok(rail) && s_chg[rail - 1].set && before(now, s_chg[rail - 1].until); +} + +bool chgmode_relaxed(int rail, uint32_t now) +{ + return rail_ok(rail) && s_chg[rail - 1].set && + before(now, s_chg[rail - 1].until + CHG_MODE_GRACE_MS); +} + +uint32_t chgmode_left_s(int rail, uint32_t now) +{ + if (!chgmode_active(rail, now)) { + return 0; + } + return (s_chg[rail - 1].until - now + 999u) / 1000u; +} + +int chgmode_count(uint32_t now) +{ + int n = 0; + for (int r = 1; r <= PHONERAIL_RAIL_COUNT; r++) { + n += chgmode_active(r, now) ? 1 : 0; + } + return n; +} + +hal_err_t chgmode_set(int rail, uint32_t minutes, uint32_t now) +{ + if (!rail_ok(rail) || minutes > CHG_MODE_MAX_MIN) { + return HAL_ERR_ARG; + } + chg_t *c = &s_chg[rail - 1]; + if (minutes == 0) { + if (chgmode_active(rail, now)) { + c->until = now; /* 立刻结束窗口,宽限期从现在算 */ + c->by_cmd = 1; + } + return HAL_OK; + } + if (!chgmode_active(rail, now) && chgmode_count(now) >= CHG_MODE_MAX_RAILS) { + return HAL_ERR_STATE; + } + /* 先写时刻、后置标志:巡检任务读到 set=1 时 until 一定是新的。 */ + c->until = now + minutes * 60000u; + c->by_cmd = 0; + c->set = 1; + return HAL_OK; +} + +hal_err_t chgmode_extend(int rail, uint32_t minutes, uint32_t now) +{ + if (!rail_ok(rail) || minutes < 1 || minutes > CHG_MODE_MAX_MIN) { + return HAL_ERR_ARG; + } + if (!chgmode_active(rail, now)) { + return HAL_ERR_STATE; + } + chg_t *c = &s_chg[rail - 1]; + const uint32_t cap = CHG_MODE_MAX_MIN * 60000u; + uint32_t left = c->until - now + minutes * 60000u; /* 窗口内 until − now 为正 */ + if (left > cap) { + left = cap; + } + c->until = now + left; /* set 已是 1;单字写,巡检看到的要么旧要么新 */ + return HAL_OK; +} + +uint32_t chgmode_tick(uint32_t now) +{ + uint32_t ended = 0; + for (int r = 1; r <= PHONERAIL_RAIL_COUNT; r++) { + chg_t *c = &s_chg[r - 1]; + if (c->set && !before(now, c->until + CHG_MODE_GRACE_MS)) { + c->set = 0; + if (!c->by_cmd) { + ended |= 1u << (r - 1); + } + } + } + return ended; +} + +void chgmode_reset(void) +{ + for (int r = 0; r < PHONERAIL_RAIL_COUNT; r++) { + s_chg[r].set = 0; + s_chg[r].until = 0; + s_chg[r].by_cmd = 0; + } +} diff --git a/firmware/main/chgmode.h b/firmware/main/chgmode.h new file mode 100644 index 0000000..191bb28 --- /dev/null +++ b/firmware/main/chgmode.h @@ -0,0 +1,87 @@ +/* 刷机模式(0.1.5,2026-09-28 用户定):某一路 VBUS 通着时**允许手机充电、不断 USB**。 + + 为什么要有它:fastboot / bootloader 阶段我们的内核补丁还没起来,手机充电器会把电池端 + (我们的直供节点)顶到它的浮充电压 ~4.4 V。平时 SW-8 会立刻断 VBUS(事件 PUSHED_UP), + 刷到一半的 fastboot 就断了。 + + 为什么放得开: + · 每路 eFuse(TPS25940A)自带**反向阻断**(V(IN)−V(OUT) < −9.3 mV 断开)—— + 充电器顶高节点时 eFuse 自己断开,能量进不了 Buck、到不了 12V 母线。 + 2026-09-28 板 B 五次顶高实测电流 0 ~ −0.5 mA,印证了这一点; + · 4.4 V 是小米8 高压电芯 + 手机充电芯片自己的设计点,保护板已旁路。 + + 刷机模式下(这一路、窗口内)t_safety 的判据改成: + | 判据 | 平时 | 刷机模式 | + |---|---|---| + | SW-8 顶高断 VBUS | > 设定 +100 mV | > CHG_MODE_MAX_MV(绝对值) | + | SR-5 过压关路 | > 设定 +100 mV 持续 20 ms | > CHG_MODE_MAX_MV 持续 20 ms | + | SW-8 反灌断 VBUS | I < −300 mA | **不变**(eFuse 之外的第二道) | + | SR-5 欠压关路 | < 设定 −250 mV | **不变** | + 窗口结束后再留 CHG_MODE_GRACE_MS 的「过压仍按绝对值判」:窗口一过 SW-8 立刻断 VBUS, + 节点从 4.4 V 往回落要几毫秒,不能被 SR-5 当成过压把手机关掉。 + + **最多同时 CHG_MODE_MAX_RAILS 路**:下行 VBUS 由板上一片 3 A 的 5V(U30)供, + 一台充电 0.5 A(SDP)~1.5 A(CDP),五台一起会把 5V 拉垮、全部 USB 掉线。 + + ---- 线程 ---------------------------------------------------------------- + **只有执行器任务写**(`chgmode_set()` 由命令处理器调,`chgmode_tick()` 在执行器循环里调, + 两者同一个任务),巡检任务(t_safety)**只读**。每路状态是一个 32 位截止时刻 + 一个字节标志, + 单字读写本身原子;「先写时刻、后置标志」保证巡检不会读到半截。 + 为什么要 tick 清标志:时钟是 32 位毫秒,过期的窗口放着不管,~24.8 天后 + `(int32_t)(until - now)` 会翻回正数,窗口「复活」。 */ +#ifndef CHGMODE_H +#define CHGMODE_H + +#include +#include + +#include "hal_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define CHG_MODE_MAX_RAILS 2 +#define CHG_MODE_DEFAULT_MIN 60 /* 2026-09-28 用户改:30 → 60 */ +#define CHG_MODE_MAX_MIN 120 +#define CHG_MODE_MAX_MV 4450 /* 手机充电器浮充 ~4.4 V(实测 4395)+ 余量;超过 = 充电器异常 */ +#define CHG_MODE_GRACE_MS 1000 + +/* LOG_EV_CHG_MODE 的 arg(开的时候是分钟数 1~120)。⚠️ 进 flash,冻结。 + 加钟:CHG_EV_EXTEND | 加的分钟数(1~120 ⇒ 129~248)。 */ +#define CHG_EV_OFF 0u +#define CHG_EV_EXTEND 0x80u +#define CHG_EV_EXPIRED 255u +#define CHG_EV_DROPPED 254u /* 0.1.13:这一路断了电(刀片拔走 / 安全判据关路),自动退出 */ + +/* 开 / 关。minutes = 0 关(进入宽限期),1~CHG_MODE_MAX_MIN 开(覆盖原窗口)。 + HAL_ERR_ARG —— 路号 / 分钟越界; + HAL_ERR_STATE —— 要开第 CHG_MODE_MAX_RAILS+1 路(本路已开着的续期不算新增)。 */ +hal_err_t chgmode_set(int rail, uint32_t minutes, uint32_t now); + +/* 加钟(0.1.6):在**正开着**的窗口上再加 minutes(1~CHG_MODE_MAX_MIN)。 + 加完剩余时间超过 CHG_MODE_MAX_MIN 的按它截断(「最长 120 分钟」是剩余时间的上限,不是一次窗口的)。 + HAL_ERR_ARG —— 路号 / 分钟越界; + HAL_ERR_STATE —— 这一路没开着(过期了 / 宽限期里也算没开:要重新开,不是加钟)。 */ +hal_err_t chgmode_extend(int rail, uint32_t minutes, uint32_t now); + +/* 过期清理(执行器循环里调,每拍一次即可)。返回本次**自然到期**的路的位图(bit0 = CH1), + 调用方据此记事件;被命令关掉的不在其中(关的那一刻命令层已经记过)。宽限期结束才清标志。 */ +uint32_t chgmode_tick(uint32_t now); + +/* 窗口内:放开「顶高断 VBUS」。 */ +bool chgmode_active(int rail, uint32_t now); +/* 窗口内或宽限期内:过压按 CHG_MODE_MAX_MV 绝对值判。 */ +bool chgmode_relaxed(int rail, uint32_t now); +/* 剩余秒数(不在窗口内为 0)。status 上报用。 */ +uint32_t chgmode_left_s(int rail, uint32_t now); +/* 窗口内的路数。 */ +int chgmode_count(uint32_t now); + +void chgmode_reset(void); /* 全关(上电 / 宿主机测试) */ + +#ifdef __cplusplus +} +#endif + +#endif /* CHGMODE_H */ diff --git a/firmware/main/cmd.c b/firmware/main/cmd.c index d62ab14..103326c 100644 --- a/firmware/main/cmd.c +++ b/firmware/main/cmd.c @@ -13,7 +13,7 @@ static const char *const k_names[CMD_COUNT] = { [CMD_POWER_CYCLE] = "power_cycle", [CMD_KEY] = "key", [CMD_EDL] = "edl", [CMD_VBUS] = "vbus", [CMD_FAN] = "fan", [CMD_EVENTS] = "events", [CMD_POWER] = "power", [CMD_WIFI] = "wifi", [CMD_MQTT] = "mqtt", - [CMD_NET] = "net", [CMD_OTA] = "ota", + [CMD_NET] = "net", [CMD_OTA] = "ota", [CMD_FLASH_MODE] = "flash_mode", }; static const char *const k_errs[CMD_ERR_COUNT] = { @@ -105,7 +105,7 @@ static const handler_t k_handlers[CMD_COUNT] = { [CMD_POWER_CYCLE] = do_power_cycle, [CMD_KEY] = do_key, [CMD_EDL] = do_edl, [CMD_VBUS] = do_vbus, [CMD_FAN] = do_fan, [CMD_EVENTS] = do_events, [CMD_POWER] = do_power, [CMD_WIFI] = do_wifi, [CMD_MQTT] = do_mqtt, - [CMD_NET] = do_net, [CMD_OTA] = do_ota, + [CMD_NET] = do_net, [CMD_OTA] = do_ota, [CMD_FLASH_MODE] = do_flash_mode, }; static cmd_id_t lookup(const char *name) diff --git a/firmware/main/cmd.h b/firmware/main/cmd.h index c7dd65b..faa7d94 100644 --- a/firmware/main/cmd.h +++ b/firmware/main/cmd.h @@ -59,6 +59,7 @@ typedef enum { CMD_MQTT = 11, /* A14/C1:写 broker 地址 / 账号 / 遥测周期(只收 USB) */ CMD_NET = 12, /* A14:网络状态(只读,不回显密码) */ CMD_OTA = 13, /* C2:从 URL 下载固件、校验 sha256、切启动分区(ota.h) */ + CMD_FLASH_MODE = 14, /* 0.1.5:刷机模式——VBUS 通着时允许手机充电、不断 USB(chgmode.h) */ CMD_COUNT, } cmd_id_t; @@ -138,6 +139,10 @@ typedef struct { } cmd_stats_t; void cmd_stats(cmd_stats_t *st); +/* 刷机模式到期清理 + 记事件(LOG_EV_CHG_MODE,arg = CHG_EV_EXPIRED)。**只许在执行器任务里调** + (chgmode.h:窗口表只有这个任务写),app_main 每转一圈调一次。 */ +void cmd_chgmode_tick(uint32_t now); + #ifdef __cplusplus } #endif diff --git a/firmware/main/cmd_exec.c b/firmware/main/cmd_exec.c index 06e21f6..5cf3e7f 100644 --- a/firmware/main/cmd_exec.c +++ b/firmware/main/cmd_exec.c @@ -5,8 +5,10 @@ 判重、名字表、回应拼装在 cmd.c;status / events 的正文在 cmd_report.c。 */ #include +#include "chgmode.h" #include "cmd_priv.h" #include "evlog.h" +#include "hal_time.h" #include "i2cbus.h" #include "rail.h" #include "rail_cfg.h" @@ -209,6 +211,7 @@ cmd_err_t do_vbus(ctx_t *c) if (on && !rail_efuse_is_on(c->rail)) { return reject(c, CMD_ERR_STATE, 1); } + t_actuator_cancel_vbus_back(c->rail); /* 人动了 VBUS:硬重启排的「过会儿接回」作废 */ return from_bus(slot_vbus_enable(c->rail, on)); } @@ -234,6 +237,7 @@ cmd_err_t do_power(ctx_t *c) if (t_actuator_busy(c->rail)) { return CMD_ERR_BUSY; /* 硬重启走到一半再开关一次是自作主张(t_actuator_abort 同理) */ } + t_actuator_cancel_vbus_back(c->rail); /* 人动了电:硬重启排的「过会儿接回 VBUS」作废 */ if (on) { const hal_err_t ee = rail_efuse_enable(c->rail, true); if (ee == HAL_OK) { @@ -248,10 +252,113 @@ cmd_err_t do_power(ctx_t *c) const hal_err_t ee = rail_efuse_enable(c->rail, false); if (ee == HAL_OK) { note_intent(c->rail, false); /* 直供确实断了:下次开机也别开它 */ + /* 这一路停了,刷机模式也一并退出(不占 CHG_MODE_MAX_RAILS 的名额)。 */ + if (chgmode_active(c->rail, hal_time_now_ms())) { + (void)chgmode_set(c->rail, 0, hal_time_now_ms()); + evlog_put((uint8_t)c->rail, LOG_EV_CHG_MODE, CHG_EV_OFF, now_mv(c->rail), now_ua(c->rail)); + } } return from_bus(ee != HAL_OK ? ee : ev); } +/* 0.1.5 刷机模式(chgmode.h):`{"cmd":"flash_mode","rail":1,"on":true,"min":60}`。 + min 省略 = CHG_MODE_DEFAULT_MIN;关不带 min。回应带 `"left":秒`。 + 0.1.6 加钟:`{"cmd":"flash_mode","rail":1,"add":30}`(与 on / min 互斥)—— 只给开着的窗口加, + 剩余最多 CHG_MODE_MAX_MIN 分钟(超出截断);没开着回 state。 + ⚠️ 只改判据,**不动 VBUS**:开 VBUS 仍用 `vbus` 命令(顺序无所谓:先开模式再开 VBUS, + 或 VBUS 被 SW-8 断了之后开模式再重开 VBUS)。 */ +cmd_err_t do_flash_mode(ctx_t *c) +{ + cmd_err_t e = need_rail(c); + if (e != CMD_OK) { + return e; + } + /* 加钟(0.1.6):`{"cmd":"flash_mode","rail":1,"add":30}`,与 on 二选一。 */ + int64_t add = 0; + const int ga = cj_get_int(c->o, "add", &add); + if (ga < 0) { + return CMD_ERR_ARG; + } + if (ga == 1) { + bool on_probe = false; + int64_t min_probe = 0; + if (cj_get_bool(c->o, "on", &on_probe) != 0 || cj_get_int(c->o, "min", &min_probe) != 0) { + return CMD_ERR_ARG; /* add 不和 on / min 混用 */ + } + if (add < 1 || add > CHG_MODE_MAX_MIN) { + return reject(c, CMD_ERR_RANGE, add); + } + const uint32_t now = hal_time_now_ms(); + const hal_err_t he = chgmode_extend(c->rail, (uint32_t)add, now); + if (he == HAL_ERR_STATE) { + return reject(c, CMD_ERR_STATE, 0); /* 没开着:要重新开,不是加钟 */ + } + if (he != HAL_OK) { + return reject(c, CMD_ERR_RANGE, add); + } + evlog_put((uint8_t)c->rail, LOG_EV_CHG_MODE, (uint8_t)(CHG_EV_EXTEND | (uint32_t)add), + now_mv(c->rail), now_ua(c->rail)); + cj_w(c->w, ",\"left\":%lu", (unsigned long)chgmode_left_s(c->rail, now)); + return CMD_OK; + } + bool on = false; + if (cj_get_bool(c->o, "on", &on) != 1) { + return CMD_ERR_ARG; + } + int64_t min = CHG_MODE_DEFAULT_MIN; + const int g = cj_get_int(c->o, "min", &min); + if (g < 0 || (!on && g == 1)) { + return CMD_ERR_ARG; + } + const uint32_t now = hal_time_now_ms(); + if (!on) { + const bool was = chgmode_active(c->rail, now); + (void)chgmode_set(c->rail, 0, now); + if (was) { + evlog_put((uint8_t)c->rail, LOG_EV_CHG_MODE, CHG_EV_OFF, now_mv(c->rail), now_ua(c->rail)); + } + cj_w(c->w, ",\"left\":0"); + return CMD_OK; + } + if (min < 1 || min > CHG_MODE_MAX_MIN) { + return reject(c, CMD_ERR_RANGE, min); + } + /* 关着电的路开刷机模式没有意义(手机没电),与 vbus 命令同一条规矩。 */ + if (!rail_efuse_is_on(c->rail)) { + return reject(c, CMD_ERR_STATE, 1); + } + const hal_err_t he = chgmode_set(c->rail, (uint32_t)min, now); + if (he == HAL_ERR_STATE) { + return reject(c, CMD_ERR_STATE, chgmode_count(now)); /* 已有 CHG_MODE_MAX_RAILS 路开着 */ + } + if (he != HAL_OK) { + return reject(c, CMD_ERR_RANGE, min); + } + evlog_put((uint8_t)c->rail, LOG_EV_CHG_MODE, (uint8_t)min, now_mv(c->rail), now_ua(c->rail)); + cj_w(c->w, ",\"left\":%lu", (unsigned long)chgmode_left_s(c->rail, now)); + return CMD_OK; +} + +void cmd_chgmode_tick(uint32_t now) +{ + const uint32_t ended = chgmode_tick(now); + for (int r = 1; r <= PHONERAIL_RAIL_COUNT; r++) { + if ((ended & (1u << (r - 1))) != 0) { + evlog_put((uint8_t)r, LOG_EV_CHG_MODE, CHG_EV_EXPIRED, now_mv(r), now_ua(r)); + } + } + /* 0.1.13:这一路已经断了电(刀片被拔走 / SR-5 等判据关路 / mux 失联全断 —— 都是 t_safety 关的 eFuse, + 不经过 `power` 命令)⇒ 刷机模式一并退出。不退的话它一直占着 CHG_MODE_MAX_RAILS 的名额 + (2026-09-28 板 B:第 2 槽刀片拔走后还挂着 48 分钟),插回来上电时还带着放宽的判据。 + 硬重启那几秒 eFuse 本来就关着,不算(执行器忙)。 */ + for (int r = 1; r <= PHONERAIL_RAIL_COUNT; r++) { + if (chgmode_active(r, now) && !rail_efuse_is_on(r) && !t_actuator_busy(r)) { + (void)chgmode_set(r, 0, now); + evlog_put((uint8_t)r, LOG_EV_CHG_MODE, CHG_EV_DROPPED, now_mv(r), now_ua(r)); + } + } +} + cmd_err_t do_fan(ctx_t *c) { int64_t duty = 0; diff --git a/firmware/main/cmd_net.c b/firmware/main/cmd_net.c index 140a4d2..2e2837b 100644 --- a/firmware/main/cmd_net.c +++ b/firmware/main/cmd_net.c @@ -33,14 +33,38 @@ cmd_err_t do_wifi(ctx_t *c) } const char *ssid = NULL; const char *pass = NULL; - if (get_str(c, "ssid", true, &ssid) != CMD_OK || get_str(c, "pass", false, &pass) != CMD_OK) { + bool del = false; + if (get_str(c, "ssid", true, &ssid) != CMD_OK || get_str(c, "pass", false, &pass) != CMD_OK || + cj_get_bool(c->o, "del", &del) < 0) { return CMD_ERR_ARG; } - if (!netcfg_wifi_ok(ssid, pass)) { - c->bad = (int64_t)strlen(pass); /* 最常见的错是口令长度(<8),记长度不记内容 */ - return CMD_ERR_RANGE; + netcfg_t cfg; + netcfg_get(&cfg); + hal_err_t e; + if (del) { + /* 删:`{"cmd":"wifi","ssid":"X","del":true}`。没有这一组 = range(记现有组数)。 */ + if (netcfg_wifi_find(&cfg, ssid) < 0) { + c->bad = netcfg_wifi_count(&cfg); + return CMD_ERR_RANGE; + } + e = netcfg_del_wifi(ssid); + } else { + if (!netcfg_wifi_ok(ssid, pass)) { + c->bad = (int64_t)strlen(pass); /* 最常见的错是口令长度(<8),记长度不记内容 */ + return CMD_ERR_RANGE; + } + if (netcfg_wifi_find(&cfg, ssid) < 0 && netcfg_wifi_count(&cfg) >= NETCFG_WIFI_N) { + c->bad = NETCFG_WIFI_N; /* 满了:先删一组 */ + return CMD_ERR_STATE; + } + e = netcfg_set_wifi(ssid, pass); } - return netcfg_set_wifi(ssid, pass) == HAL_OK ? CMD_OK : CMD_ERR_IO; + if (e != HAL_OK) { + return CMD_ERR_IO; + } + netcfg_get(&cfg); + cj_w(c->w, ",\"n\":%d", netcfg_wifi_count(&cfg)); + return CMD_OK; } cmd_err_t do_mqtt(ctx_t *c) @@ -91,9 +115,19 @@ cmd_err_t do_net(ctx_t *c) cj_w(w, ",\"id\":"); w_str(w, st.id); + /* `ssid` / `pass_set` = 正在连 / 已连上的那一组;`ssids` = 整张表(按轮换顺序)。 */ + const int n = netcfg_wifi_count(&cfg); + const netcfg_wifi_t *cur = (st.wifi_idx < n) ? &cfg.wifi[st.wifi_idx] : &cfg.wifi[0]; cj_w(w, ",\"wifi\":\"%s\",\"ssid\":", net_state_str(st.wifi)); - w_str(w, cfg.ssid); - cj_w(w, ",\"pass_set\":%s,\"ip\":", cfg.pass[0] ? "true" : "false"); + w_str(w, cur->ssid); + cj_w(w, ",\"pass_set\":%s,\"ssids\":[", cur->pass[0] ? "true" : "false"); + for (int i = 0; i < n; i++) { + if (i > 0) { + cj_w(w, ","); + } + w_str(w, cfg.wifi[i].ssid); + } + cj_w(w, "],\"ip\":"); w_str(w, st.ip); cj_w(w, ",\"rssi\":%d,\"wifi_reason\":%u,\"mqtt\":\"%s\",\"uri\":", (int)st.rssi, (unsigned)st.wifi_reason, net_state_str(st.mqtt)); diff --git a/firmware/main/cmd_priv.h b/firmware/main/cmd_priv.h index d565618..eb860c4 100644 --- a/firmware/main/cmd_priv.h +++ b/firmware/main/cmd_priv.h @@ -26,6 +26,7 @@ cmd_err_t do_edl(ctx_t *c); cmd_err_t do_vbus(ctx_t *c); cmd_err_t do_power(ctx_t *c); cmd_err_t do_fan(ctx_t *c); +cmd_err_t do_flash_mode(ctx_t *c); /* cmd_net.c:网络配置(只收 USB)与网络状态。 */ cmd_err_t do_wifi(ctx_t *c); diff --git a/firmware/main/cmd_report.c b/firmware/main/cmd_report.c index e12c021..af18859 100644 --- a/firmware/main/cmd_report.c +++ b/firmware/main/cmd_report.c @@ -7,6 +7,7 @@ #include #include "board_pins.h" +#include "chgmode.h" #include "cmd_priv.h" #include "evlog.h" #include "hal_gpio.h" @@ -69,6 +70,8 @@ static void rail_obj(cj_writer_t *w, int rail, const safety_snap_t *s) cj_w(w, ",\"pg\":null,\"flt\":null"); } } + /* 0.1.5:刷机模式剩余秒数(0 = 没开)。 */ + cj_w(w, ",\"chg\":%lu", (unsigned long)chgmode_left_s(rail, hal_time_now_ms())); cj_w(w, ",\"st\":\"%s\",\"res\":\"%s\"}", t_actuator_state_str(t_actuator_state(rail)), t_actuator_result_str(t_actuator_last_result(rail))); } @@ -131,9 +134,10 @@ void cmd_report_status(cj_writer_t *w, const cmd_env_t *env) thermal_obj(w, env->thermal); } if (s != NULL) { - cj_w(w, ",\"safety\":{\"cycles\":%lu,\"skipped\":%lu,\"i2c_err\":%lu,\"events\":%lu}", + cj_w(w, ",\"safety\":{\"cycles\":%lu,\"skipped\":%lu,\"i2c_err\":%lu,\"events\":%lu,\"inits\":%lu}", (unsigned long)s->cycles, (unsigned long)s->skipped, - (unsigned long)s->i2c_errors, (unsigned long)s->events); + (unsigned long)s->i2c_errors, (unsigned long)s->events, + (unsigned long)s->blade_inits); } ota_obj(w); evlog_stats_t es; diff --git a/firmware/main/evlog.c b/firmware/main/evlog.c index 04244e8..1953be5 100644 --- a/firmware/main/evlog.c +++ b/firmware/main/evlog.c @@ -1,582 +1,583 @@ -/* 事件日志的实现。接口与「为什么这么分层」在 evlog.h,这里只写 flash 上的样子 - 与几处不写下来就一定会被改坏的地方。 - - ---- flash 上的样子(17 个扇区 × 4 KB) ---------------------------------- - - 扇区 n: [ 16 B 扇区头 ][ 记录 0 ][ 记录 1 ] … [ 记录 254 ] - magic/ver/ 每条 16 B,255 条 - recs/epoch/chk - - · **扇区头里的 `epoch` 是全局递增的**(第几个被写的扇区), - 所以 `扇区号 = epoch % 17`,重启时不用记住任何东西:读 17 个头, - epoch 最大的那个就是正在写的扇区。 - · **记录的序号 = `epoch × 255 + 槽号`**。序号因此就是「flash 里第几条」, - 不需要额外存一个计数器,也不会在掉电后倒退。 - · 环形:写满一个扇区就**擦掉下一个**(也就是最老的那个)再写它的头。 - 擦除是 4 KB 粒度的,所以「盖掉最旧的」一次盖 255 条 —— 这正是 §10.7 - 的容量算法里那 4096 条的来源。 - - ---- 为什么每条记录要带一个校验字节 -------------------------------------- - NOR flash 的写可以被掉电打断在任意一个字节上,落进去的是**半条记录**, - 而半条记录在字节层面看跟一条真记录长得一模一样(时间戳合法、路号合法、 - 类型合法)。没有校验的话,重启后的扫描会把它当成有效记录继续往后写, - 而上位机拉到的是一条时间戳来自过去、电压是 0xFFFF 的「事件」。 - 校验字节让扫描停在它前面 —— `test_a_torn_write_does_not_become_a_record()`。 - - ⚠️ 校验的是**完整性**,不是正确性:它认得出「写了一半」,认不出「写错了地方」。 - 后者由扇区头里的 epoch 与 recs 兜着。 - - ---- 字节序 -------------------------------------------------------------- - 记录是把结构体原样写下去的,所以 flash 里是**本机小端**。这不成问题: - 读它的只有同一颗 MCU 上的同一份固件。上位机拿到的记录是经链路层 - (A13/A14)序列化过去的,不是直接读 flash 的镜像。 */ -#include -#include - -#include "evlog.h" -#include "hal_lock.h" -#include "hal_store.h" -#include "hal_time.h" - -#define EVLOG_MAGIC 0x474F4C45u /* "ELOG" */ -#define EVLOG_VER 1 - -/* 一次 `hal_store_write()` 最多带几条。256 B 的栈上缓冲,够摊薄写事务的开销了; - 再大也只是让 `evlog_flush()` 的最坏耗时变长。 */ -#define EVLOG_CHUNK 16 - -typedef struct { - uint32_t magic; - uint16_t ver; - uint16_t recs; /* 本扇区能装几条。几何变了(换了 flash / 换了分区大小)时, - 旧扇区认不出来 —— 这比按新几何去解释旧内容好得多 */ - uint32_t epoch; /* 全局递增:第几个被写的扇区 */ - uint32_t chk; -} evlog_shdr_t; - -/* 两个「结构体就是 flash 布局」的地方,编译期钉死。 */ -typedef char evlog_rec_is_16b[(sizeof(evlog_rec_t) == 16) ? 1 : -1]; -typedef char evlog_hdr_is_16b[(sizeof(evlog_shdr_t) == 16) ? 1 : -1]; - -/* ---- RAM 环形缓冲 -------------------------------------------------------- - 入队方可能是任何一个任务(t_safety 优先级最高),出队方只有 t_log。 - 两个下标的改动在临界区里做完(hal_lock.h:那不是第三把锁)。 */ -static evlog_rec_t s_ring[EVLOG_RAM_CAP]; -static uint32_t s_head; /* 下一条写进去的位置 */ -static uint32_t s_tail; /* 下一条要落盘的位置 */ -static uint32_t s_count; /* 环里现有几条 */ -static uint32_t s_oldest_ms; /* 环里最老那条的入队时刻(攒批的计时起点) */ -static uint32_t s_lost; /* 欠着一条 LOST 记录,值是丢了多少条 */ - -/* ---- flash 侧 ------------------------------------------------------------ */ -static bool s_have_store; -static uint32_t s_sector_bytes; -static uint32_t s_sectors; -static uint32_t s_recs; /* 每扇区能装几条记录(= sector_bytes/16 − 1) */ -static uint32_t s_epoch; /* 正在写的扇区的 epoch */ -static uint32_t s_slot; /* 该扇区里下一个空槽;== s_recs 表示这个扇区写满了 */ -static uint32_t s_next_seq; -static uint32_t s_oldest_seq; - -/* ---- 计数器 -------------------------------------------------------------- */ -static uint32_t s_queued; -static uint32_t s_stored; -static uint32_t s_dropped; -static uint32_t s_io_errors; - -/* ---- 校验 ---------------------------------------------------------------- */ - -/* CRC-8(多项式 0x07,初值 0xA5)。 - - ⚠️ **不要「优化」成异或和。** 第一版就是异或和,它认不出下面这条: - 一条 `bus_mv` 与 `current_ua` 都是 0 的记录(BOOT 就长这样),被掉电切在 - 第 8 个字节上,后 8 个字节留在 0xFF —— 而 8 个 0xFF 的异或恰好是 0, - 于是半条记录的异或和与整条**一模一样**,校验照过。 - `test_a_torn_write_with_zero_payload_is_still_rejected()` 就是这条。 - CRC 对「一段字节整体变成 0xFF」敏感,初值也不是 0(认得出全 0 的一片)。 */ -static uint8_t rec_chk(const evlog_rec_t *r) -{ - const uint8_t *b = (const uint8_t *)r; - uint8_t c = 0xA5u; - for (size_t i = 0; i < sizeof(*r); i++) { - if (i == offsetof(evlog_rec_t, chk)) { - continue; - } - c ^= b[i]; - for (int k = 0; k < 8; k++) { - c = (uint8_t)((c & 0x80u) ? ((uint8_t)(c << 1) ^ 0x07u) : (uint8_t)(c << 1)); - } - } - return c; -} - -/* 擦干净的槽(16 个 0xFF)在这里必须是 false —— 扫描就靠它找「上次写到哪了」, - 而这是整份日志唯一的位置信息。所以不靠 CRC 去撞那 1/256,直接认死: - **全 0xFF = 没写过**。真记录里 `t_ms` 是 0xFFFFFFFF 且五个字段全 0xFF 的 - 那一条不存在(那是 49.7 天整、−1 mV、−1 µA、路号 255 同时成立)。 */ -static bool rec_all_ff(const evlog_rec_t *r) -{ - const uint8_t *b = (const uint8_t *)r; - for (size_t i = 0; i < sizeof(*r); i++) { - if (b[i] != 0xFFu) { - return false; - } - } - return true; -} - -static bool rec_valid(const evlog_rec_t *r) -{ - return !rec_all_ff(r) && r->chk == rec_chk(r); -} - -static uint32_t hdr_chk(const evlog_shdr_t *h) -{ - return h->magic ^ ((uint32_t)h->ver | ((uint32_t)h->recs << 16)) ^ h->epoch ^ 0x5A5A5A5Au; -} - -static bool hdr_valid(const evlog_shdr_t *h, uint32_t sector) -{ - return h->magic == EVLOG_MAGIC && h->ver == EVLOG_VER && h->recs == (uint16_t)s_recs && - h->chk == hdr_chk(h) && (h->epoch % s_sectors) == sector; -} - -static uint32_t rec_off(uint32_t sector, uint32_t slot) -{ - return sector * s_sector_bytes + (slot + 1u) * (uint32_t)sizeof(evlog_rec_t); -} - -/* ---- 入队(谁产生事件谁调;不阻塞、不碰 flash) -------------------------- */ - -static void push(const evlog_rec_t *r) -{ - /* ⚠️ 临界区里**只改内存**:时间戳、校验都在外面算完了。 - 往里面加一行「顺手」的活,抖的是 t_safety 那 20 ms 的节拍。 */ - hal_crit_enter(); - if (s_count == EVLOG_RAM_CAP) { - /* 满了盖最旧的一条 —— §10.7 的原则是「最新的一条永远进得来」。 - 丢掉的这条会在下一次落盘时以一条 LOST 记录留下痕迹。 */ - s_tail = (s_tail + 1u) % EVLOG_RAM_CAP; - s_count--; - s_dropped++; - s_lost++; - } - if (s_count == 0u) { - s_oldest_ms = r->t_ms; /* 攒批的计时从环里第一条算起 */ - } - s_ring[s_head] = *r; - s_head = (s_head + 1u) % EVLOG_RAM_CAP; - s_count++; - s_queued++; - hal_crit_exit(); -} - -void evlog_put(uint8_t rail, log_ev_t type, uint8_t arg, int32_t bus_mv, int32_t ua) -{ - evlog_rec_t r; - memset(&r, 0, sizeof(r)); /* 记录会被原样写进 flash:不留没初始化的字节 */ - r.t_ms = hal_time_now_ms(); - r.rail = rail; - r.type = (uint8_t)type; - r.arg = arg; - r.bus_mv = bus_mv; - r.current_ua = ua; - r.chk = rec_chk(&r); - push(&r); -} - -void evlog_put_safety(void *ctx, const safety_event_t *ev) -{ - (void)ctx; - if (ev == NULL) { - return; - } - evlog_rec_t r; - memset(&r, 0, sizeof(r)); - /* ⚠️ 时间戳用事件里带的那个:判据记的是**断电那一刻**, - 而这个回调是断完电才被调的。差的那几十微秒不重要,重要的是别把 - 「事件发生的时刻」悄悄换成「记账的时刻」—— 排查时序问题时会差出一整圈。 */ - r.t_ms = ev->t_ms; - r.rail = ev->rail; - r.type = (uint8_t)evlog_from_safety(ev->type); - r.bus_mv = ev->bus_mv; - r.current_ua = ev->current_ua; - r.chk = rec_chk(&r); - push(&r); -} - -/* 显式的一一对应。**不许写成 `LOG_EV_OVER_VOLT + (ev − 1)`** —— - 那样 t_safety.h 里插进一个新事件类型,flash 里所有旧记录的含义就整体挪位了, - 而且不会有任何一处报错。`test_the_safety_mapping_is_explicit()` 拦这件事。 */ -log_ev_t evlog_from_safety(uint8_t safety_ev) -{ - switch ((safety_ev_t)safety_ev) { - case SAFETY_EV_OVER_VOLT: return LOG_EV_OVER_VOLT; - case SAFETY_EV_UNDER_VOLT: return LOG_EV_UNDER_VOLT; - case SAFETY_EV_REVERSE: return LOG_EV_REVERSE; - case SAFETY_EV_PUSHED_UP: return LOG_EV_PUSHED_UP; - case SAFETY_EV_MUX_LOST: return LOG_EV_MUX_LOST; - case SAFETY_EV_SLOT_EMPTY: return LOG_EV_SLOT_EMPTY; - case SAFETY_EV_BLADE_MUTE: return LOG_EV_BLADE_MUTE; - case SAFETY_EV_NONE: break; - } - return LOG_EV_NONE; -} - -/* ---- 落盘(只有 t_log 与 A12 的拉日志命令调) ---------------------------- */ - -static hal_err_t write_header(uint32_t sector, uint32_t epoch) -{ - evlog_shdr_t h; - memset(&h, 0, sizeof(h)); - h.magic = EVLOG_MAGIC; - h.ver = EVLOG_VER; - h.recs = (uint16_t)s_recs; - h.epoch = epoch; - h.chk = hdr_chk(&h); - return hal_store_write(sector * s_sector_bytes, &h, sizeof(h)); -} - -/* 换到下一个扇区:先擦(这一下盖掉的就是最老的 255 条),再写头。 */ -static hal_err_t advance_sector(void) -{ - const uint32_t epoch = s_epoch + 1u; - const uint32_t sector = epoch % s_sectors; - hal_err_t err = hal_store_erase(sector); - if (err != HAL_OK) { - return err; - } - err = write_header(sector, epoch); - if (err != HAL_OK) { - return err; - } - s_epoch = epoch; - s_slot = 0; - s_next_seq = epoch * s_recs; - if (epoch >= s_sectors) { - s_oldest_seq = (epoch - s_sectors + 1u) * s_recs; - } - return HAL_OK; -} - -/* 把 n 条连续记录写进当前扇区(跨扇区就自己换)。返回真的写进去几条。 */ -static uint32_t store_run(const evlog_rec_t *r, uint32_t n) -{ - uint32_t done = 0; - while (done < n) { - if (s_slot >= s_recs) { - if (advance_sector() != HAL_OK) { - s_io_errors++; - break; - } - } - uint32_t k = n - done; - if (k > s_recs - s_slot) { - k = s_recs - s_slot; - } - const uint32_t off = rec_off(s_epoch % s_sectors, s_slot); - if (hal_store_write(off, &r[done], k * (uint32_t)sizeof(evlog_rec_t)) != HAL_OK) { - s_io_errors++; - /* 写失败时**这一槽可能只落进去了半条**(掉电、或者 flash 真坏了)。 - 半条记录后面再接着写的话,重启扫描会停在半条那里,把后面写的 - 全部当成不存在。所以整个扇区的剩余部分就此放弃,下一条换扇区去。 - 代价是最多浪费 254 个槽,换来的是「扫描停在哪 = 日志到哪为止」 - 这条不变量永远成立。 */ - s_slot = s_recs; - break; - } - s_slot += k; - s_next_seq += k; - s_stored += k; - done += k; - } - return done; -} - -/* 补一条「这里丢了 n 条」。它自己也占一个序号 —— 日志里看得见的那种丢失。 - 返回真的写进去几条(0 或 1):它和普通记录一样算进 `evlog_flush()` 的返回值, - 否则会出现「flush 说写了 0 条,序号却往前走了一格」这种对不上的账。 */ -static uint32_t store_lost_marker(void) -{ - uint32_t n; - hal_crit_enter(); - n = s_lost; - s_lost = 0; - hal_crit_exit(); - if (n == 0u) { - return 0u; - } - evlog_rec_t r; - memset(&r, 0, sizeof(r)); - r.t_ms = hal_time_now_ms(); - r.type = (uint8_t)LOG_EV_LOST; - r.bus_mv = (int32_t)n; /* 丢了几条 */ - r.chk = rec_chk(&r); - if (store_run(&r, 1u) != 1u) { - hal_crit_enter(); - s_lost += n; /* 连这条都没写进去,欠着,下次再补 */ - hal_crit_exit(); - return 0u; - } - return 1u; -} - -static uint32_t pop(evlog_rec_t *dst, uint32_t want) -{ - hal_crit_enter(); - uint32_t n = (want < s_count) ? want : s_count; - for (uint32_t i = 0; i < n; i++) { - dst[i] = s_ring[(s_tail + i) % EVLOG_RAM_CAP]; - } - s_tail = (s_tail + n) % EVLOG_RAM_CAP; - s_count -= n; - if (s_count > 0u) { - s_oldest_ms = s_ring[s_tail].t_ms; - } - hal_crit_exit(); - return n; -} - -bool evlog_due(void) -{ - bool due; - hal_crit_enter(); - due = (s_lost > 0u) || (s_count >= EVLOG_BATCH) || - (s_count > 0u && hal_time_elapsed_ms(s_oldest_ms) >= EVLOG_MAX_AGE_MS); - hal_crit_exit(); - return due; -} - -uint32_t evlog_flush(uint32_t max) -{ - if (!s_have_store) { - return 0u; /* 没有那块分区:事件留在 RAM 里,别把它们弄丢 */ - } - const uint32_t budget = (max == 0u) ? EVLOG_RAM_CAP : max; - /* ⚠️ 补记的 LOST 不占预算:它是上一次落盘欠下的账,拖到下一次只会让 - 「日志里丢了一段」这件事更晚才被看见。最多超出预算 1 条。 */ - uint32_t total = store_lost_marker(); - - uint32_t from_ring = 0; - evlog_rec_t buf[EVLOG_CHUNK]; - while (from_ring < budget) { - uint32_t want = budget - from_ring; - if (want > EVLOG_CHUNK) { - want = EVLOG_CHUNK; - } - const uint32_t n = pop(buf, want); - if (n == 0u) { - break; - } - const uint32_t w = store_run(buf, n); - total += w; - from_ring += w; - if (w < n) { - /* 已经从环里取出来了却没写进去 —— 这些是真丢了, - 记进 LOST,下一次落盘时它会以一条记录的形式出现在日志里。 */ - hal_crit_enter(); - s_dropped += (n - w); - s_lost += (n - w); - hal_crit_exit(); - break; - } - } - return total; -} - -/* ---- 读回 ---------------------------------------------------------------- */ - -uint32_t evlog_read(uint32_t from_seq, evlog_rec_t *out, uint32_t max) -{ - if (!s_have_store || out == NULL || max == 0u) { - return 0u; - } - uint32_t seq = (from_seq < s_oldest_seq) ? s_oldest_seq : from_seq; - if (seq >= s_next_seq) { - return 0u; - } - uint32_t n = s_next_seq - seq; - if (n > max) { - n = max; - } - for (uint32_t i = 0; i < n; i++) { - const uint32_t s = seq + i; - const uint32_t epoch = s / s_recs; - const uint32_t slot = s % s_recs; - evlog_rec_t r; - memset(&r, 0, sizeof(r)); - if (hal_store_read(rec_off(epoch % s_sectors, slot), &r, sizeof(r)) != HAL_OK || - !rec_valid(&r)) { - /* 序号空间里的一个洞(写失败之后被放弃的槽)。**照样占一个位置**, - 否则调用方的游标 `from_seq + n` 会对不上,拉日志会卡在洞上。 */ - memset(&r, 0, sizeof(r)); - r.type = (uint8_t)LOG_EV_NONE; - } - out[i] = r; - } - return n; -} - -/* ---- 启动:扫一遍 flash 找到接着写的位置 --------------------------------- */ - -static hal_err_t format_all(void) -{ - for (uint32_t i = 0; i < s_sectors; i++) { - const hal_err_t err = hal_store_erase(i); - if (err != HAL_OK) { - return err; - } - } - const hal_err_t err = write_header(0u, 0u); - if (err != HAL_OK) { - return err; - } - s_epoch = 0; - s_slot = 0; - s_next_seq = 0; - s_oldest_seq = 0; - return HAL_OK; -} - -hal_err_t evlog_init(void) { return evlog_init_ex(0); } - -hal_err_t evlog_init_ex(uint8_t boot_arg) -{ - /* RAM 侧无条件清干净:真板上这里刚上电,宿主机上这条让 `sim_hal_reboot()` - 之后的行为和真重启一致(statics 在进程里是活着的)。 */ - hal_crit_enter(); - s_head = 0; - s_tail = 0; - s_count = 0; - s_oldest_ms = 0; - s_lost = 0; - hal_crit_exit(); - s_queued = 0; - s_stored = 0; - s_dropped = 0; - s_io_errors = 0; - s_have_store = false; - s_sector_bytes = 0; - s_sectors = 0; - s_recs = 0; - s_epoch = 0; - s_slot = 0; - s_next_seq = 0; - s_oldest_seq = 0; - - hal_err_t err = hal_store_init(); - if (err != HAL_OK) { - /* 没有分区。**不 abort、不影响入队**:日志是排查手段,不是安全链 - (evlog.h 上面那段)。事件照进 RAM,只是没人能把它们写下去。 */ - evlog_put(0, LOG_EV_BOOT, boot_arg, 0, 0); - return err; - } - - hal_store_geom_t g; - hal_store_geometry(&g); - if (g.sector_bytes < 2u * sizeof(evlog_rec_t) || - (g.sector_bytes % sizeof(evlog_rec_t)) != 0u || g.sectors < 2u) { - s_have_store = false; - evlog_put(0, LOG_EV_BOOT, boot_arg, 0, 0); - return HAL_ERR_STATE; - } - s_sector_bytes = g.sector_bytes; - s_sectors = g.sectors; - s_recs = g.sector_bytes / (uint32_t)sizeof(evlog_rec_t) - 1u; /* 头占一个槽 */ - s_have_store = true; - - /* 17 个扇区头里 epoch 最大的那个 = 上次正在写的扇区。 - `hdr_valid()` 顺带查了 `epoch % 扇区数 == 扇区号` —— 几何变了、 - 或者这块区被别人写过,都会在这里落空,然后整块重来。 */ - bool found = false; - uint32_t best = 0; - for (uint32_t i = 0; i < s_sectors; i++) { - evlog_shdr_t h; - memset(&h, 0, sizeof(h)); - if (hal_store_read(i * s_sector_bytes, &h, sizeof(h)) != HAL_OK) { - continue; - } - if (hdr_valid(&h, i) && (!found || h.epoch > best)) { - found = true; - best = h.epoch; - } - } - - if (!found) { - err = format_all(); - if (err != HAL_OK) { - s_io_errors++; - s_have_store = false; - } - } else { - s_epoch = best; - /* 扫到第一个校验不过的槽为止 —— 那就是上次写到的地方。 - 「校验不过」包含两种:擦干净的(0xFF)和被掉电切断的半条。 */ - s_slot = 0; - for (uint32_t r = 0; r < s_recs; r++) { - evlog_rec_t rec; - memset(&rec, 0, sizeof(rec)); - if (hal_store_read(rec_off(best % s_sectors, r), &rec, sizeof(rec)) != HAL_OK || - !rec_valid(&rec)) { - break; - } - s_slot = r + 1u; - } - s_next_seq = best * s_recs + s_slot; - s_oldest_seq = (best >= s_sectors) ? (best - s_sectors + 1u) * s_recs : 0u; - } - - /* §10.7「上电从 0 计」⇒ 时间戳会跳回去。没有这一条的话,重启前后的记录 - 在时间轴上互相穿插,而日志里看不出重启发生过。 */ - evlog_put(0, LOG_EV_BOOT, boot_arg, 0, 0); - return s_have_store ? HAL_OK : HAL_ERR_STATE; -} - -/* ---- 杂项 ---------------------------------------------------------------- */ - -void evlog_stats(evlog_stats_t *st) -{ - if (st == NULL) { - return; - } - hal_crit_enter(); - st->queued = s_queued; - st->stored = s_stored; - st->dropped = s_dropped; - st->pending = s_count; - hal_crit_exit(); - st->io_errors = s_io_errors; - st->oldest_seq = s_oldest_seq; - st->next_seq = s_next_seq; -} - -uint32_t evlog_capacity(void) { return s_have_store ? s_sectors * s_recs : 0u; } - -const char *evlog_ev_str(log_ev_t ev) -{ - switch (ev) { - case LOG_EV_NONE: return "(空)"; - case LOG_EV_BOOT: return "上电"; - case LOG_EV_LOST: return "事件丢失"; - case LOG_EV_OVER_VOLT: return "过压关路"; - case LOG_EV_UNDER_VOLT: return "欠压关路"; - case LOG_EV_REVERSE: return "反灌断VBUS"; - case LOG_EV_PUSHED_UP: return "顶高断VBUS"; - case LOG_EV_EFUSE_FAULT: return "eFuse故障"; - case LOG_EV_PG_CHANGE: return "PG变化"; - case LOG_EV_I2C_LOST: return "I2C失联"; - case LOG_EV_THERMAL_DERATE: return "过温降载"; - case LOG_EV_FAN_STALL: return "风扇停转"; - case LOG_EV_REBOOT: return "重启序列"; - case LOG_EV_CMD_REJECT: return "命令被拒"; - case LOG_EV_MUX_LOST: return "mux失联全断"; - case LOG_EV_SLOT_EMPTY: return "刀片不在位"; - case LOG_EV_PD_LIMIT: return "PD限功率"; - case LOG_EV_PG_TIMEOUT: return "上电等不到PG"; - case LOG_EV_OTA: return "固件升级"; - case LOG_EV_BOOT_CHECK: return "开机重按"; - case LOG_EV_BLADE_MUTE: return "刀片不应答关路"; - case LOG_EV_COUNT: break; - } - return "?"; -} +/* 事件日志的实现。接口与「为什么这么分层」在 evlog.h,这里只写 flash 上的样子 + 与几处不写下来就一定会被改坏的地方。 + + ---- flash 上的样子(17 个扇区 × 4 KB) ---------------------------------- + + 扇区 n: [ 16 B 扇区头 ][ 记录 0 ][ 记录 1 ] … [ 记录 254 ] + magic/ver/ 每条 16 B,255 条 + recs/epoch/chk + + · **扇区头里的 `epoch` 是全局递增的**(第几个被写的扇区), + 所以 `扇区号 = epoch % 17`,重启时不用记住任何东西:读 17 个头, + epoch 最大的那个就是正在写的扇区。 + · **记录的序号 = `epoch × 255 + 槽号`**。序号因此就是「flash 里第几条」, + 不需要额外存一个计数器,也不会在掉电后倒退。 + · 环形:写满一个扇区就**擦掉下一个**(也就是最老的那个)再写它的头。 + 擦除是 4 KB 粒度的,所以「盖掉最旧的」一次盖 255 条 —— 这正是 §10.7 + 的容量算法里那 4096 条的来源。 + + ---- 为什么每条记录要带一个校验字节 -------------------------------------- + NOR flash 的写可以被掉电打断在任意一个字节上,落进去的是**半条记录**, + 而半条记录在字节层面看跟一条真记录长得一模一样(时间戳合法、路号合法、 + 类型合法)。没有校验的话,重启后的扫描会把它当成有效记录继续往后写, + 而上位机拉到的是一条时间戳来自过去、电压是 0xFFFF 的「事件」。 + 校验字节让扫描停在它前面 —— `test_a_torn_write_does_not_become_a_record()`。 + + ⚠️ 校验的是**完整性**,不是正确性:它认得出「写了一半」,认不出「写错了地方」。 + 后者由扇区头里的 epoch 与 recs 兜着。 + + ---- 字节序 -------------------------------------------------------------- + 记录是把结构体原样写下去的,所以 flash 里是**本机小端**。这不成问题: + 读它的只有同一颗 MCU 上的同一份固件。上位机拿到的记录是经链路层 + (A13/A14)序列化过去的,不是直接读 flash 的镜像。 */ +#include +#include + +#include "evlog.h" +#include "hal_lock.h" +#include "hal_store.h" +#include "hal_time.h" + +#define EVLOG_MAGIC 0x474F4C45u /* "ELOG" */ +#define EVLOG_VER 1 + +/* 一次 `hal_store_write()` 最多带几条。256 B 的栈上缓冲,够摊薄写事务的开销了; + 再大也只是让 `evlog_flush()` 的最坏耗时变长。 */ +#define EVLOG_CHUNK 16 + +typedef struct { + uint32_t magic; + uint16_t ver; + uint16_t recs; /* 本扇区能装几条。几何变了(换了 flash / 换了分区大小)时, + 旧扇区认不出来 —— 这比按新几何去解释旧内容好得多 */ + uint32_t epoch; /* 全局递增:第几个被写的扇区 */ + uint32_t chk; +} evlog_shdr_t; + +/* 两个「结构体就是 flash 布局」的地方,编译期钉死。 */ +typedef char evlog_rec_is_16b[(sizeof(evlog_rec_t) == 16) ? 1 : -1]; +typedef char evlog_hdr_is_16b[(sizeof(evlog_shdr_t) == 16) ? 1 : -1]; + +/* ---- RAM 环形缓冲 -------------------------------------------------------- + 入队方可能是任何一个任务(t_safety 优先级最高),出队方只有 t_log。 + 两个下标的改动在临界区里做完(hal_lock.h:那不是第三把锁)。 */ +static evlog_rec_t s_ring[EVLOG_RAM_CAP]; +static uint32_t s_head; /* 下一条写进去的位置 */ +static uint32_t s_tail; /* 下一条要落盘的位置 */ +static uint32_t s_count; /* 环里现有几条 */ +static uint32_t s_oldest_ms; /* 环里最老那条的入队时刻(攒批的计时起点) */ +static uint32_t s_lost; /* 欠着一条 LOST 记录,值是丢了多少条 */ + +/* ---- flash 侧 ------------------------------------------------------------ */ +static bool s_have_store; +static uint32_t s_sector_bytes; +static uint32_t s_sectors; +static uint32_t s_recs; /* 每扇区能装几条记录(= sector_bytes/16 − 1) */ +static uint32_t s_epoch; /* 正在写的扇区的 epoch */ +static uint32_t s_slot; /* 该扇区里下一个空槽;== s_recs 表示这个扇区写满了 */ +static uint32_t s_next_seq; +static uint32_t s_oldest_seq; + +/* ---- 计数器 -------------------------------------------------------------- */ +static uint32_t s_queued; +static uint32_t s_stored; +static uint32_t s_dropped; +static uint32_t s_io_errors; + +/* ---- 校验 ---------------------------------------------------------------- */ + +/* CRC-8(多项式 0x07,初值 0xA5)。 + + ⚠️ **不要「优化」成异或和。** 第一版就是异或和,它认不出下面这条: + 一条 `bus_mv` 与 `current_ua` 都是 0 的记录(BOOT 就长这样),被掉电切在 + 第 8 个字节上,后 8 个字节留在 0xFF —— 而 8 个 0xFF 的异或恰好是 0, + 于是半条记录的异或和与整条**一模一样**,校验照过。 + `test_a_torn_write_with_zero_payload_is_still_rejected()` 就是这条。 + CRC 对「一段字节整体变成 0xFF」敏感,初值也不是 0(认得出全 0 的一片)。 */ +static uint8_t rec_chk(const evlog_rec_t *r) +{ + const uint8_t *b = (const uint8_t *)r; + uint8_t c = 0xA5u; + for (size_t i = 0; i < sizeof(*r); i++) { + if (i == offsetof(evlog_rec_t, chk)) { + continue; + } + c ^= b[i]; + for (int k = 0; k < 8; k++) { + c = (uint8_t)((c & 0x80u) ? ((uint8_t)(c << 1) ^ 0x07u) : (uint8_t)(c << 1)); + } + } + return c; +} + +/* 擦干净的槽(16 个 0xFF)在这里必须是 false —— 扫描就靠它找「上次写到哪了」, + 而这是整份日志唯一的位置信息。所以不靠 CRC 去撞那 1/256,直接认死: + **全 0xFF = 没写过**。真记录里 `t_ms` 是 0xFFFFFFFF 且五个字段全 0xFF 的 + 那一条不存在(那是 49.7 天整、−1 mV、−1 µA、路号 255 同时成立)。 */ +static bool rec_all_ff(const evlog_rec_t *r) +{ + const uint8_t *b = (const uint8_t *)r; + for (size_t i = 0; i < sizeof(*r); i++) { + if (b[i] != 0xFFu) { + return false; + } + } + return true; +} + +static bool rec_valid(const evlog_rec_t *r) +{ + return !rec_all_ff(r) && r->chk == rec_chk(r); +} + +static uint32_t hdr_chk(const evlog_shdr_t *h) +{ + return h->magic ^ ((uint32_t)h->ver | ((uint32_t)h->recs << 16)) ^ h->epoch ^ 0x5A5A5A5Au; +} + +static bool hdr_valid(const evlog_shdr_t *h, uint32_t sector) +{ + return h->magic == EVLOG_MAGIC && h->ver == EVLOG_VER && h->recs == (uint16_t)s_recs && + h->chk == hdr_chk(h) && (h->epoch % s_sectors) == sector; +} + +static uint32_t rec_off(uint32_t sector, uint32_t slot) +{ + return sector * s_sector_bytes + (slot + 1u) * (uint32_t)sizeof(evlog_rec_t); +} + +/* ---- 入队(谁产生事件谁调;不阻塞、不碰 flash) -------------------------- */ + +static void push(const evlog_rec_t *r) +{ + /* ⚠️ 临界区里**只改内存**:时间戳、校验都在外面算完了。 + 往里面加一行「顺手」的活,抖的是 t_safety 那 20 ms 的节拍。 */ + hal_crit_enter(); + if (s_count == EVLOG_RAM_CAP) { + /* 满了盖最旧的一条 —— §10.7 的原则是「最新的一条永远进得来」。 + 丢掉的这条会在下一次落盘时以一条 LOST 记录留下痕迹。 */ + s_tail = (s_tail + 1u) % EVLOG_RAM_CAP; + s_count--; + s_dropped++; + s_lost++; + } + if (s_count == 0u) { + s_oldest_ms = r->t_ms; /* 攒批的计时从环里第一条算起 */ + } + s_ring[s_head] = *r; + s_head = (s_head + 1u) % EVLOG_RAM_CAP; + s_count++; + s_queued++; + hal_crit_exit(); +} + +void evlog_put(uint8_t rail, log_ev_t type, uint8_t arg, int32_t bus_mv, int32_t ua) +{ + evlog_rec_t r; + memset(&r, 0, sizeof(r)); /* 记录会被原样写进 flash:不留没初始化的字节 */ + r.t_ms = hal_time_now_ms(); + r.rail = rail; + r.type = (uint8_t)type; + r.arg = arg; + r.bus_mv = bus_mv; + r.current_ua = ua; + r.chk = rec_chk(&r); + push(&r); +} + +void evlog_put_safety(void *ctx, const safety_event_t *ev) +{ + (void)ctx; + if (ev == NULL) { + return; + } + evlog_rec_t r; + memset(&r, 0, sizeof(r)); + /* ⚠️ 时间戳用事件里带的那个:判据记的是**断电那一刻**, + 而这个回调是断完电才被调的。差的那几十微秒不重要,重要的是别把 + 「事件发生的时刻」悄悄换成「记账的时刻」—— 排查时序问题时会差出一整圈。 */ + r.t_ms = ev->t_ms; + r.rail = ev->rail; + r.type = (uint8_t)evlog_from_safety(ev->type); + r.bus_mv = ev->bus_mv; + r.current_ua = ev->current_ua; + r.chk = rec_chk(&r); + push(&r); +} + +/* 显式的一一对应。**不许写成 `LOG_EV_OVER_VOLT + (ev − 1)`** —— + 那样 t_safety.h 里插进一个新事件类型,flash 里所有旧记录的含义就整体挪位了, + 而且不会有任何一处报错。`test_the_safety_mapping_is_explicit()` 拦这件事。 */ +log_ev_t evlog_from_safety(uint8_t safety_ev) +{ + switch ((safety_ev_t)safety_ev) { + case SAFETY_EV_OVER_VOLT: return LOG_EV_OVER_VOLT; + case SAFETY_EV_UNDER_VOLT: return LOG_EV_UNDER_VOLT; + case SAFETY_EV_REVERSE: return LOG_EV_REVERSE; + case SAFETY_EV_PUSHED_UP: return LOG_EV_PUSHED_UP; + case SAFETY_EV_MUX_LOST: return LOG_EV_MUX_LOST; + case SAFETY_EV_SLOT_EMPTY: return LOG_EV_SLOT_EMPTY; + case SAFETY_EV_BLADE_MUTE: return LOG_EV_BLADE_MUTE; + case SAFETY_EV_NONE: break; + } + return LOG_EV_NONE; +} + +/* ---- 落盘(只有 t_log 与 A12 的拉日志命令调) ---------------------------- */ + +static hal_err_t write_header(uint32_t sector, uint32_t epoch) +{ + evlog_shdr_t h; + memset(&h, 0, sizeof(h)); + h.magic = EVLOG_MAGIC; + h.ver = EVLOG_VER; + h.recs = (uint16_t)s_recs; + h.epoch = epoch; + h.chk = hdr_chk(&h); + return hal_store_write(sector * s_sector_bytes, &h, sizeof(h)); +} + +/* 换到下一个扇区:先擦(这一下盖掉的就是最老的 255 条),再写头。 */ +static hal_err_t advance_sector(void) +{ + const uint32_t epoch = s_epoch + 1u; + const uint32_t sector = epoch % s_sectors; + hal_err_t err = hal_store_erase(sector); + if (err != HAL_OK) { + return err; + } + err = write_header(sector, epoch); + if (err != HAL_OK) { + return err; + } + s_epoch = epoch; + s_slot = 0; + s_next_seq = epoch * s_recs; + if (epoch >= s_sectors) { + s_oldest_seq = (epoch - s_sectors + 1u) * s_recs; + } + return HAL_OK; +} + +/* 把 n 条连续记录写进当前扇区(跨扇区就自己换)。返回真的写进去几条。 */ +static uint32_t store_run(const evlog_rec_t *r, uint32_t n) +{ + uint32_t done = 0; + while (done < n) { + if (s_slot >= s_recs) { + if (advance_sector() != HAL_OK) { + s_io_errors++; + break; + } + } + uint32_t k = n - done; + if (k > s_recs - s_slot) { + k = s_recs - s_slot; + } + const uint32_t off = rec_off(s_epoch % s_sectors, s_slot); + if (hal_store_write(off, &r[done], k * (uint32_t)sizeof(evlog_rec_t)) != HAL_OK) { + s_io_errors++; + /* 写失败时**这一槽可能只落进去了半条**(掉电、或者 flash 真坏了)。 + 半条记录后面再接着写的话,重启扫描会停在半条那里,把后面写的 + 全部当成不存在。所以整个扇区的剩余部分就此放弃,下一条换扇区去。 + 代价是最多浪费 254 个槽,换来的是「扫描停在哪 = 日志到哪为止」 + 这条不变量永远成立。 */ + s_slot = s_recs; + break; + } + s_slot += k; + s_next_seq += k; + s_stored += k; + done += k; + } + return done; +} + +/* 补一条「这里丢了 n 条」。它自己也占一个序号 —— 日志里看得见的那种丢失。 + 返回真的写进去几条(0 或 1):它和普通记录一样算进 `evlog_flush()` 的返回值, + 否则会出现「flush 说写了 0 条,序号却往前走了一格」这种对不上的账。 */ +static uint32_t store_lost_marker(void) +{ + uint32_t n; + hal_crit_enter(); + n = s_lost; + s_lost = 0; + hal_crit_exit(); + if (n == 0u) { + return 0u; + } + evlog_rec_t r; + memset(&r, 0, sizeof(r)); + r.t_ms = hal_time_now_ms(); + r.type = (uint8_t)LOG_EV_LOST; + r.bus_mv = (int32_t)n; /* 丢了几条 */ + r.chk = rec_chk(&r); + if (store_run(&r, 1u) != 1u) { + hal_crit_enter(); + s_lost += n; /* 连这条都没写进去,欠着,下次再补 */ + hal_crit_exit(); + return 0u; + } + return 1u; +} + +static uint32_t pop(evlog_rec_t *dst, uint32_t want) +{ + hal_crit_enter(); + uint32_t n = (want < s_count) ? want : s_count; + for (uint32_t i = 0; i < n; i++) { + dst[i] = s_ring[(s_tail + i) % EVLOG_RAM_CAP]; + } + s_tail = (s_tail + n) % EVLOG_RAM_CAP; + s_count -= n; + if (s_count > 0u) { + s_oldest_ms = s_ring[s_tail].t_ms; + } + hal_crit_exit(); + return n; +} + +bool evlog_due(void) +{ + bool due; + hal_crit_enter(); + due = (s_lost > 0u) || (s_count >= EVLOG_BATCH) || + (s_count > 0u && hal_time_elapsed_ms(s_oldest_ms) >= EVLOG_MAX_AGE_MS); + hal_crit_exit(); + return due; +} + +uint32_t evlog_flush(uint32_t max) +{ + if (!s_have_store) { + return 0u; /* 没有那块分区:事件留在 RAM 里,别把它们弄丢 */ + } + const uint32_t budget = (max == 0u) ? EVLOG_RAM_CAP : max; + /* ⚠️ 补记的 LOST 不占预算:它是上一次落盘欠下的账,拖到下一次只会让 + 「日志里丢了一段」这件事更晚才被看见。最多超出预算 1 条。 */ + uint32_t total = store_lost_marker(); + + uint32_t from_ring = 0; + evlog_rec_t buf[EVLOG_CHUNK]; + while (from_ring < budget) { + uint32_t want = budget - from_ring; + if (want > EVLOG_CHUNK) { + want = EVLOG_CHUNK; + } + const uint32_t n = pop(buf, want); + if (n == 0u) { + break; + } + const uint32_t w = store_run(buf, n); + total += w; + from_ring += w; + if (w < n) { + /* 已经从环里取出来了却没写进去 —— 这些是真丢了, + 记进 LOST,下一次落盘时它会以一条记录的形式出现在日志里。 */ + hal_crit_enter(); + s_dropped += (n - w); + s_lost += (n - w); + hal_crit_exit(); + break; + } + } + return total; +} + +/* ---- 读回 ---------------------------------------------------------------- */ + +uint32_t evlog_read(uint32_t from_seq, evlog_rec_t *out, uint32_t max) +{ + if (!s_have_store || out == NULL || max == 0u) { + return 0u; + } + uint32_t seq = (from_seq < s_oldest_seq) ? s_oldest_seq : from_seq; + if (seq >= s_next_seq) { + return 0u; + } + uint32_t n = s_next_seq - seq; + if (n > max) { + n = max; + } + for (uint32_t i = 0; i < n; i++) { + const uint32_t s = seq + i; + const uint32_t epoch = s / s_recs; + const uint32_t slot = s % s_recs; + evlog_rec_t r; + memset(&r, 0, sizeof(r)); + if (hal_store_read(rec_off(epoch % s_sectors, slot), &r, sizeof(r)) != HAL_OK || + !rec_valid(&r)) { + /* 序号空间里的一个洞(写失败之后被放弃的槽)。**照样占一个位置**, + 否则调用方的游标 `from_seq + n` 会对不上,拉日志会卡在洞上。 */ + memset(&r, 0, sizeof(r)); + r.type = (uint8_t)LOG_EV_NONE; + } + out[i] = r; + } + return n; +} + +/* ---- 启动:扫一遍 flash 找到接着写的位置 --------------------------------- */ + +static hal_err_t format_all(void) +{ + for (uint32_t i = 0; i < s_sectors; i++) { + const hal_err_t err = hal_store_erase(i); + if (err != HAL_OK) { + return err; + } + } + const hal_err_t err = write_header(0u, 0u); + if (err != HAL_OK) { + return err; + } + s_epoch = 0; + s_slot = 0; + s_next_seq = 0; + s_oldest_seq = 0; + return HAL_OK; +} + +hal_err_t evlog_init(void) { return evlog_init_ex(0); } + +hal_err_t evlog_init_ex(uint8_t boot_arg) +{ + /* RAM 侧无条件清干净:真板上这里刚上电,宿主机上这条让 `sim_hal_reboot()` + 之后的行为和真重启一致(statics 在进程里是活着的)。 */ + hal_crit_enter(); + s_head = 0; + s_tail = 0; + s_count = 0; + s_oldest_ms = 0; + s_lost = 0; + hal_crit_exit(); + s_queued = 0; + s_stored = 0; + s_dropped = 0; + s_io_errors = 0; + s_have_store = false; + s_sector_bytes = 0; + s_sectors = 0; + s_recs = 0; + s_epoch = 0; + s_slot = 0; + s_next_seq = 0; + s_oldest_seq = 0; + + hal_err_t err = hal_store_init(); + if (err != HAL_OK) { + /* 没有分区。**不 abort、不影响入队**:日志是排查手段,不是安全链 + (evlog.h 上面那段)。事件照进 RAM,只是没人能把它们写下去。 */ + evlog_put(0, LOG_EV_BOOT, boot_arg, 0, 0); + return err; + } + + hal_store_geom_t g; + hal_store_geometry(&g); + if (g.sector_bytes < 2u * sizeof(evlog_rec_t) || + (g.sector_bytes % sizeof(evlog_rec_t)) != 0u || g.sectors < 2u) { + s_have_store = false; + evlog_put(0, LOG_EV_BOOT, boot_arg, 0, 0); + return HAL_ERR_STATE; + } + s_sector_bytes = g.sector_bytes; + s_sectors = g.sectors; + s_recs = g.sector_bytes / (uint32_t)sizeof(evlog_rec_t) - 1u; /* 头占一个槽 */ + s_have_store = true; + + /* 17 个扇区头里 epoch 最大的那个 = 上次正在写的扇区。 + `hdr_valid()` 顺带查了 `epoch % 扇区数 == 扇区号` —— 几何变了、 + 或者这块区被别人写过,都会在这里落空,然后整块重来。 */ + bool found = false; + uint32_t best = 0; + for (uint32_t i = 0; i < s_sectors; i++) { + evlog_shdr_t h; + memset(&h, 0, sizeof(h)); + if (hal_store_read(i * s_sector_bytes, &h, sizeof(h)) != HAL_OK) { + continue; + } + if (hdr_valid(&h, i) && (!found || h.epoch > best)) { + found = true; + best = h.epoch; + } + } + + if (!found) { + err = format_all(); + if (err != HAL_OK) { + s_io_errors++; + s_have_store = false; + } + } else { + s_epoch = best; + /* 扫到第一个校验不过的槽为止 —— 那就是上次写到的地方。 + 「校验不过」包含两种:擦干净的(0xFF)和被掉电切断的半条。 */ + s_slot = 0; + for (uint32_t r = 0; r < s_recs; r++) { + evlog_rec_t rec; + memset(&rec, 0, sizeof(rec)); + if (hal_store_read(rec_off(best % s_sectors, r), &rec, sizeof(rec)) != HAL_OK || + !rec_valid(&rec)) { + break; + } + s_slot = r + 1u; + } + s_next_seq = best * s_recs + s_slot; + s_oldest_seq = (best >= s_sectors) ? (best - s_sectors + 1u) * s_recs : 0u; + } + + /* §10.7「上电从 0 计」⇒ 时间戳会跳回去。没有这一条的话,重启前后的记录 + 在时间轴上互相穿插,而日志里看不出重启发生过。 */ + evlog_put(0, LOG_EV_BOOT, boot_arg, 0, 0); + return s_have_store ? HAL_OK : HAL_ERR_STATE; +} + +/* ---- 杂项 ---------------------------------------------------------------- */ + +void evlog_stats(evlog_stats_t *st) +{ + if (st == NULL) { + return; + } + hal_crit_enter(); + st->queued = s_queued; + st->stored = s_stored; + st->dropped = s_dropped; + st->pending = s_count; + hal_crit_exit(); + st->io_errors = s_io_errors; + st->oldest_seq = s_oldest_seq; + st->next_seq = s_next_seq; +} + +uint32_t evlog_capacity(void) { return s_have_store ? s_sectors * s_recs : 0u; } + +const char *evlog_ev_str(log_ev_t ev) +{ + switch (ev) { + case LOG_EV_NONE: return "(空)"; + case LOG_EV_BOOT: return "上电"; + case LOG_EV_LOST: return "事件丢失"; + case LOG_EV_OVER_VOLT: return "过压关路"; + case LOG_EV_UNDER_VOLT: return "欠压关路"; + case LOG_EV_REVERSE: return "反灌断VBUS"; + case LOG_EV_PUSHED_UP: return "顶高断VBUS"; + case LOG_EV_EFUSE_FAULT: return "eFuse故障"; + case LOG_EV_PG_CHANGE: return "PG变化"; + case LOG_EV_I2C_LOST: return "I2C失联"; + case LOG_EV_THERMAL_DERATE: return "过温降载"; + case LOG_EV_FAN_STALL: return "风扇停转"; + case LOG_EV_REBOOT: return "重启序列"; + case LOG_EV_CMD_REJECT: return "命令被拒"; + case LOG_EV_MUX_LOST: return "mux失联全断"; + case LOG_EV_SLOT_EMPTY: return "刀片不在位"; + case LOG_EV_PD_LIMIT: return "PD限功率"; + case LOG_EV_PG_TIMEOUT: return "上电等不到PG"; + case LOG_EV_OTA: return "固件升级"; + case LOG_EV_BOOT_CHECK: return "开机重按"; + case LOG_EV_CHG_MODE: return "刷机模式"; + case LOG_EV_BLADE_MUTE: return "刀片不应答关路"; + case LOG_EV_COUNT: break; + } + return "?"; +} diff --git a/firmware/main/evlog.h b/firmware/main/evlog.h index 598cb53..8130967 100644 --- a/firmware/main/evlog.h +++ b/firmware/main/evlog.h @@ -1,192 +1,196 @@ -/* 事件日志(需求 §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 -#include - -#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) */ - - /* A15 新增(需求 §10.9 第 6 步「等各路 PG,超时则记事件并保持该路关闭」)。 - 与 `PG_CHANGE`(8)不是一回事:那是「PG 变了」,这是「开了电、PG 一直没来、已经关回去」。 - arg:bit0 = 超时那一刻 FLT# 在报;bit1 = 这一片 PCA9555 读不到,改按 INA226 电压判的。 */ - LOG_EV_PG_TIMEOUT = 17, - - /* C2 新增:OTA 的每一步。arg = `ota_ev_t`(ota.h);失败那一条 bus_mv = `ota_err_t`, - current_ua = 镜像字节数。整板级(rail = 0)。 */ - LOG_EV_OTA = 18, - - /* 0.1.2 新增:硬重启按电源键之后的开机校验(t_actuator.h `ACT_HR_BOOT_TRIES`)。 - 只在「重按过」或「最终没开起来」时记,一次就开的不记(每次硬重启都记一条太吵)。 - arg:bit0~3 = 一共按了几次,bit7 = 1 最终没开起来(手机电流始终 < 100 mA)。 - bus_mv = 判定时这一路电压,current_ua = 判定时电流。 - 频繁出现 = 这一台的电源键弹片和刀片铜盘接触不良(2026-09-28 板 B 第 1 路)。 */ - LOG_EV_BOOT_CHECK = 19, - - /* 0.1.4 新增:整片刀片不应答(本路 INA226 + PCA9555 连续 10 圈都 NAK,eFuse 开着)⇒ 关这一路。 - `t_safety.h` 的 `T_SAFETY_BLADE_MUTE_N`。bus_mv / current_ua 是上一次读到的旧值,仅供参考。 - 出现 = 这一槽 `PRSNT#` 假在位,或刀片的 I²C 整个断了。 */ - LOG_EV_BLADE_MUTE = 20, - - 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); - -/* 同上,只是 `LOG_EV_BOOT` 那一条的 arg 由调用方给(A15:ESP 上填 `esp_reset_reason()`, - §10.10「看门狗复位 … 记一条复位事件」就是这一条 —— 不另开事件号, - 因为「这次上电是怎么来的」本来就是 BOOT 的属性)。`evlog_init()` = `evlog_init_ex(0)`。 */ -hal_err_t evlog_init_ex(uint8_t boot_arg); - -/* 入队一条。**不阻塞、不碰 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 +#include + +#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) */ + + /* A15 新增(需求 §10.9 第 6 步「等各路 PG,超时则记事件并保持该路关闭」)。 + 与 `PG_CHANGE`(8)不是一回事:那是「PG 变了」,这是「开了电、PG 一直没来、已经关回去」。 + arg:bit0 = 超时那一刻 FLT# 在报;bit1 = 这一片 PCA9555 读不到,改按 INA226 电压判的。 */ + LOG_EV_PG_TIMEOUT = 17, + + /* C2 新增:OTA 的每一步。arg = `ota_ev_t`(ota.h);失败那一条 bus_mv = `ota_err_t`, + current_ua = 镜像字节数。整板级(rail = 0)。 */ + LOG_EV_OTA = 18, + + /* 0.1.2 新增:硬重启按电源键之后的开机校验(t_actuator.h `ACT_HR_BOOT_TRIES`)。 + 只在「重按过」或「最终没开起来」时记,一次就开的不记(每次硬重启都记一条太吵)。 + arg:bit0~3 = 一共按了几次,bit7 = 1 最终没开起来(手机电流始终 < 100 mA)。 + bus_mv = 判定时这一路电压,current_ua = 判定时电流。 + 频繁出现 = 这一台的电源键弹片和刀片铜盘接触不良(2026-09-28 板 B 第 1 路)。 */ + LOG_EV_BOOT_CHECK = 19, + + /* 0.1.4 新增:整片刀片不应答(本路 INA226 + PCA9555 连续 10 圈都 NAK,eFuse 开着)⇒ 关这一路。 + `t_safety.h` 的 `T_SAFETY_BLADE_MUTE_N`。bus_mv / current_ua 是上一次读到的旧值,仅供参考。 + 出现 = 这一槽 `PRSNT#` 假在位,或刀片的 I²C 整个断了。 */ + LOG_EV_BLADE_MUTE = 20, + + /* 0.1.5 新增:刷机模式(chgmode.h)开 / 关 / 到期。arg = 分钟数(1~120,开), + 0 = 命令关(CHG_EV_OFF),255 = 到期(CHG_EV_EXPIRED)。bus_mv / current_ua 是当时这一路的读数。 */ + LOG_EV_CHG_MODE = 21, + + 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); + +/* 同上,只是 `LOG_EV_BOOT` 那一条的 arg 由调用方给(A15:ESP 上填 `esp_reset_reason()`, + §10.10「看门狗复位 … 记一条复位事件」就是这一条 —— 不另开事件号, + 因为「这次上电是怎么来的」本来就是 BOOT 的属性)。`evlog_init()` = `evlog_init_ex(0)`。 */ +hal_err_t evlog_init_ex(uint8_t boot_arg); + +/* 入队一条。**不阻塞、不碰 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 */ diff --git a/firmware/main/i2cbus.c b/firmware/main/i2cbus.c index 85dd841..683bee5 100644 --- a/firmware/main/i2cbus.c +++ b/firmware/main/i2cbus.c @@ -1,152 +1,152 @@ -/* 带 mux 的 I²C 门面。契约与三条规矩见 i2cbus.h。 */ -#include "i2cbus.h" - -#include - -#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; -} +/* 带 mux 的 I²C 门面。契约与三条规矩见 i2cbus.h。 */ +#include "i2cbus.h" + +#include + +#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 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_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; + } + /* 嵌套 = **本任务**已经持着这把锁(回调里又调了一次,头文件末尾那条警告)。 + ⚠️ 0.1.11 之前这里看的是一个全局的 `s_in_xact`:别的任务正在事务里时它也是 true, + 于是执行器撞上巡检那 ~2.3 ms(每 20 ms 一次,≈12%)就被当成「嵌套」立刻回 STATE —— + 现场表现是 `key` 随机回 busy、`vbus` 随机回 io(2026-09-28 板 B)。别的任务持锁 = 该等锁。 */ + if (hal_lock_held(HAL_LOCK_I2C)) { + 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; /* 这一次什么也没做 —— 调用方决定重试还是跳过 */ + } + + err = select_ch(ch); + if (err == HAL_OK) { + err = fn(arg); + } + + hal_lock_give(HAL_LOCK_I2C); + return err; +} + +hal_err_t i2cbus_recover(void) +{ + if (!s_inited) { + return HAL_ERR_STATE; + } + if (hal_lock_held(HAL_LOCK_I2C)) { + 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; +} diff --git a/firmware/main/mlink_esp.c b/firmware/main/mlink_esp.c index dccf375..05dc659 100644 --- a/firmware/main/mlink_esp.c +++ b/firmware/main/mlink_esp.c @@ -45,8 +45,28 @@ static const char *TAG = "mlink"; #define NVS_NS "phonerail" -#define NVS_KEY "net" -#define NVS_VERSION 1 /* netcfg_t 布局一变就加一:旧版本的 blob 直接当没配置 */ +#define NVS_KEY "net2" +#define NVS_VERSION 2 /* netcfg_t 布局一变就加一:旧版本的 blob 直接当没配置 */ + +/* ≤0.1.8 的配置(只有一组 WiFi)存在 "net" 键里。0.1.9 起: + · 读:没有 "net2" 就读 "net" 迁过来(上架的板子升级后不用重新配网); + · 写:两个键**都写**,"net" 里放表里第一组 —— OTA 回滚到老固件照样连得上网。 */ +#define NVS_KEY_V1 "net" +#define NVS_VERSION_V1 1 + +typedef struct { /* ≤0.1.8 的 netcfg_t,一个字节都不能动 */ + char ssid[NETCFG_SSID_MAX + 1]; + char pass[NETCFG_PASS_MAX + 1]; + char uri[NETCFG_URI_MAX + 1]; + char user[NETCFG_USER_MAX + 1]; + char mpass[NETCFG_MPASS_MAX + 1]; + uint32_t tele_ms; +} netcfg_v1_t; + +typedef struct { + uint8_t ver; + netcfg_v1_t cfg; +} nvs_blob_v1_t; /* evse-ca(home 服务器上那个 mosquitto 的自签 CA,公钥证书,不是机密)。 `main/CMakeLists.txt` 的 EMBED_TXTFILES 编进来,结尾带 0。只在 mqtts:// 时用。 */ @@ -63,6 +83,8 @@ static SemaphoreHandle_t s_mtx; /* 护 s_st 与 s_mqtt 句柄 */ static net_status_t s_st; static esp_mqtt_client_handle_t s_mqtt; static netcfg_t s_cur; /* 当前生效的配置(只在 t_link 里读写) */ +static volatile uint8_t s_idx; /* 正在连 / 已连上的是 s_cur.wifi 第几组 */ +static volatile bool s_leaving; /* wifi_apply 主动断开了一条好好的连接:那次断开事件不算失败 */ static volatile bool s_dirty; /* 配置变了,等 t_link 来重配 */ static esp_timer_handle_t s_retry; static uint32_t s_attempt; @@ -106,8 +128,22 @@ static hal_err_t nvs_save(void *ctx, const netcfg_t *cfg) if (nvs_open(NVS_NS, NVS_READWRITE, &h) != ESP_OK) { return HAL_ERR_BUS; } - const nvs_blob_t b = { .ver = NVS_VERSION, .cfg = *cfg }; - esp_err_t e = nvs_set_blob(h, NVS_KEY, &b, sizeof(b)); + static nvs_blob_t b; /* ≈560 B,不放栈上(只有执行器任务进来) */ + static nvs_blob_v1_t v1; + b.ver = NVS_VERSION; + b.cfg = *cfg; + memset(&v1, 0, sizeof(v1)); + v1.ver = NVS_VERSION_V1; + memcpy(v1.cfg.ssid, cfg->wifi[0].ssid, sizeof(v1.cfg.ssid)); + memcpy(v1.cfg.pass, cfg->wifi[0].pass, sizeof(v1.cfg.pass)); + memcpy(v1.cfg.uri, cfg->uri, sizeof(v1.cfg.uri)); + memcpy(v1.cfg.user, cfg->user, sizeof(v1.cfg.user)); + memcpy(v1.cfg.mpass, cfg->mpass, sizeof(v1.cfg.mpass)); + v1.cfg.tele_ms = cfg->tele_ms; + esp_err_t e = nvs_set_blob(h, NVS_KEY, &b, sizeof(b)); + if (e == ESP_OK) { + e = nvs_set_blob(h, NVS_KEY_V1, &v1, sizeof(v1)); + } if (e == ESP_OK) { e = nvs_commit(h); } @@ -121,14 +157,29 @@ static bool nvs_load(netcfg_t *out) if (nvs_open(NVS_NS, NVS_READONLY, &h) != ESP_OK) { return false; } - nvs_blob_t b; - size_t n = sizeof(b); - const bool ok = nvs_get_blob(h, NVS_KEY, &b, &n) == ESP_OK && n == sizeof(b) && - b.ver == NVS_VERSION; - nvs_close(h); + static nvs_blob_t b; /* ≈560 B,不放栈上(只在启动时进来一次) */ + size_t n = sizeof(b); + bool ok = nvs_get_blob(h, NVS_KEY, &b, &n) == ESP_OK && n == sizeof(b) && + b.ver == NVS_VERSION; if (ok) { *out = b.cfg; + } else { + static nvs_blob_v1_t v1; + n = sizeof(v1); + ok = nvs_get_blob(h, NVS_KEY_V1, &v1, &n) == ESP_OK && n == sizeof(v1) && + v1.ver == NVS_VERSION_V1; + if (ok) { + memset(out, 0, sizeof(*out)); + memcpy(out->wifi[0].ssid, v1.cfg.ssid, sizeof(v1.cfg.ssid)); + memcpy(out->wifi[0].pass, v1.cfg.pass, sizeof(v1.cfg.pass)); + memcpy(out->uri, v1.cfg.uri, sizeof(v1.cfg.uri)); + memcpy(out->user, v1.cfg.user, sizeof(v1.cfg.user)); + memcpy(out->mpass, v1.cfg.mpass, sizeof(v1.cfg.mpass)); + out->tele_ms = v1.cfg.tele_ms; + ESP_LOGI(TAG, "旧版网络配置(一组 WiFi)已迁移;下次改配置时写成新版"); + } } + nvs_close(h); return ok; } @@ -280,10 +331,25 @@ static void mqtt_stop(void) /* ---- WiFi 事件(系统事件循环) ------------------------------------------- */ +/* 把 STA 配成表里第 idx 组(idx 必须 < count)。 */ +static void wifi_set_sta(uint8_t idx) +{ + const netcfg_wifi_t *w = &s_cur.wifi[idx]; + wifi_config_t wc = { 0 }; + memcpy(wc.sta.ssid, w->ssid, strlen(w->ssid)); + memcpy(wc.sta.password, w->pass, strlen(w->pass)); + wc.sta.threshold.authmode = w->pass[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN; + esp_wifi_set_config(WIFI_IF_STA, &wc); + ST_EDIT(s_st.wifi_idx = idx); +} + static void retry_cb(void *arg) { (void)arg; - if (s_st.wifi != NET_UP && s_cur.ssid[0] != '\0') { + const int n = netcfg_wifi_count(&s_cur); + if (s_st.wifi != NET_UP && n > 0) { + const uint8_t i = s_idx < n ? s_idx : 0; + wifi_set_sta(i); esp_wifi_connect(); } } @@ -293,13 +359,24 @@ static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data) (void)arg; if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { const wifi_event_sta_disconnected_t *d = data; - ESP_LOGW(TAG, "WiFi 断开,原因 %u,%u ms 后重试", (unsigned)d->reason, - (unsigned)mlink_backoff_ms(s_attempt)); - ST_EDIT(s_st.wifi_reason = d->reason;if (s_st.wifi == NET_UP) { s_st.wifi_drops++; } s_st.wifi = - s_cur.ssid[0] ? NET_CONNECTING : NET_OFF; + const int n = netcfg_wifi_count(&s_cur); + const bool was_up = s_st.wifi == NET_UP; + ST_EDIT(s_st.wifi_reason = d->reason; if (was_up) { s_st.wifi_drops++; } s_st.wifi = + n > 0 ? NET_CONNECTING : NET_OFF; s_st.ip[0] = '\0'; s_st.rssi = 0); - if (s_cur.ssid[0] != '\0') { - const uint32_t ms = mlink_backoff_ms(s_attempt++); + if (s_leaving) { + s_leaving = false; /* wifi_apply 自己断的,它紧接着就去连了 */ + return; + } + if (n > 0) { + /* 连上过又掉的:先重试同一组;没连上的:换下一组。退避按「轮完一整圈」算 —— + 几组全连不上也是 1 s、2 s、4 s …… 地拉长,而不是每组各退各的。 */ + if (!was_up && n > 1) { + s_idx = (uint8_t)((s_idx + 1) % n); + } + const uint32_t ms = mlink_backoff_ms(s_attempt++ / (uint32_t)n); + ESP_LOGW(TAG, "WiFi 断开,原因 %u,%u ms 后连「%s」", (unsigned)d->reason, (unsigned)ms, + s_cur.wifi[s_idx < n ? s_idx : 0].ssid); esp_timer_stop(s_retry); esp_timer_start_once(s_retry, (uint64_t)ms * 1000u); } @@ -308,7 +385,7 @@ static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data) s_attempt = 0; ST_EDIT(s_st.wifi = NET_UP; snprintf(s_st.ip, sizeof(s_st.ip), IPSTR, IP2STR(&e->ip_info.ip))); - ESP_LOGI(TAG, "WiFi 已连上,IP %s", s_st.ip); + ESP_LOGI(TAG, "WiFi「%s」已连上,IP %s", s_cur.wifi[s_idx].ssid, s_st.ip); } } @@ -316,20 +393,22 @@ static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data) static void wifi_apply(void) { esp_timer_stop(s_retry); + s_leaving = s_st.wifi == NET_UP; esp_wifi_disconnect(); s_attempt = 0; - if (s_cur.ssid[0] == '\0') { + const int n = netcfg_wifi_count(&s_cur); + if (n == 0) { + s_leaving = false; ST_EDIT(s_st.wifi = NET_OFF); return; } - wifi_config_t wc = { 0 }; - memcpy(wc.sta.ssid, s_cur.ssid, strlen(s_cur.ssid)); - memcpy(wc.sta.password, s_cur.pass, strlen(s_cur.pass)); - wc.sta.threshold.authmode = s_cur.pass[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN; - esp_wifi_set_config(WIFI_IF_STA, &wc); + if (s_idx >= n) { + s_idx = 0; + } + wifi_set_sta(s_idx); ST_EDIT(s_st.wifi = NET_CONNECTING); esp_wifi_connect(); - ESP_LOGI(TAG, "连 WiFi「%s」", s_cur.ssid); /* 只打 SSID,不打口令 */ + ESP_LOGI(TAG, "连 WiFi「%s」(%u/%d)", s_cur.wifi[s_idx].ssid, (unsigned)s_idx + 1, n); /* 只打 SSID,不打口令 */ } /* ---- 对外 ---------------------------------------------------------------- */ @@ -391,9 +470,24 @@ void mlink_esp_poll(void) } if (s_dirty) { s_dirty = false; - mqtt_stop(); - netcfg_get(&s_cur); - wifi_apply(); + /* 加 / 删的不是正连着的那一组、口令也没变:WiFi 不断;broker 也没变:MQTT 也不断。 + 经 USB 往表里加一组备用热点,不该让板子掉线一次。 */ + static netcfg_t next; /* ≈560 B,不放栈上(只有 t_link 进来) */ + netcfg_get(&next); + const int j = (s_st.wifi == NET_UP) ? netcfg_wifi_find(&next, s_cur.wifi[s_idx].ssid) : -1; + const bool wifi_same = j >= 0 && strcmp(next.wifi[j].pass, s_cur.wifi[s_idx].pass) == 0; + const bool mqtt_same = strcmp(next.uri, s_cur.uri) == 0 && strcmp(next.user, s_cur.user) == 0 && + strcmp(next.mpass, s_cur.mpass) == 0; + if (!(wifi_same && mqtt_same)) { + mqtt_stop(); + } + s_cur = next; + if (wifi_same) { + s_idx = (uint8_t)j; + ST_EDIT(s_st.wifi_idx = s_idx); + } else { + wifi_apply(); + } } /* MQTT 的生命周期只在这里管:WiFi 有地址、配了 broker、还没建客户端 ⇒ 建。 之后掉线重连由 esp-mqtt 自己做(network.reconnect_timeout_ms,默认 10 s)。 */ diff --git a/firmware/main/netcfg.c b/firmware/main/netcfg.c index 401634d..0c8b3b9 100644 --- a/firmware/main/netcfg.c +++ b/firmware/main/netcfg.c @@ -18,9 +18,20 @@ void netcfg_init(const netcfg_t *loaded, netcfg_save_fn save, netcfg_changed_fn if (loaded != NULL) { s_cfg = *loaded; } - /* 从 flash 读回来的东西不可信:强制收尾,免得一个没带 0 的字段把后面的读穿。 */ - s_cfg.ssid[NETCFG_SSID_MAX] = '\0'; - s_cfg.pass[NETCFG_PASS_MAX] = '\0'; + /* 从 flash 读回来的东西不可信:强制收尾,免得一个没带 0 的字段把后面的读穿; + 空位挤到末尾(「有效的就是 [0, n)」是 count / 轮换的前提)。 */ + int n = 0; + for (int i = 0; i < NETCFG_WIFI_N; i++) { + netcfg_wifi_t w = s_cfg.wifi[i]; + w.ssid[NETCFG_SSID_MAX] = '\0'; + w.pass[NETCFG_PASS_MAX] = '\0'; + if (w.ssid[0] != '\0') { + s_cfg.wifi[n++] = w; + } + } + for (int i = n; i < NETCFG_WIFI_N; i++) { + memset(&s_cfg.wifi[i], 0, sizeof(s_cfg.wifi[i])); + } s_cfg.uri[NETCFG_URI_MAX] = '\0'; s_cfg.user[NETCFG_USER_MAX] = '\0'; s_cfg.mpass[NETCFG_MPASS_MAX] = '\0'; @@ -90,6 +101,25 @@ static hal_err_t commit(const netcfg_t *next) return HAL_OK; } +int netcfg_wifi_count(const netcfg_t *cfg) +{ + int n = 0; + while (n < NETCFG_WIFI_N && cfg->wifi[n].ssid[0] != '\0') { + n++; + } + return n; +} + +int netcfg_wifi_find(const netcfg_t *cfg, const char *ssid) +{ + for (int i = 0; i < netcfg_wifi_count(cfg); i++) { + if (strcmp(cfg->wifi[i].ssid, ssid) == 0) { + return i; + } + } + return -1; +} + hal_err_t netcfg_set_wifi(const char *ssid, const char *pass) { if (!netcfg_wifi_ok(ssid, pass)) { @@ -97,10 +127,30 @@ hal_err_t netcfg_set_wifi(const char *ssid, const char *pass) } netcfg_t next; netcfg_get(&next); - memset(next.ssid, 0, sizeof(next.ssid)); - memset(next.pass, 0, sizeof(next.pass)); - strcpy(next.ssid, ssid); - strcpy(next.pass, pass); + int i = netcfg_wifi_find(&next, ssid); + if (i < 0) { + i = netcfg_wifi_count(&next); + if (i >= NETCFG_WIFI_N) { + return HAL_ERR_STATE; /* 满了:先删一组。不悄悄挤掉最老的 —— 那可能正是唯一连得上的 */ + } + } + memset(&next.wifi[i], 0, sizeof(next.wifi[i])); + strcpy(next.wifi[i].ssid, ssid); + strcpy(next.wifi[i].pass, pass); + return commit(&next); +} + +hal_err_t netcfg_del_wifi(const char *ssid) +{ + netcfg_t next; + netcfg_get(&next); + const int i = ssid != NULL ? netcfg_wifi_find(&next, ssid) : -1; + if (i < 0) { + return HAL_ERR_ARG; + } + const int n = netcfg_wifi_count(&next); + memmove(&next.wifi[i], &next.wifi[i + 1], (size_t)(n - 1 - i) * sizeof(next.wifi[0])); + memset(&next.wifi[n - 1], 0, sizeof(next.wifi[0])); return commit(&next); } diff --git a/firmware/main/netcfg.h b/firmware/main/netcfg.h index d22230e..b11a6e6 100644 --- a/firmware/main/netcfg.h +++ b/firmware/main/netcfg.h @@ -2,7 +2,8 @@ IF-19 原话:「凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时先用 IF-13 的 USB 链路配网」。所以: - · 配置由两条命令写入:`wifi`(SSID / 密码)与 `mqtt`(broker 地址 / 账号 / 遥测周期), + · 配置由两条命令写入:`wifi`(SSID / 密码,**最多存 NETCFG_WIFI_N 组**,0.1.9)与 + `mqtt`(broker 地址 / 账号 / 遥测周期), **只收 USB 链路来的**(cmd.c 回 `link`)—— 经 WiFi 改 WiFi 配置,一次手误就把 唯一的远程通道弄断了,而那时只能拆机插 USB; · 本模块只管「校验 + 在内存里持有一份 + 调存储后端 + 通知网络那一侧」, @@ -14,8 +15,13 @@ ---- 跨任务 -------------------------------------------------------------- 写者是执行器任务(命令处理器),读者是链路任务(网络胶水)。配置读写都在 - `hal_crit` 里整块拷贝(≈260 B 的 memcpy,关中断几微秒),读者永远拿到一份完整的。 - 网络状态反过来:链路任务写、命令处理器读,同样整块拷贝。 */ + `hal_crit` 里整块拷贝(≈560 B 的 memcpy,关中断十几微秒),读者永远拿到一份完整的。 + 网络状态反过来:链路任务写、命令处理器读,同样整块拷贝。 + + ---- 多组 WiFi(0.1.9,2026-09-28 用户要求)------------------------------- + `wifi` 命令 = **加一组或改同名那组的口令**,`"del":true` = 删同名那组;满了回 `state`。 + 连不上就按表里的顺序轮着换下一组(mlink_esp.c)。表里的空位(ssid 为空)一律跳过, + `netcfg_init` 会把它们挤到末尾。 */ #ifndef NETCFG_H #define NETCFG_H @@ -40,10 +46,16 @@ extern "C" { #define NETCFG_TELE_MIN_MS 200 #define NETCFG_TELE_MAX_MS 60000 +#define NETCFG_WIFI_N 4 /* 存几组 WiFi */ + +typedef struct { + char ssid[NETCFG_SSID_MAX + 1]; /* 空 = 空位 */ + char pass[NETCFG_PASS_MAX + 1]; +} netcfg_wifi_t; + /* ⚠️ 这个结构体**原样存进 NVS**(mlink_esp.c 带版本号存)。改字段就升版本号。 */ typedef struct { - char ssid[NETCFG_SSID_MAX + 1]; - char pass[NETCFG_PASS_MAX + 1]; + netcfg_wifi_t wifi[NETCFG_WIFI_N]; /* 按加入的顺序,空位都在末尾 */ char uri[NETCFG_URI_MAX + 1]; char user[NETCFG_USER_MAX + 1]; char mpass[NETCFG_MPASS_MAX + 1]; @@ -61,10 +73,18 @@ void netcfg_init(const netcfg_t *loaded, netcfg_save_fn save, netcfg_changed_fn void netcfg_get(netcfg_t *out); /* 校验 → 存 → 改内存 → 通知。校验不过 HAL_ERR_ARG,存不下是 save 的错误码。 + `set_wifi`:同名的改口令,否则加到末尾;表满了 HAL_ERR_STATE。 + `del_wifi`:没有同名的 HAL_ERR_ARG。 `mqtt` 的 tele_ms 传 0 = 用默认值。 */ hal_err_t netcfg_set_wifi(const char *ssid, const char *pass); +hal_err_t netcfg_del_wifi(const char *ssid); hal_err_t netcfg_set_mqtt(const char *uri, const char *user, const char *mpass, uint32_t tele_ms); +/* 表里有几组(空位在末尾,所以有效的就是 [0, n))。 */ +int netcfg_wifi_count(const netcfg_t *cfg); +/* 名为 ssid 的那组在第几个,没有 −1。 */ +int netcfg_wifi_find(const netcfg_t *cfg, const char *ssid); + /* 纯校验,给命令处理器先判一遍好回准确的错误。 */ bool netcfg_wifi_ok(const char *ssid, const char *pass); bool netcfg_mqtt_ok(const char *uri, const char *user, const char *mpass, uint32_t tele_ms); @@ -88,6 +108,7 @@ typedef struct { uint32_t tele_skipped; /* 该发遥测时命令队列满 / 上一条还没发完 */ uint8_t wifi_reason; /* 最近一次断开的原因码(wifi_err_reason_t;201 = 找不到 AP, 202 = 认证失败,15 = 四次握手超时 ≈ 口令错),0 = 没断过 */ + uint8_t wifi_idx; /* 正在连 / 已连上的是表里第几组(net 回应的 ssid 取它) */ char id[13]; /* 板子 ID = STA MAC 的 12 位小写十六进制,topic 用它 */ } net_status_t; diff --git a/firmware/main/ota_esp.c b/firmware/main/ota_esp.c index 1b040d2..a15d2c5 100644 --- a/firmware/main/ota_esp.c +++ b/firmware/main/ota_esp.c @@ -339,7 +339,7 @@ void ota_esp_poll(void) .pending = true, .now_ms = now, .boot_done = s_boot_done, - .wifi_set = cfg.ssid[0] != '\0', + .wifi_set = netcfg_wifi_count(&cfg) > 0, .wifi_up = ns.wifi == NET_UP, .mqtt_set = cfg.uri[0] != '\0', .mqtt_up = ns.mqtt == NET_UP, diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h index 89cdc28..e147b60 100644 --- a/firmware/main/phonerail.h +++ b/firmware/main/phonerail.h @@ -3,7 +3,7 @@ #define PHONERAIL_H /* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ -#define PHONERAIL_FW_VERSION "0.1.4-c5" +#define PHONERAIL_FW_VERSION "0.1.13-c5" /* 通道数。五路直供,见设计文档 §2。 */ #define PHONERAIL_RAIL_COUNT 5 diff --git a/firmware/main/slot.c b/firmware/main/slot.c index 5c38b4c..b96022e 100644 --- a/firmware/main/slot.c +++ b/firmware/main/slot.c @@ -107,6 +107,14 @@ hal_err_t slot_init(int rail) return i2cbus_xact(slot_mux_ch(rail), SLOT_LOCK_TIMEOUT_MS, do_init, &r); } +hal_err_t slot_init_locked(int rail) +{ + if (!rail_valid(rail)) { + return HAL_ERR_ARG; + } + return pca9555_init(rail - 1); +} + hal_err_t slot_init_keep_vbus(int rail) { if (!rail_valid(rail)) { diff --git a/firmware/main/slot.h b/firmware/main/slot.h index edf1205..6ec42f0 100644 --- a/firmware/main/slot.h +++ b/firmware/main/slot.h @@ -66,6 +66,10 @@ hal_err_t slot_init(int rail); 这片掉过电(方向寄存器不是我们写的)就不留,等同 slot_init()(pca9555_init_keep)。 */ hal_err_t slot_init_keep_vbus(int rail); +/* 已持锁版本的 slot_init():给 `t_safety` 在热插拔进来的刀片上用(0.1.10)—— + 它本来就在这一路的 `xact` 里。 */ +hal_err_t slot_init_locked(int rail); + /* ---- VBUS(PCA9555 P0.4 → TPS22918) ------------------------------------- */ hal_err_t slot_vbus_enable(int rail, bool on); diff --git a/firmware/main/t_actuator.c b/firmware/main/t_actuator.c index 3ae279b..707e494 100644 --- a/firmware/main/t_actuator.c +++ b/firmware/main/t_actuator.c @@ -24,6 +24,9 @@ typedef struct { uint8_t good_n; /* HR_VERIFY:连续几个样本电流达标 */ bool ua_seen; /* HR_VERIFY:这一轮窗口里读到过电流没有 */ int32_t last_ua; /* HR_VERIFY:最近一个读到的样本 */ + bool vbus_back; /* HR:开始时断了 VBUS,开机校验通过后要接回(0.1.11) */ + bool vbus_pending; /* 已开机,等到 vbus_at 再接回 VBUS(ACT_HR_VBUS_BACK_MS) */ + uint32_t vbus_at; } act_rail_t; static act_rail_t s_rail[PHONERAIL_RAIL_COUNT]; @@ -118,6 +121,7 @@ static void boot_report(act_rail_t *r, int rail, bool booted) 按下去过东西就必须经 CLEANUP,那是「键一定会松开」的唯一保证。 */ static void finish(act_rail_t *r, act_result_t res, uint32_t now) { + r->vbus_back = false; /* 只有 hr_ok() 会接回 VBUS;别的结局一律让它断着 */ r->result = res; r->done++; if (r->release_mask != 0) { @@ -128,6 +132,39 @@ static void finish(act_rail_t *r, act_result_t res, uint32_t now) } } +/* HR 成功收尾:开始时断过 VBUS 的,排一个 ACT_HR_VBUS_BACK_MS 之后的「接回」 + (t_actuator_step 里做,那时执行器已空闲)。 */ +static void hr_ok(act_rail_t *r, int rail, uint32_t now) +{ + (void)rail; + if (r->vbus_back) { + r->vbus_pending = true; + r->vbus_at = now + ACT_HR_VBUS_BACK_MS; + } + finish(r, ACT_RES_OK, now); +} + +/* 到点了就接回 VBUS。返回下次最晚多久再来。 + 这一路被关了电(安全判据 / 拔刀片)就作废;写失败下一拍再试(影子如实留在 off)。 */ +static uint32_t vbus_back_step(act_rail_t *r, int rail, uint32_t now) +{ + if (!r->vbus_pending || r->state != ACT_ST_IDLE) { + return ACT_IDLE_WAIT_MS; + } + const int32_t left = (int32_t)(r->vbus_at - now); + if (left > 0) { + return (uint32_t)left; + } + if (!rail_efuse_is_on(rail)) { + r->vbus_pending = false; + return ACT_IDLE_WAIT_MS; + } + if (slot_vbus_enable(rail, true) == HAL_OK) { + r->vbus_pending = false; + } + return ACT_IDLE_WAIT_MS; +} + /* ---- 状态机本体:一个状态一段,返回「还要多久再来」 ---------------------- */ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now) @@ -181,7 +218,7 @@ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now) return r->hold_ms - el; } if (s_ua == NULL) { - finish(r, ACT_RES_OK, now); /* 不校验(0.1.1 行为):CLEANUP 松键 */ + hr_ok(r, rail, now); /* 不校验(0.1.1 行为):CLEANUP 松键 */ return ACT_CLEANUP_RETRY_MS; } /* 要校验:先在这里松键(松不掉就走 CLEANUP 的「不放弃」,不再校验)。 */ @@ -206,7 +243,7 @@ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now) r->good_n = ua >= ACT_HR_BOOT_UA_MIN ? (uint8_t)(r->good_n + 1) : 0; if (r->good_n >= ACT_HR_VERIFY_GOOD_N) { boot_report(r, rail, true); - finish(r, ACT_RES_OK, now); + hr_ok(r, rail, now); return ACT_IDLE_WAIT_MS; } } @@ -216,7 +253,7 @@ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now) } if (!r->ua_seen) { /* 整个窗口都读不到电流:判不了,**不重按**(ACT_HR_BOOT_TRIES 那段注释)。 */ - finish(r, ACT_RES_OK, now); + hr_ok(r, rail, now); return ACT_IDLE_WAIT_MS; } if (r->presses < ACT_HR_BOOT_TRIES) { @@ -330,8 +367,25 @@ hal_err_t t_actuator_hard_reset(int rail) (void)out_write(rail, km, 0); /* 失败也照断电:断电永远比不断电安全 */ } + /* 0.1.11(2026-09-28 板 B):VBUS 通着就**先断**,开机校验通过后再接回(hr_ok)。 + 不断的三个后果,现场全见过: + · 手机靠 USB 撑着,根本没断电 —— 硬重启是假的; + · 手机吃 USB 的电,直供电流 ≈ 0 ⇒ 开机校验判「没开起来」、再按两下电源键; + · 断电那一刻 USB 还在 ⇒ 被当成「插充电器开机」,进关机充电模式,一拔 USB 就关机。 + 断不掉也照样断电(断电永远比不断电安全),只是事后不接回。 */ + r->vbus_back = false; + r->vbus_pending = false; /* 上一次硬重启排的接回作废 */ + const bool had_vbus = slot_vbus_is_on(rail); + if (had_vbus && slot_vbus_enable(rail, false) == HAL_OK) { + r->vbus_back = true; + } + const hal_err_t err = rail_efuse_enable(rail, false); if (err != HAL_OK) { + if (r->vbus_back) { + (void)slot_vbus_enable(rail, true); /* 序列没开始:恢复原样 */ + r->vbus_back = false; + } return err; /* 序列没开始 */ } r->seq = ACT_SEQ_HARD_RESET; @@ -396,6 +450,7 @@ hal_err_t t_actuator_edl(int rail) return HAL_ERR_STATE; } + r->vbus_pending = false; /* EDL 自己管 VBUS */ const uint8_t mask = (uint8_t)(1u << PCA9555_BIT_EDL_EN); const hal_err_t err = out_write(rail, mask, mask); @@ -417,6 +472,7 @@ void t_actuator_abort(int rail) return; } act_rail_t *r = at(rail); + r->vbus_pending = false; /* 被打断的路(多半是安全判据关了电)不再接回 VBUS */ if (r->state == ACT_ST_IDLE || r->state == ACT_ST_CLEANUP) { return; /* CLEANUP 已经在收尾了,别把它的结果覆盖掉 */ } @@ -424,6 +480,18 @@ void t_actuator_abort(int rail) (void)rail_step(rail, hal_time_now_ms()); /* 立刻试一次收尾,别等下一拍 */ } +void t_actuator_cancel_vbus_back(int rail) +{ + if (s_inited && rail_valid(rail)) { + at(rail)->vbus_pending = false; + } +} + +bool t_actuator_vbus_back_pending(int rail) +{ + return s_inited && rail_valid(rail) && at(rail)->vbus_pending; +} + uint32_t t_actuator_step(void) { if (!s_inited) { @@ -433,7 +501,11 @@ uint32_t t_actuator_step(void) uint32_t wait = ACT_IDLE_WAIT_MS; for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { - const uint32_t w = rail_step(rail, now); + uint32_t w = rail_step(rail, now); + const uint32_t v = vbus_back_step(at(rail), rail, now); + if (v < w) { + w = v; + } if (w < wait) { wait = w; } diff --git a/firmware/main/t_actuator.h b/firmware/main/t_actuator.h index 8268644..25dbf44 100644 --- a/firmware/main/t_actuator.h +++ b/firmware/main/t_actuator.h @@ -135,6 +135,13 @@ extern "C" { /* CLEANUP 重试间隔,以及空闲时建议任务睡多久(A12 会换成「带超时地等队列」)。 */ #define ACT_CLEANUP_RETRY_MS 5 + +/* 0.1.11:硬重启开始时断掉的 VBUS,开机校验通过后**再等这么久**才接回(执行器此时已空闲, + 不挡别的命令)。校验通过时手机才起来几秒,还在 bootloader / 内核早期 —— 关充电的内核补丁 + 没生效,这时接回 USB,充电器立刻把节点顶到 4.4 V、SW-8 当场断 VBUS(2026-09-28 板 B + 实测:硬重启后 13 s「顶高断 VBUS」4394 mV)。60 s 时 Android 内核早已 probe 完充电驱动。 + 没刷补丁的手机接回后照样会被 SW-8 断掉 —— 那是对的。 */ +#define ACT_HR_VBUS_BACK_MS 60000 #define ACT_IDLE_WAIT_MS 100 /* ---- 按键:逻辑键位,不是器件上的位号 ----------------------------------- @@ -235,6 +242,12 @@ hal_err_t t_actuator_edl(int rail); 或者再开一次都是自作主张;断电是 A10 的判据和上位机命令的事。 */ void t_actuator_abort(int rail); +/* 取消「过一会儿把 VBUS 接回去」(ACT_HR_VBUS_BACK_MS)。有人手动动了这一路的 + VBUS / 电(`vbus` / `power` 命令)就该调:人的意图优先,别 60 s 后又被改回去。 + 硬重启 / EDL / abort 自己会取消。 */ +void t_actuator_cancel_vbus_back(int rail); +bool t_actuator_vbus_back_pending(int rail); + /* 推一步。返回**下次最晚多久再来**(ms):五路里最近的那个截止时刻。 全空闲返回 ACT_IDLE_WAIT_MS。醒早了没关系 —— 每个状态都是拿绝对时刻算的, 早来就再返回一个剩余时间。(ESP-IDF 默认 `CONFIG_FREERTOS_HZ=100`, diff --git a/firmware/main/t_safety.c b/firmware/main/t_safety.c index 945b2c8..e797885 100644 --- a/firmware/main/t_safety.c +++ b/firmware/main/t_safety.c @@ -1,457 +1,498 @@ -#include "t_safety.h" - -#include -#include -#include - -#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 "刀片不在位"; - case SAFETY_EV_BLADE_MUTE: - 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 / −250 mV 连着 T_SAFETY_DEV_HOLD_CYCLES 圈(= 持续 20 ms)---- */ - if (dev > T_SAFETY_DEV_MV || dev < -T_SAFETY_UNDER_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; - } - } - - /* 整片刀片不应答(T_SAFETY_BLADE_MUTE_N 的理由)。**不走 `trip_rail_locked()`**: - 它要先写这片 PCA9555 断 VBUS,而这片刚刚 NAK 过 —— 再发一次只是白等十几 ms 的 - IDF NAK 超时、把本圈预算吃光(规矩 4)。只关 eFuse(锁外,GPIO 直连,永远关得掉)。 - 判据本身不发任何事务:两个 ok 位是上面刚读出来的。 */ - safety_rail_t *st = &s->st[rail - 1]; - if (rail_efuse_is_on(rail) && !s->rail_ok[rail - 1] && !s->io_ok[rail - 1]) { - if (st->mute_n < 0xFF) { - st->mute_n++; - } - if (st->mute_n >= T_SAFETY_BLADE_MUTE_N) { - st->pend_abort = true; - st->pend_efuse = true; - st->pend_ev = (uint8_t)SAFETY_EV_BLADE_MUTE; - /* 同 SR-5:不清零。关掉之后下一圈 en 为 false 自然清;写引脚失败的话下一圈立刻再关。 */ - } - } else { - st->mute_n = 0; - } - - /* 判据就在这里跑:断 VBUS 要在本路通道选好、锁还持着的此刻(规矩 3)。 - rail_ok 为 false 时它第一道闸就返回,不会盖掉上面攒下的 BLADE_MUTE。 */ - 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; - - /* ⛔ 空槽**不走总线**(2026-09-27 上板实测)。原来五路无条件各读一次:空槽的 INA226 / - PCA9555 必然 NAK,而 IDF 驱动每次 NAK 要十几 ms(含它自己的错误日志), - 槽 1 那两次就把 10 ms 整圈预算吃光 ⇒ 槽 2~5 **和主干上的输入侧 0x43** 一圈都没读到, - 现象是 `txns=2`、每圈 skipped、「最迟 57 ms」—— 空机时输入侧的监控整个是瞎的。 - 在位脚是纯 GPIO,先 poll 再按它挑。`check_present()` 在后面照旧再 poll 一次、照旧负责断电。 */ - slot_prsnt_poll(); - - /* 五路,一路一次 `xact`(通道查表,**不许算** —— rail_cfg.h 那条禁令)。 */ - for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { - if (!slot_present(rail)) { - s->rail_ok[rail - 1] = false; /* 没读 ≠ 读到了:别让上一圈的旧值冒充新鲜 */ - s->io_ok[rail - 1] = false; - continue; - } - 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); -} +#include "t_safety.h" + +#include +#include +#include + +#include "chgmode.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 "刀片不在位"; + case SAFETY_EV_BLADE_MUTE: + 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); + + /* 刷机模式(chgmode.h,0.1.5):窗口内放开「顶高断 VBUS」;窗口内 + 宽限期内, + 「过高」按 CHG_MODE_MAX_MV 绝对值判(手机充电器浮充 ~4.4 V 是预期的,超过才是异常)。 + 反灌(电流 < −300 mA)与欠压判据不受影响。 */ + const bool thru = chgmode_active(rail, now); + const bool high = chgmode_relaxed(rail, now) ? (mv > CHG_MODE_MAX_MV) : (dev > T_SAFETY_DEV_MV); + + /* ---- SW-8(先跑):只在 VBUS 通着的时候武装 ------------------------- + 反灌的能量只可能从 VBUS 那一侧来,断开之后判据自然就解除武装了。 + 先跑它是因为过压那种情况两条判据都成立:该先撤掉能量来源(VBUS), + 20 ms 后 SR-5 再决定要不要连这一路一起关掉。 + 刷机模式窗口内「顶高」同样按绝对值判(`high`);窗口一过(宽限期里)回到按设定值判 —— + 那一刻手机还在充,节点在 4.4 V,就在这里立刻断 VBUS,而 SR-5 仍按绝对值判、不会误关路。 */ + if (slot_vbus_is_on(rail)) { + if (ua < T_SAFETY_REV_UA) { + cut_vbus_locked(s, rail, SAFETY_EV_REVERSE); + } else if (thru ? high : (dev > T_SAFETY_DEV_MV)) { + cut_vbus_locked(s, rail, SAFETY_EV_PUSHED_UP); + } + } + + /* ---- SR-5:+100 / −250 mV 连着 T_SAFETY_DEV_HOLD_CYCLES 圈(= 持续 20 ms)---- */ + if (high || dev < -T_SAFETY_UNDER_MV) { + if (st->dev_n < 0xFF) { + st->dev_n++; + } + if (st->dev_n >= T_SAFETY_DEV_HOLD_CYCLES) { + trip_rail_locked(s, rail, high ? 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; + + /* 热插拔进来的刀片:先把它初始化成开机时的样子,再读、再判(T_SAFETY_INSERT_N 的理由)。 */ + safety_rail_t *ist = &s->st[rail - 1]; + if (ist->need_init && ++ist->ins_n >= T_SAFETY_INSERT_N) { + ist->ins_n = 0; + hal_err_t ie = slot_init_locked(rail); + if (ie == HAL_OK) { + ie = ina226_init(rail_cfg(rail)->ina_addr); + } + if (ie == HAL_OK) { + ist->need_init = false; + s->blade_inits++; + } else { + s->i2c_errors++; + first = ie; + } + } + + 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; + } + } + + /* 整片刀片不应答(T_SAFETY_BLADE_MUTE_N 的理由)。**不走 `trip_rail_locked()`**: + 它要先写这片 PCA9555 断 VBUS,而这片刚刚 NAK 过 —— 再发一次只是白等十几 ms 的 + IDF NAK 超时、把本圈预算吃光(规矩 4)。只关 eFuse(锁外,GPIO 直连,永远关得掉)。 + 判据本身不发任何事务:两个 ok 位是上面刚读出来的。 */ + safety_rail_t *st = &s->st[rail - 1]; + if (rail_efuse_is_on(rail) && !s->rail_ok[rail - 1] && !s->io_ok[rail - 1]) { + if (st->mute_n < 0xFF) { + st->mute_n++; + } + if (st->mute_n >= T_SAFETY_BLADE_MUTE_N) { + st->pend_abort = true; + st->pend_efuse = true; + st->pend_ev = (uint8_t)SAFETY_EV_BLADE_MUTE; + /* 同 SR-5:不清零。关掉之后下一圈 en 为 false 自然清;写引脚失败的话下一圈立刻再关。 */ + } + } else { + st->mute_n = 0; + } + + /* 判据就在这里跑:断 VBUS 要在本路通道选好、锁还持着的此刻(规矩 3)。 + rail_ok 为 false 时它第一道闸就返回,不会盖掉上面攒下的 BLADE_MUTE。 */ + 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->st[i].was_present = true; /* 开机时在位的刀片 bringup 已经初始化过,不算「插进来」 */ + } + /* 游标对齐到「现在」,否则第一圈会把启动期间(器件初始化)的事务算进来。 */ + 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; + + /* ⛔ 空槽**不走总线**(2026-09-27 上板实测)。原来五路无条件各读一次:空槽的 INA226 / + PCA9555 必然 NAK,而 IDF 驱动每次 NAK 要十几 ms(含它自己的错误日志), + 槽 1 那两次就把 10 ms 整圈预算吃光 ⇒ 槽 2~5 **和主干上的输入侧 0x43** 一圈都没读到, + 现象是 `txns=2`、每圈 skipped、「最迟 57 ms」—— 空机时输入侧的监控整个是瞎的。 + 在位脚是纯 GPIO,先 poll 再按它挑。`check_present()` 在后面照旧再 poll 一次、照旧负责断电。 */ + slot_prsnt_poll(); + + /* 认「插进来」:上一圈不在、这一圈在 ⇒ 这片要重新初始化(在 cycle_rail 里、锁内做)。 + 拔走就作废 —— 下次插进来重新数。 */ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + safety_rail_t *st = &s->st[rail - 1]; + const bool here = slot_present(rail); + if (here && !st->was_present) { + st->need_init = true; + st->ins_n = 0; + } else if (!here) { + st->need_init = false; + } + st->was_present = here; + } + + /* 五路,一路一次 `xact`(通道查表,**不许算** —— rail_cfg.h 那条禁令)。 */ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + if (!slot_present(rail)) { + s->rail_ok[rail - 1] = false; /* 没读 ≠ 读到了:别让上一圈的旧值冒充新鲜 */ + s->io_ok[rail - 1] = false; + continue; + } + 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); +} diff --git a/firmware/main/t_safety.h b/firmware/main/t_safety.h index 1153138..4066e94 100644 --- a/firmware/main/t_safety.h +++ b/firmware/main/t_safety.h @@ -1,236 +1,257 @@ -/* `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 -#include -#include - -#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 的**欠压**一侧单独放宽到 −250 mV(2026-09-28 用户拍板)。 - 上板实测:小米8 插上屏幕后开机峰值 ~2.1 A,轨 1 读回 3785 mV(设定 3900,−115 mV, - ≈55 mΩ 压降),−100 mV 的门限把正在开机的手机直接关掉。SR-5 本来就只管记录告警, - 实时保护在硬件里;过压那一侧不动,仍是 +100 mV。 */ -#define T_SAFETY_UNDER_MV 250 - -/* 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 - -/* ⭐ 0.1.4:**整片刀片不应答**(本路 INA226 与 PCA9555 **同时** NAK)连续多少圈就关这一路。 - 与 §10.10 第一行「INA226 无应答 → 盲跑、保持供电」不冲突:那一行是**一颗**器件坏了, - 刀片上的另一颗还答应;两颗都不答应而 mux 是好的(选不中的话本路读函数根本不会被调, - 计不进来),只剩两种可能 —— - · 刀片根本不在,是 `PRSNT#` 假在位(2026-09-28 板 B 第 2 槽:卡低之后空槽 eFuse 开了约 4 h); - · 刀片在,但这一路完全失控:看不到 V/I、看不到 PG/FLT、**连 VBUS 都断不了**。 - 两种都是「继续供电但看不见」,和 mux 失联全断同一个道理,只是范围是一路。 - 10 圈 = 200 ms:本板实测单路 9 万圈 0 次 NAK,连续 10 次不是毛刺;空槽带 12 V 多 200 ms 无所谓。 */ -#define T_SAFETY_BLADE_MUTE_N 10 - -/* ---- 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#` 为高(刀片不在位)⇒ 这一路不许带电 */ - /* 0.1.4,同样只许加在末尾。 */ - SAFETY_EV_BLADE_MUTE, /* 本路 INA226 + PCA9555 连续 T_SAFETY_BLADE_MUTE_N 圈都不应答 ⇒ 关这一路 */ -} 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; /* 连着几圈越界了。回到范围内 / 这一路被关掉就清零 */ - uint8_t mute_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 */ +/* `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 +#include +#include + +#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 的**欠压**一侧单独放宽到 −250 mV(2026-09-28 用户拍板)。 + 上板实测:小米8 插上屏幕后开机峰值 ~2.1 A,轨 1 读回 3785 mV(设定 3900,−115 mV, + ≈55 mΩ 压降),−100 mV 的门限把正在开机的手机直接关掉。SR-5 本来就只管记录告警, + 实时保护在硬件里;过压那一侧不动,仍是 +100 mV。 */ +#define T_SAFETY_UNDER_MV 250 + +/* 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,落在阈值外侧。 + 2026-09-28 用户定:放宽到 −150 mA(刷机重启过渡时实测 −59 mA 误断 VBUS); + 同日再放宽到 −300 mA(0.1.7 下 adb reboot bootloader 实测 −208.5 mA 仍误断)。 + 测试注入相应改为 −310 mA。 */ +#define T_SAFETY_REV_UA (-300000) + +/* 反灌**不做确认窗口**(一圈看见就断),这和 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 + +/* ⭐ 0.1.4:**整片刀片不应答**(本路 INA226 与 PCA9555 **同时** NAK)连续多少圈就关这一路。 + 与 §10.10 第一行「INA226 无应答 → 盲跑、保持供电」不冲突:那一行是**一颗**器件坏了, + 刀片上的另一颗还答应;两颗都不答应而 mux 是好的(选不中的话本路读函数根本不会被调, + 计不进来),只剩两种可能 —— + · 刀片根本不在,是 `PRSNT#` 假在位(2026-09-28 板 B 第 2 槽:卡低之后空槽 eFuse 开了约 4 h); + · 刀片在,但这一路完全失控:看不到 V/I、看不到 PG/FLT、**连 VBUS 都断不了**。 + 两种都是「继续供电但看不见」,和 mux 失联全断同一个道理,只是范围是一路。 + 10 圈 = 200 ms:本板实测单路 9 万圈 0 次 NAK,连续 10 次不是毛刺;空槽带 12 V 多 200 ms 无所谓。 */ +#define T_SAFETY_BLADE_MUTE_N 10 + +/* 热插拔(0.1.10,2026-09-28 板 B 实测):刀片**带电插进来**,只有开机 bringup 才做的那套初始化 + 没人做 —— + · PCA9555:金手指触点先后接触,SDA/SCL 经上拉先于 +3V3 给它寄生供电,上电复位不干净, + 寄存器是乱的。实测 `VBUS_EN` 被抬高、TPS22918 常开(C67 = 5 V),而固件的影子是 off ⇒ + 没刷补丁的手机被充到 4.4 V、SR-5 过压关路,SW-8 却从没武装(它看的是影子); + 同一片刀片插好再给底板上电则一切正常。侧键 / EDL 同理可能被按住。 + · INA226:CALIB 是复位值 0 ⇒ **电流恒读 0**,反灌判据与开机电流校验全瞎。 + 所以 `PRSNT#` 从高变低、连着在位 T_SAFETY_INSERT_N 圈(金手指插到底、弹片不再抖)之后, + 在这一路的锁内重做一遍:PCA9555 输出清零 + 方向(VBUS 断、键全松),INA226 配置 + 校准。 + 没成功就再等 T_SAFETY_INSERT_N 圈重试,直到成功或刀片被拔走。开机时就在位的刀片不重做。 */ +#define T_SAFETY_INSERT_N 5 + +/* ---- 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 < −300 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#` 为高(刀片不在位)⇒ 这一路不许带电 */ + /* 0.1.4,同样只许加在末尾。 */ + SAFETY_EV_BLADE_MUTE, /* 本路 INA226 + PCA9555 连续 T_SAFETY_BLADE_MUTE_N 圈都不应答 ⇒ 关这一路 */ +} 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; /* 连着几圈越界了。回到范围内 / 这一路被关掉就清零 */ + uint8_t mute_n; /* 开着电、整片刀片连着几圈不应答。任一颗答应了 / 这一路关着就清零 */ + + /* 热插拔(T_SAFETY_INSERT_N 的理由)。 */ + bool was_present; /* 上一圈的 `PRSNT#`,认「插进来」那一下 */ + bool need_init; /* 插进来了、还没初始化成功 */ + uint8_t ins_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 blade_inits; /* 热插拔进来的刀片成功重新初始化过几次 */ + 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 */ diff --git a/firmware/test/test_cmd.c b/firmware/test/test_cmd.c index 0d9518e..7389ea3 100644 --- a/firmware/test/test_cmd.c +++ b/firmware/test/test_cmd.c @@ -11,6 +11,7 @@ #include #include "board_pins.h" +#include "chgmode.h" #include "cmd.h" #include "dac63204.h" #include "evlog.h" @@ -112,6 +113,7 @@ void setUp(void) t_thermal_set_floor(0); TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); t_actuator_init(NULL, NULL); + chgmode_reset(); cmd_init(NULL); /* 启动期独占,之后任何一次 I²C 事务没持锁就记违规 —— `set_voltage` 那一笔 @@ -186,7 +188,8 @@ static void test_cmd_numbers_are_frozen(void) TEST_ASSERT_EQUAL_INT(11, CMD_MQTT); TEST_ASSERT_EQUAL_INT(12, CMD_NET); TEST_ASSERT_EQUAL_INT(13, CMD_OTA); /* C2 */ - TEST_ASSERT_EQUAL_INT(14, CMD_COUNT); + TEST_ASSERT_EQUAL_INT(14, CMD_FLASH_MODE); /* 0.1.5 刷机模式 */ + TEST_ASSERT_EQUAL_INT(15, CMD_COUNT); TEST_ASSERT_EQUAL_INT(0, CMD_OK); TEST_ASSERT_EQUAL_INT(1, CMD_ERR_PARSE); @@ -690,6 +693,144 @@ static void test_small_output_buffer(void) TEST_ASSERT_EQUAL_UINT16(300, t_thermal_floor()); } +/* ---- 0.1.5 刷机模式(flash_mode) ----------------------------------------- */ + +static void test_flash_mode_command(void) +{ + /* 关着电的路不许开(与 vbus 同一条规矩),记拒绝事件。 */ + ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true}"), "\"err\":\"state\""); + TEST_ASSERT_EQUAL_INT(1, count_events(LOG_EV_CMD_REJECT)); + + ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"power\",\"rail\":1,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true}"), "\"left\":3600"); /* 默认 60 分钟 */ + TEST_ASSERT_TRUE(chgmode_active(1, hal_time_now_ms())); + evlog_rec_t r; + TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, 0, &r)); + TEST_ASSERT_EQUAL_UINT8(1, r.rail); + TEST_ASSERT_EQUAL_UINT8(CHG_MODE_DEFAULT_MIN, r.arg); + + /* status 每一路报剩余秒数 */ + ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"chg\":3600"); + + /* 越界 / 参数错 */ + ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true,\"min\":0}"), "\"err\":\"range\""); + ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true,\"min\":121}"), "\"err\":\"range\""); + ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":false,\"min\":5}"), "\"err\":\"arg\""); + ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"flash_mode\",\"rail\":1}"), "\"err\":\"arg\""); + + /* 续期:本路再开一次不占新名额 */ + ASSERT_HAS(send("{\"seq\":8,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true,\"min\":120}"), "\"left\":7200"); + + /* 第三路被拒 */ + ASSERT_HAS(send("{\"seq\":9,\"cmd\":\"power\",\"rail\":2,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":10,\"cmd\":\"power\",\"rail\":3,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":11,\"cmd\":\"flash_mode\",\"rail\":2,\"on\":true,\"min\":10}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":12,\"cmd\":\"flash_mode\",\"rail\":3,\"on\":true}"), "\"err\":\"state\""); + TEST_ASSERT_FALSE(chgmode_active(3, hal_time_now_ms())); + + /* 关:回 left 0、记一条 arg=0;腾出的名额第三路就能用了 */ + ASSERT_HAS(send("{\"seq\":13,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":false}"), "\"left\":0"); + TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, 3, &r)); /* 开1、续期、开2、关1 */ + TEST_ASSERT_EQUAL_UINT8(CHG_EV_OFF, r.arg); + ASSERT_HAS(send("{\"seq\":14,\"cmd\":\"flash_mode\",\"rail\":3,\"on\":true}"), "\"ok\":true"); +} + +/* 关这一路电 = 刷机模式一并退出(不白占名额),记一条 arg=0。 */ +static void test_power_off_ends_flash_mode(void) +{ + ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":4,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"flash_mode\",\"rail\":4,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"power\",\"rail\":4,\"on\":false}"), "\"ok\":true"); + TEST_ASSERT_FALSE(chgmode_active(4, hal_time_now_ms())); + TEST_ASSERT_EQUAL_INT(2, count_events(LOG_EV_CHG_MODE)); + evlog_rec_t r; + TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, 1, &r)); + TEST_ASSERT_EQUAL_UINT8(CHG_EV_OFF, r.arg); +} + +/* 自然到期:执行器循环里的 cmd_chgmode_tick 记**一条** arg=255,之后不再重复记。 */ +static void test_flash_mode_expiry_is_logged_once(void) +{ + ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":5,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"flash_mode\",\"rail\":5,\"on\":true,\"min\":1}"), "\"left\":60"); + cmd_chgmode_tick(hal_time_now_ms()); + TEST_ASSERT_EQUAL_INT(1, count_events(LOG_EV_CHG_MODE)); + + sim_time_advance_ms(60000 + CHG_MODE_GRACE_MS); + cmd_chgmode_tick(hal_time_now_ms()); + cmd_chgmode_tick(hal_time_now_ms() + 100); + TEST_ASSERT_EQUAL_INT(2, count_events(LOG_EV_CHG_MODE)); + evlog_rec_t r; + TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, 1, &r)); + TEST_ASSERT_EQUAL_UINT8(5, r.rail); + TEST_ASSERT_EQUAL_UINT8(CHG_EV_EXPIRED, r.arg); + ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"chg\":0"); +} + +/* 0.1.13:安全判据 / 拔刀片把这一路关了(不经过 power 命令)⇒ 下一次 tick 退出刷机模式、记 arg=254、 + 让出名额;硬重启中间 eFuse 关着的那几秒不算。 */ +static void test_flash_mode_ends_when_the_rail_loses_power(void) +{ + ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":1,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"power\",\"rail\":2,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"power\",\"rail\":3,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"flash_mode\",\"rail\":2,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"flash_mode\",\"rail\":3,\"on\":true}"), "\"err\":\"state\""); + + /* 硬重启:HR_OFF 期间 eFuse 关着,但这是序列的一部分 —— 不退。 */ + ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"power_cycle\",\"rail\":1}"), "\"ok\":true"); + TEST_ASSERT_FALSE(rail_efuse_is_on(1)); + cmd_chgmode_tick(hal_time_now_ms()); + TEST_ASSERT_TRUE(chgmode_active(1, hal_time_now_ms())); + + /* 第 2 路被 t_safety 关了(这里直接拉低 EN 扮演):下一次 tick 退出。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(2, false)); + const int before = count_events(LOG_EV_CHG_MODE); + cmd_chgmode_tick(hal_time_now_ms()); + TEST_ASSERT_FALSE(chgmode_active(2, hal_time_now_ms())); + TEST_ASSERT_EQUAL_INT(before + 1, count_events(LOG_EV_CHG_MODE)); + evlog_rec_t r; + TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, before, &r)); + TEST_ASSERT_EQUAL_UINT8(2, r.rail); + TEST_ASSERT_EQUAL_UINT8(CHG_EV_DROPPED, r.arg); + cmd_chgmode_tick(hal_time_now_ms() + 100); + TEST_ASSERT_EQUAL_INT(before + 1, count_events(LOG_EV_CHG_MODE)); /* 只记一次 */ + + /* 名额让出来了。 */ + ASSERT_HAS(send("{\"seq\":8,\"cmd\":\"flash_mode\",\"rail\":3,\"on\":true}"), "\"ok\":true"); +} + +/* 0.1.6 加钟:只给开着的窗口加;剩余最多 120 分钟(超出截断);与 on / min 互斥;事件 arg = 0x80 | 分钟。 */ +static void test_flash_mode_extend(void) +{ + ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":2,\"on\":true}"), "\"ok\":true"); + /* 没开着 ⇒ state(要重新开,不是加钟) */ + ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":30}"), "\"err\":\"state\""); + + ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"flash_mode\",\"rail\":2,\"on\":true,\"min\":20}"), "\"left\":1200"); + sim_time_advance_ms(5 * 60000); /* 过了 5 分钟,还剩 15 */ + ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":30}"), "\"left\":2700"); /* 15 + 30 */ + evlog_rec_t r; + TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, 1, &r)); + TEST_ASSERT_EQUAL_UINT8(CHG_EV_EXTEND | 30u, r.arg); + + /* 截断到 120 分钟 */ + ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":120}"), "\"left\":7200"); + + /* 越界 / 混用 */ + ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":0}"), "\"err\":\"range\""); + ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":121}"), "\"err\":\"range\""); + ASSERT_HAS(send("{\"seq\":8,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":10,\"on\":true}"), "\"err\":\"arg\""); + ASSERT_HAS(send("{\"seq\":9,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":10,\"min\":10}"), "\"err\":\"arg\""); + ASSERT_HAS(send("{\"seq\":10,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":true}"), "\"err\":\"arg\""); + + /* 加钟不占新名额:两路都开着时照样能加 */ + ASSERT_HAS(send("{\"seq\":11,\"cmd\":\"power\",\"rail\":3,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":12,\"cmd\":\"flash_mode\",\"rail\":3,\"on\":true}"), "\"ok\":true"); + ASSERT_HAS(send("{\"seq\":13,\"cmd\":\"flash_mode\",\"rail\":3,\"add\":10}"), "\"left\":4200"); +} + int main(void) { UNITY_BEGIN(); @@ -709,6 +850,11 @@ int main(void) RUN_TEST(test_busy_rail_refuses_a_second_sequence); RUN_TEST(test_reboots_are_logged_with_kind_and_link); RUN_TEST(test_vbus_needs_the_rail_powered); + RUN_TEST(test_flash_mode_command); + RUN_TEST(test_power_off_ends_flash_mode); + RUN_TEST(test_flash_mode_expiry_is_logged_once); + RUN_TEST(test_flash_mode_ends_when_the_rail_loses_power); + RUN_TEST(test_flash_mode_extend); RUN_TEST(test_power_off_drops_vbus_and_always_drops_the_efuse); RUN_TEST(test_power_records_intent_only_when_it_took_effect); RUN_TEST(test_fan_floor); diff --git a/firmware/test/test_evlog.c b/firmware/test/test_evlog.c index cc523f0..13d2a2f 100644 --- a/firmware/test/test_evlog.c +++ b/firmware/test/test_evlog.c @@ -88,7 +88,8 @@ static void test_flash_format_is_frozen(void) TEST_ASSERT_EQUAL_INT(18, LOG_EV_OTA); /* C2 */ TEST_ASSERT_EQUAL_INT(19, LOG_EV_BOOT_CHECK); /* 0.1.2 开机校验 */ TEST_ASSERT_EQUAL_INT(20, LOG_EV_BLADE_MUTE); /* 0.1.4 刀片不应答 */ - TEST_ASSERT_EQUAL_INT(21, LOG_EV_COUNT); + TEST_ASSERT_EQUAL_INT(21, LOG_EV_CHG_MODE); /* 0.1.5 刷机模式 */ + TEST_ASSERT_EQUAL_INT(22, LOG_EV_COUNT); } /* §10.7 的「必记事件」有八类,A10 只产生其中两类。剩下的号先占住, diff --git a/firmware/test/test_i2cbus.c b/firmware/test/test_i2cbus.c index fcf582f..5d78f84 100644 --- a/firmware/test/test_i2cbus.c +++ b/firmware/test/test_i2cbus.c @@ -65,6 +65,19 @@ static hal_err_t cb_recover_inside(void *arg) return HAL_OK; } +/* 事务进行中,**另一个任务**也来要总线(sim 的假任务号 1 扮演执行器)。 */ +static hal_err_t cb_other_task(void *arg) +{ + ctx_t *c = (ctx_t *)arg; + c->calls++; + static ctx_t inner_ctx; + inner_ctx.addr = BLADE_INA; + sim_lock_set_task(1); + c->inner = i2cbus_xact(1, 5, cb_read_mv, &inner_ctx); + sim_lock_set_task(0); + return HAL_OK; +} + /* 在事务里再调一次 `i2cbus_xact()` —— 头文件末尾那条警告。 */ static hal_err_t cb_nested(void *arg) { @@ -444,6 +457,23 @@ static void test_recover_is_rejected_inside_a_transaction(void) 不拦的话会在同一个任务里第二次 take 同一把锁 —— sim 上是 HAL_ERR_STATE, FreeRTOS 上(非递归的 mutex)就是那个任务当场停住, 现象是 `t_safety` 整个不跑了,而看门狗之外没有任何日志。 */ +/* ⭐ 0.1.11:别的任务正持着锁 ≠ 嵌套。它该**等锁**(超时回 TIMEOUT),而不是回 STATE。 + 原来的全局标志把两者混为一谈,执行器撞上巡检就回 STATE ⇒ `key` 随机 busy、`vbus` 随机 io。 */ +static void test_another_tasks_xact_waits_instead_of_looking_nested(void) +{ + mount_ready(); + ctx_t c = { BLADE_INA, 0, -1, HAL_OK }; + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_xact(0, 10, cb_other_task, &c)); + TEST_ASSERT_EQUAL_UINT32(1, c.calls); + TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, c.inner); + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); + /* 锁放了之后,那个任务再来就拿得到。 */ + sim_lock_set_task(1); + ctx_t d = { BLADE_INA, 0, -1, HAL_OK }; + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_xact(1, 5, cb_read_mv, &d)); + sim_lock_set_task(0); +} + static void test_a_nested_xact_is_rejected_and_does_not_deadlock(void) { mount_ready(); @@ -604,6 +634,7 @@ int main(void) RUN_TEST(test_recover_pulses_mux_rst_and_forgets_the_channel); RUN_TEST(test_recover_is_rejected_inside_a_transaction); RUN_TEST(test_a_nested_xact_is_rejected_and_does_not_deadlock); + RUN_TEST(test_another_tasks_xact_waits_instead_of_looking_nested); RUN_TEST(test_a_lock_timeout_changes_nothing_at_all); RUN_TEST(test_xact_rejects_bad_arguments_before_taking_the_lock); RUN_TEST(test_forget_channel_only_drops_the_shadow); diff --git a/firmware/test/test_net.c b/firmware/test/test_net.c index a01c4f0..10f231c 100644 --- a/firmware/test/test_net.c +++ b/firmware/test/test_net.c @@ -97,8 +97,8 @@ static void test_config_over_usb_is_saved_and_applied(void) "\"ok\":true"); TEST_ASSERT_EQUAL_INT(2, s_saves); TEST_ASSERT_EQUAL_INT(2, s_changed); - TEST_ASSERT_EQUAL_STRING("farm-2g", s_saved.ssid); - TEST_ASSERT_EQUAL_STRING("s3cret-pass", s_saved.pass); /* 第二次存的是整份:WiFi 那部分还在 */ + TEST_ASSERT_EQUAL_STRING("farm-2g", s_saved.wifi[0].ssid); + TEST_ASSERT_EQUAL_STRING("s3cret-pass", s_saved.wifi[0].pass); /* 第二次存的是整份:WiFi 那部分还在 */ TEST_ASSERT_EQUAL_STRING("mqtt://10.5.5.13:1883", s_saved.uri); TEST_ASSERT_EQUAL_UINT32(2000, s_saved.tele_ms); @@ -172,7 +172,7 @@ static void test_config_validation(void) pass[63] = '\0'; snprintf(line, sizeof(line), "{\"seq\":11,\"cmd\":\"wifi\",\"ssid\":\"x\",\"pass\":\"%s\"}", pass); ASSERT_HAS(send_on(CMD_LINK_USB, line), "\"ok\":true"); - TEST_ASSERT_EQUAL_STRING(pass, s_saved.pass); + TEST_ASSERT_EQUAL_STRING(pass, s_saved.wifi[1].pass); TEST_ASSERT_EQUAL_INT(3, s_saves); } @@ -186,7 +186,7 @@ static void test_unsaved_config_does_not_take_effect(void) "\"err\":\"io\""); netcfg_t c; netcfg_get(&c); - TEST_ASSERT_EQUAL_STRING("old", c.ssid); + TEST_ASSERT_EQUAL_STRING("old", c.wifi[0].ssid); TEST_ASSERT_EQUAL_INT(1, s_changed); } @@ -197,11 +197,11 @@ static void test_loaded_config_is_sanitised(void) netcfg_t junk; memset(&junk, 'A', sizeof(junk)); junk.tele_ms = 5; - memcpy(junk.ssid, "a\"b\\c\n", 6); + memcpy(junk.wifi[0].ssid, "a\"b\\c\n", 6); netcfg_init(&junk, NULL, NULL, NULL); netcfg_t c; netcfg_get(&c); - TEST_ASSERT_EQUAL_size_t(NETCFG_SSID_MAX, strlen(c.ssid)); + TEST_ASSERT_EQUAL_size_t(NETCFG_SSID_MAX, strlen(c.wifi[0].ssid)); TEST_ASSERT_EQUAL_size_t(NETCFG_URI_MAX, strlen(c.uri)); TEST_ASSERT_EQUAL_UINT32(NETCFG_TELE_DEFAULT_MS, c.tele_ms); @@ -210,6 +210,101 @@ static void test_loaded_config_is_sanitised(void) TEST_ASSERT_EQUAL_CHAR('}', r[strlen(r) - 1]); } +/* ---- 多组 WiFi(0.1.9)--------------------------------------------------- */ + +/* ⭐ 加第二组不挤掉第一组;同名再发 = 改口令、不多占一格;回应带组数。 */ +static void test_wifi_adds_and_updates_by_name(void) +{ + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":1,\"cmd\":\"wifi\",\"ssid\":\"home\",\"pass\":\"pass-home-1\"}"), + "\"n\":1"); + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":2,\"cmd\":\"wifi\",\"ssid\":\"hotspot\",\"pass\":\"pass-hot-1\"}"), + "\"n\":2"); + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":3,\"cmd\":\"wifi\",\"ssid\":\"home\",\"pass\":\"pass-home-2\"}"), + "\"n\":2"); + netcfg_t c; + netcfg_get(&c); + TEST_ASSERT_EQUAL_INT(2, netcfg_wifi_count(&c)); + TEST_ASSERT_EQUAL_STRING("home", c.wifi[0].ssid); + TEST_ASSERT_EQUAL_STRING("pass-home-2", c.wifi[0].pass); + TEST_ASSERT_EQUAL_STRING("hotspot", c.wifi[1].ssid); + TEST_ASSERT_EQUAL_STRING("pass-hot-1", c.wifi[1].pass); + TEST_ASSERT_EQUAL_MEMORY(&s_saved, &c, sizeof(c)); /* 存的是整张表 */ + + /* net:ssid = 正在连的那组(状态里 wifi_idx),ssids = 整张表;任何一组的口令都不出现。 */ + net_status_t st; + memset(&st, 0, sizeof(st)); + st.wifi_idx = 1; + net_status_set(&st); + const char *r = send_on(CMD_LINK_WIFI, "{\"cmd\":\"net\"}"); + ASSERT_HAS(r, "\"ssid\":\"hotspot\",\"pass_set\":true,\"ssids\":[\"home\",\"hotspot\"]"); + ASSERT_LACKS(r, "pass-home"); + ASSERT_LACKS(r, "pass-hot"); +} + +/* ⭐ 删一组后面的往前补;删没有的 = range;满了再加新名字 = state、表不变;满了改已有的照样行。 */ +static void test_wifi_delete_and_full_table(void) +{ + char line[128]; + for (int i = 0; i < NETCFG_WIFI_N; i++) { + snprintf(line, sizeof(line), "{\"seq\":%d,\"cmd\":\"wifi\",\"ssid\":\"n%d\",\"pass\":\"12345678\"}", + i + 1, i); + ASSERT_HAS(send_on(CMD_LINK_USB, line), "\"ok\":true"); + } + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":20,\"cmd\":\"wifi\",\"ssid\":\"extra\",\"pass\":\"12345678\"}"), + "\"err\":\"state\""); + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":21,\"cmd\":\"wifi\",\"ssid\":\"n3\",\"pass\":\"87654321\"}"), + "\"ok\":true"); + TEST_ASSERT_EQUAL_INT(NETCFG_WIFI_N + 1, s_saves); + + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":22,\"cmd\":\"wifi\",\"ssid\":\"n1\",\"del\":true}"), + "\"n\":3"); + netcfg_t c; + netcfg_get(&c); + TEST_ASSERT_EQUAL_INT(NETCFG_WIFI_N - 1, netcfg_wifi_count(&c)); + TEST_ASSERT_EQUAL_STRING("n0", c.wifi[0].ssid); + TEST_ASSERT_EQUAL_STRING("n2", c.wifi[1].ssid); + TEST_ASSERT_EQUAL_STRING("n3", c.wifi[2].ssid); + TEST_ASSERT_EQUAL_STRING("87654321", c.wifi[2].pass); + TEST_ASSERT_EQUAL_STRING("", c.wifi[3].ssid); + TEST_ASSERT_EQUAL_STRING("", c.wifi[3].pass); /* 挪走之后末尾清干净,不留口令 */ + + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":23,\"cmd\":\"wifi\",\"ssid\":\"n1\",\"del\":true}"), + "\"err\":\"range\""); + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":24,\"cmd\":\"wifi\",\"ssid\":\"n1\",\"del\":1}"), + "\"err\":\"arg\""); + /* 删也只收 USB。 */ + ASSERT_HAS(send_on(CMD_LINK_WIFI, "{\"seq\":25,\"cmd\":\"wifi\",\"ssid\":\"n0\",\"del\":true}"), + "\"err\":\"link\""); + /* 删到空:net 回应仍是合法 JSON,ssids 是空数组。 */ + for (int i = 0; i < 3; i++) { + snprintf(line, sizeof(line), "{\"seq\":%d,\"cmd\":\"wifi\",\"ssid\":\"%s\",\"del\":true}", 30 + i, + i == 0 ? "n0" : i == 1 ? "n2" : "n3"); + ASSERT_HAS(send_on(CMD_LINK_USB, line), "\"ok\":true"); + } + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"cmd\":\"net\"}"), "\"ssid\":\"\",\"pass_set\":false,\"ssids\":[]"); +} + +/* NVS 读回来的表中间有空位:挤到前面,count 才算得对、轮换才不会停在空位上。 */ +static void test_loaded_table_is_compacted(void) +{ + netcfg_t in; + memset(&in, 0, sizeof(in)); + strcpy(in.wifi[1].ssid, "b"); + strcpy(in.wifi[1].pass, "12345678"); + strcpy(in.wifi[3].ssid, "d"); + strcpy(in.wifi[2].pass, "orphan-pass"); /* 空 SSID 带着口令:整格丢掉 */ + netcfg_init(&in, NULL, NULL, NULL); + netcfg_t c; + netcfg_get(&c); + TEST_ASSERT_EQUAL_INT(2, netcfg_wifi_count(&c)); + TEST_ASSERT_EQUAL_STRING("b", c.wifi[0].ssid); + TEST_ASSERT_EQUAL_STRING("12345678", c.wifi[0].pass); + TEST_ASSERT_EQUAL_STRING("d", c.wifi[1].ssid); + TEST_ASSERT_EQUAL_INT(1, netcfg_wifi_find(&c, "d")); + TEST_ASSERT_EQUAL_INT(-1, netcfg_wifi_find(&c, "")); + TEST_ASSERT_EQUAL_STRING("", c.wifi[2].pass); +} + /* ---- MQTT 链路的平台无关部分 --------------------------------------------- */ static void test_topics_follow_the_mac(void) @@ -287,6 +382,9 @@ int main(void) RUN_TEST(test_config_validation); RUN_TEST(test_unsaved_config_does_not_take_effect); RUN_TEST(test_loaded_config_is_sanitised); + RUN_TEST(test_wifi_adds_and_updates_by_name); + RUN_TEST(test_wifi_delete_and_full_table); + RUN_TEST(test_loaded_table_is_compacted); RUN_TEST(test_topics_follow_the_mac); RUN_TEST(test_inbound_messages); RUN_TEST(test_backoff_doubles_and_caps); diff --git a/firmware/test/test_t_actuator.c b/firmware/test/test_t_actuator.c index 7216965..0ac095b 100644 --- a/firmware/test/test_t_actuator.c +++ b/firmware/test/test_t_actuator.c @@ -1231,6 +1231,113 @@ static void test_boot_check_first_press_boots(void) TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); } +/* ⭐ 0.1.11(板 B 实测):VBUS 通着做硬重启 —— 手机靠 USB 撑着没断电、直供电流 ≈ 0 ⇒ + 校验判「没开起来」再按两下、断电时带着 USB 进关机充电。现在:断电前先断 VBUS, + 整个开机 + 校验期间都断着,开机校验通过才接回。 */ +static void test_hard_reset_cuts_vbus_first_and_restores_it_after_boot(void) +{ + const int rail = 4; + s_pg_val[rail - 1] = true; + phone_up(1, false); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(rail, true)); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); /* 断电之前就断了 */ + run_phone_ms(rail, HR_TO_PRESS_MS + ACT_HR_BOOT_KEY_MS + 10); + TEST_ASSERT_EQUAL_INT(ACT_ST_HR_VERIFY, t_actuator_state(rail)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); /* 校验期间也断着 */ + + for (int i = 0; i < 1000 && t_actuator_busy(rail); i++) { + run_phone_ms(rail, 10); /* 走到校验刚结束的那一刻,从这里起算 60 s */ + } + TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail)); + TEST_ASSERT_TRUE(s_boot_booted); + /* 开起来了,但**还不接回**:这时手机在 bootloader / 内核早期,关充电补丁没生效, + 接回 USB 就被充到 4.4 V(板 B 实测 13 s 顶高断 VBUS)。执行器已空闲、不挡命令。 */ + TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_TRUE(t_actuator_vbus_back_pending(rail)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); + run_phone_ms(rail, ACT_HR_VBUS_BACK_MS - 1000); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); + run_phone_ms(rail, 1500); + TEST_ASSERT_EQUAL_INT(1, pin(rail, PCA9555_BIT_VBUS_EN)); /* 到点:原样接回 */ + TEST_ASSERT_TRUE(slot_vbus_is_on(rail)); + TEST_ASSERT_FALSE(t_actuator_vbus_back_pending(rail)); +} + +/* 等接回的这 60 s 里:有人手动动了 VBUS / 电,或者这一路被关了电 ⇒ 不再接回。 */ +static void test_a_pending_vbus_restore_is_dropped_when_someone_else_acts(void) +{ + const int rail = 1; + s_pg_val[rail - 1] = true; + + /* ① 人手动断 VBUS(cmd 层会调 cancel)。 */ + phone_up(1, false); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(rail, true)); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_phone_ms(rail, HR_TO_PRESS_MS + ONE_TRY_MS + 100); + TEST_ASSERT_TRUE(t_actuator_vbus_back_pending(rail)); + t_actuator_cancel_vbus_back(rail); + run_phone_ms(rail, ACT_HR_VBUS_BACK_MS + 1000); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); + + /* ② 这一路在等的时候被关了电(安全判据 / 拔刀片):到点也不接。 */ + phone_up(1, false); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(rail, true)); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_phone_ms(rail, HR_TO_PRESS_MS + ONE_TRY_MS + 100); + TEST_ASSERT_TRUE(t_actuator_vbus_back_pending(rail)); + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, false)); + run_phone_ms(rail, ACT_HR_VBUS_BACK_MS + 1000); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); + TEST_ASSERT_FALSE(t_actuator_vbus_back_pending(rail)); + + /* ③ 再来一次硬重启:上一次排的接回作废(新的一次从头算)。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, true)); + phone_up(1, false); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(rail, true)); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_phone_ms(rail, HR_TO_PRESS_MS + ONE_TRY_MS + 100); + TEST_ASSERT_TRUE(t_actuator_vbus_back_pending(rail)); + phone_up(1, false); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); /* VBUS 这时是断的 */ + TEST_ASSERT_FALSE(t_actuator_vbus_back_pending(rail)); + run_phone_ms(rail, HR_TO_PRESS_MS + ONE_TRY_MS + ACT_HR_VBUS_BACK_MS + 1000); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); +} + +/* 没开起来 / 被打断:VBUS **不接回** —— 接回去就是让一台没开机的手机进关机充电。 + 开始时本来就没通的,成功了也不许凭空打开。 */ +static void test_hard_reset_leaves_vbus_off_unless_the_phone_booted(void) +{ + const int rail = 2; + s_pg_val[rail - 1] = true; + phone_up(0, false); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(rail, true)); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_phone_ms(rail, HR_TO_PRESS_MS + 3 * ONE_TRY_MS + 1000); + TEST_ASSERT_EQUAL_INT(ACT_RES_NO_BOOT, t_actuator_last_result(rail)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); + TEST_ASSERT_FALSE(slot_vbus_is_on(rail)); + + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(rail, true)); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_phone_ms(rail, ACT_HR_OFF_MS / 2); + t_actuator_abort(rail); + run_phone_ms(rail, ONE_TRY_MS); + TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, t_actuator_last_result(rail)); + TEST_ASSERT_EQUAL_INT(0, pin(rail, PCA9555_BIT_VBUS_EN)); + + const int r5 = 5; + s_pg_val[r5 - 1] = true; + phone_up(1, false); + TEST_ASSERT_FALSE(slot_vbus_is_on(r5)); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(r5)); + run_phone_ms(r5, HR_TO_PRESS_MS + ONE_TRY_MS + 1000); + TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(r5)); + TEST_ASSERT_EQUAL_INT(0, pin(r5, PCA9555_BIT_VBUS_EN)); +} + /* ⭐ 这次的主症状:第一下没压上,第二下开了。 */ static void test_boot_check_presses_again_when_the_phone_stays_off(void) { @@ -1375,6 +1482,9 @@ int main(void) RUN_TEST(test_actuator_and_safety_share_one_lock_without_starving); RUN_TEST(test_boot_check_first_press_boots); + RUN_TEST(test_hard_reset_cuts_vbus_first_and_restores_it_after_boot); + RUN_TEST(test_hard_reset_leaves_vbus_off_unless_the_phone_booted); + RUN_TEST(test_a_pending_vbus_restore_is_dropped_when_someone_else_acts); RUN_TEST(test_boot_check_presses_again_when_the_phone_stays_off); RUN_TEST(test_boot_check_gives_up_after_three_presses_and_keeps_power); RUN_TEST(test_boot_check_does_not_press_again_when_current_is_unreadable); diff --git a/firmware/test/test_t_safety.c b/firmware/test/test_t_safety.c index af1a07d..8b32090 100644 --- a/firmware/test/test_t_safety.c +++ b/firmware/test/test_t_safety.c @@ -1,660 +1,755 @@ -/* A10-R2 之一:`t_safety` **一圈的形状与账**(需求 §10.3、计划 §5.5)。 - 判据本身在 `test_t_safety_rules.c` 里,这里只管「这一圈发了什么、发给了谁」。 - - ---- R1 → R2,这个文件测的东西换了重心 ------------------------------------ - - R1:一条总线、六片不同地址的 INA226,一圈 13 次事务。那时唯一会出事的形态是 - 「地址算错」,而地址算错**会 NAK**,一眼就能看见。 - - R2:五片刀片完全同构(INA226 全是 0x40、PCA9555 全是 0x20),区分它们的 - 唯一手段是底板 TCA9548A 的通道。于是出现了一种 R1 根本没有的错误形态: - **通道选错 ⇒ 读到另一台手机的电压电流**,而量纲、范围、变化趋势全都正常, - 没有任何一层会报错、会 NAK、会计数。⭐ 本文件里带星的那两条就是它的拦网。 - - ⚠️ 两条能成立的前提: - · 假总线里有一颗**真的 mux**(`sim_i2c_add_mux`)—— 没有它,五片刀片会 - 塌成一片,「选错通道」在宿主机上一次也复现不出来; - · 假 I²C **真的把虚拟时钟花掉**(`sim_i2c_set_bus_time`,默认开)—— - 没有它,「20 ms 周期」就退化成「测 delay 参数传对了没」,而真正会出事的是 - 「巡检自己占掉了 2.34 ms,节拍还准不准」。 */ -#include -#include - -#include "board_pins.h" -#include "hal_gpio.h" -#include "hal_i2c.h" -#include "hal_i2c_acct.h" -#include "hal_lock.h" -#include "hal_time.h" -#include "i2cbus.h" -#include "ina226.h" -#include "pca9555.h" -#include "rail.h" -#include "rail_cfg.h" -#include "sched.h" -#include "sim_hal.h" -#include "slot.h" -#include "t_actuator.h" -#include "t_safety.h" -#include "tasks.h" -#include "tca9548a.h" -#include "unity.h" - -#define N PHONERAIL_RAIL_COUNT - -/* 主干上那两片 DAC(`rail.h` 的 U6 / U7)。巡检一次也不该碰它们 —— - 设定值是影子里的(`rail_setpoint_mv()`),去读 DAC 就是每圈多五次事务。 */ -#define DAC_ADDR_A 0x4A -#define DAC_ADDR_B 0x4B - -/* ⭐ 五路预置成**互不相同**的电压:CH1 3600 → CH5 4000 mV。 - 只有值各不相同,「读到了另一台手机的数据」才留得下痕迹。 */ -#define RAIL_MV(rail) (3600 + 100 * ((rail) - 1)) -/* 电流也各不相同:CH1 1 A → CH5 5 A。 */ -#define RAIL_UA(rail) ((rail) * 1000000) - -/* 五片刀片的 Port1(四个故障/PG 信号,开漏上拉 ⇒ 静息 0x0F)各驱动成不同的图案, - 每一片**恰好有一个信号在响**,而且响的不是同一个。 - 全驱成一样的话,「五路读的都是同一片」这条错就看不出来。 */ -static const uint8_t k_port1[N] = { - (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_EFUSE_FLT)), /* CH1:eFuse 报故障 */ - (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_BUCK_PG)), /* CH2:Buck PG 掉了 */ - (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_INA_ALERT)), /* CH3:INA226 在告警 */ - (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_EFUSE_PG)), /* CH4:eFuse PG 掉了 */ - 0x0Fu, /* CH5:什么事都没有 */ -}; - -static safety_snap_t g_snap; - -/* `board_up()` 跑完那一刻的基准值。启动本身会在总线和引脚上留下痕迹, - 拿绝对值去断言等于把启动的账算进第一圈(那正是 `t_safety_init()` 对齐游标要防的事)。 */ -static uint32_t g_resets0; /* `MUX_RST#` 复位次数 —— `i2cbus_init()` 必定留下一次,见下 */ - -static int ch_of(int rail) { return (int)rail_cfg(rail)->mux_ch; } - -static sim_i2c_dev_t *ina_of(int rail) -{ - return sim_i2c_dev_ch(ch_of(rail), rail_cfg(rail)->ina_addr); -} -static sim_i2c_dev_t *pca_of(int rail) { return sim_i2c_dev_ch(ch_of(rail), PCA9555_ADDR); } -static sim_i2c_dev_t *mux_dev(void) { return sim_i2c_dev(TCA9548A_ADDR); } - -/* 从**假器件的寄存器**注入,不去改快照 —— 改快照等于跳过驱动和 mux, - 那样连「读的是哪一片」都证明不了。 */ -static void put_vi(int rail, int mv, int ua) -{ - const int ch = ch_of(rail); - const uint8_t addr = rail_cfg(rail)->ina_addr; - TEST_ASSERT_EQUAL_INT(0, mv % 5); /* 母线 LSB 1.25 mV:不是 5 的倍数就注不准 */ - sim_i2c_set_reg_ch(ch, addr, INA226_REG_BUS_V, (uint16_t)(mv * 4 / 5)); - sim_i2c_set_reg_ch(ch, addr, INA226_REG_CURRENT, - (uint16_t)(int16_t)(ua / INA226_CURRENT_LSB_UA)); -} - -/* 假板子:主干(mux + 输入侧 INA226 + 两片 DAC)+ 五片刀片(各 INA226 + PCA9555)。 */ -static void board_up(void) -{ - const hal_i2c_cfg_t cfg = { .sda_gpio = BOARD_PIN_I2C_SDA, - .scl_gpio = BOARD_PIN_I2C_SCL, - .freq_hz = 400000 }; - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg)); - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); - - sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N); - - for (int rail = 1; rail <= N; rail++) { - const int ch = ch_of(rail); - sim_i2c_add_ch(ch, rail_cfg(rail)->ina_addr, 2); - put_vi(rail, RAIL_MV(rail), RAIL_UA(rail)); - sim_pca9555_attach_ch(rail - 1, ch, PCA9555_ADDR); - sim_pca9555_drive(rail - 1, 1, k_port1[rail - 1]); - } - - sim_i2c_add(INA226_ADDR_INPUT, 2); - sim_i2c_set_reg(INA226_ADDR_INPUT, INA226_REG_BUS_V, (uint16_t)(12000 * 4 / 5)); - sim_i2c_set_reg(INA226_ADDR_INPUT, INA226_REG_CURRENT, (uint16_t)(2000000 / 500)); - sim_i2c_add(DAC_ADDR_A, 2); - sim_i2c_add(DAC_ADDR_B, 2); - - TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); - TEST_ASSERT_EQUAL_INT(HAL_OK, slot_prsnt_init()); - - /* ⚠️ `PRSNT#` 配的是**上拉输入**,没人驱动就是高 = 刀片不在位。 - 不驱低的话每一圈都会触发「刀片不在位」判据,而现象是「所有用例都多出几条 - 事件、五路自己关掉」—— 看上去像判据写错了,其实是假板子插反了。 */ - for (int rail = 1; rail <= N; rail++) { - sim_gpio_drive(rail_cfg(rail)->prsnt_n, 0); - } - slot_prsnt_poll(); - - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail)); - } - t_actuator_init(NULL, NULL); - rail_setpoint_reset(); - t_safety_init(&g_snap); - - /* ⚠️ 这里一定是 1,不是 0:`i2cbus_init()` 先把 `MUX_RST#` 配成输出(两个 HAL - 都保证「配完是 0」)再写 1,中间那一瞬间对真器件就是一次复位脉冲,假 mux - 如实照做。这个窗口是已知且可接受的(那时还没人用总线),但**不能当它不存在** - —— 所以后面的用例一律和这个基准比差值。 */ - g_resets0 = sim_i2c_mux_resets(); - TEST_ASSERT_EQUAL_UINT32(1, g_resets0); -} - -void setUp(void) -{ - sim_hal_reset(); - board_up(); -} - -/* 每个用例收尾都查一次「有没有同时开两路通道」。真板上那是两片同地址的 INA226 - 一起应答、读回来是线与 —— 数值正常、还很稳,没有任何一层会报错。 - 所以这个数**必须恒为 0**,放在 tearDown 里等于给每一条用例都加上这条断言。 */ -void tearDown(void) -{ - TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); -} - -/* ---- 一圈的事务账(计划 §5.5) ------------------------------------------- */ - -/* 一圈正好 `t_safety_cycle_txns()` 次事务,而且**每一片器件被读的次数是钉死的**。 - 顺手多读一个寄存器(比如 check 一下 Calibration)会当场变红, - 然后写的人才会想起来去把预算那一行也改了。 */ -static void test_one_cycle_is_exactly_the_budgeted_transaction_count(void) -{ - uint32_t ina_rd[N], pca_rd[N], pca_wr[N]; - for (int rail = 1; rail <= N; rail++) { - ina_rd[rail - 1] = ina_of(rail)->n_read; - pca_rd[rail - 1] = pca_of(rail)->n_read; - pca_wr[rail - 1] = pca_of(rail)->n_write; - } - const uint32_t in_rd = sim_i2c_dev(INA226_ADDR_INPUT)->n_read; - const uint32_t mux_wr = mux_dev()->n_write; - const uint32_t mux_rd = mux_dev()->n_read; - const uint32_t dac_rd = sim_i2c_dev(DAC_ADDR_A)->n_read + sim_i2c_dev(DAC_ADDR_B)->n_read; - - const uint32_t before = hal_i2c_acct_txns(); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - - TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), hal_i2c_acct_txns() - before); - TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); - - /* 逐片对账:五路各 2 次 INA226 寄存器读 + 1 次 PCA9555 输入读; - 主干那片 INA226 2 次;mux 被写 6 次(五路各一次 + 回主干一次)。 - ⚠️ 比的是**这一圈的增量**:`slot_init()` 自己就读了 PCA9555 六次(写一个回读一个), - 拿绝对值比等于把启动的账算进巡检。 */ - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_UINT32(2, ina_of(rail)->n_read - ina_rd[rail - 1]); - TEST_ASSERT_EQUAL_UINT32(1, pca_of(rail)->n_read - pca_rd[rail - 1]); - /* 「读输入」在线上是「写一个寄存器号 + 读两字节」,所以写计数也会 +1。 - 真正要拦的是**写输出锁存器**:平安无事的一圈里,最后落到这一片上的写 - 只能是那一个字节的寄存器指针(0x00),不能是 OUTPUT0(0x02)。 */ - TEST_ASSERT_EQUAL_UINT32(1, pca_of(rail)->n_write - pca_wr[rail - 1]); - TEST_ASSERT_EQUAL_UINT8(PCA9555_REG_INPUT0, pca_of(rail)->last_write[0]); - TEST_ASSERT_EQUAL_UINT8(1, pca_of(rail)->last_write_len); - TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(rail - 1, 0)); /* 键与 VBUS 全是 0 */ - } - TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_dev(INA226_ADDR_INPUT)->n_read - in_rd); - TEST_ASSERT_EQUAL_UINT32((uint32_t)N + 1u, mux_dev()->n_write - mux_wr); - TEST_ASSERT_EQUAL_UINT32(0, mux_dev()->n_read - mux_rd); /* 选完不回读,那是 probe 的事 */ - - /* 设定值从影子里取(`rail_setpoint_mv()`),判据一次也不该去读 DAC。 - 读一次就是每圈多五次事务,把 §10.3 的预算直接顶穿。 */ - TEST_ASSERT_EQUAL_UINT32( - 0, sim_i2c_dev(DAC_ADDR_A)->n_read + sim_i2c_dev(DAC_ADDR_B)->n_read - dac_rd); -} - -/* 静态预算与实测必须**一模一样**。不一样只有两种可能:巡检里多读/少读了东西, - 或者有别的任务在同一圈里插了事务进来 —— 两种都该被这条拦住。 - (这也是为什么预算函数不是一个写死的常数:它按同一个算式从事务形状来。) */ -static void test_measured_bus_time_equals_the_static_budget(void) -{ - for (int i = 0; i < 10; i++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us); - TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); - } -} - -/* 计划 §5.5 那张表的可执行版本:23 次事务 ≈ 2.5 ms / 12.5 %。 - 这里按线上真实发生的位数算,得 6×20 + 17×48 = 936 位 = 2340 µs = 11.7 %。 - §5.5 手算的 2.48 ms 用的是每次事务的近似值,差的那 140 µs 是近似的账。 - ⚠️ 这一条同时钉住「R1 的 13 次 / 1.56 ms 已经不是这块板子的数了」—— - 把预算函数改回 R1 那版,这里第一行就红。 */ -static void test_the_bus_account_matches_plan_5_5(void) -{ - TEST_ASSERT_EQUAL_UINT32(23, t_safety_cycle_txns()); - TEST_ASSERT_EQUAL_UINT32(20, hal_i2c_txn_bits(1, 0)); /* mux 选通:裸写一个字节 */ - TEST_ASSERT_EQUAL_UINT32(48, hal_i2c_txn_bits(1, 2)); /* 寄存器读 */ - TEST_ASSERT_EQUAL_UINT32(2340, t_safety_budget_us()); - - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - - const uint32_t period = task_desc(TASK_SAFETY)->period_ms; - const uint32_t pm = t_safety_load_permille(g_snap.bus_us, period); - TEST_ASSERT_EQUAL_UINT32(117, pm); - /* 计划 §5.5 的「≈12.4 %」:11 %~13 % 都算对得上。超出就是巡检里多读/少读了东西。 */ - TEST_ASSERT_TRUE(pm >= 110 && pm <= 130); - /* 一圈占的总线时间必须远小于周期,否则巡检自己就把节拍吃掉了。 */ - TEST_ASSERT_TRUE(g_snap.bus_us < period * 1000u / 4u); - - char line[160]; - const int n = t_safety_format(&g_snap, line, sizeof(line)); - TEST_ASSERT_TRUE(n > 0 && n < (int)sizeof(line)); - TEST_ASSERT_NOT_NULL(strstr(line, "txns=23")); - TEST_ASSERT_NOT_NULL(strstr(line, "bus=2340 us/20 ms")); - TEST_ASSERT_NOT_NULL(strstr(line, "load=11.7%")); - printf(" %s(计划 §5.5:≈2.5 ms / 12.4 %%)\n", line); -} - -/* ---- ⭐ R2 最危险的错误形态:读到了另一台手机 ---------------------------- */ - -/* 五个通道上的假 INA226 预置成**互不相同**的电压与电流,跑一圈,逐路对上。 - 通道选错的后果是「读到另一台手机的数据」—— 量纲、范围、变化趋势全都正常, - 遥测曲线好看得很,五台机器的过压判据却全部盯错了对象。 - R1 里这条错误不存在(六片六个地址,错了就 NAK);R2 里它不报错、不 NAK、 - 不计数,**只有值本身能拆穿它**。 */ -static void test_each_rail_is_read_on_its_own_mux_channel(void) -{ - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]); - TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv); - TEST_ASSERT_EQUAL_INT32(RAIL_UA(rail), g_snap.rail[rail - 1].current_ua); - } - /* 主干那片(0x43)不在任何通道后面,它的值不许混进五路里。 */ - TEST_ASSERT_TRUE(g_snap.input_ok); - TEST_ASSERT_EQUAL_INT32(12000, g_snap.input.bus_mv); - - /* 换一组值再跑一圈:万一上面那组恰好被「按顺序发牌」蒙对,换一组就露馅。 */ - for (int rail = 1; rail <= N; rail++) { - put_vi(rail, 4000 - 100 * (rail - 1), -(rail * 100000)); /* 顺序反过来、电流转负 */ - } - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT32(4000 - 100 * (rail - 1), g_snap.rail[rail - 1].bus_mv); - TEST_ASSERT_EQUAL_INT32(-(rail * 100000), g_snap.rail[rail - 1].current_ua); - } -} - -/* 同一件事,换 PCA9555 那一路:五片的四个故障信号各响一个,逐片对上。 - INA226 那条对上了、这条没对上,说明「选通道」和「读哪一片」在两条路径上 - 各自算了一遍 —— 而 `slot.c` 存在的全部理由就是让这两件事只算一次。 */ -static void test_each_blade_reports_its_own_pca9555_inputs(void) -{ - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]); - TEST_ASSERT_EQUAL_UINT8(k_port1[rail - 1], g_snap.io[rail - 1].port1); - } - /* 换成语义判读再验一遍(极性写反在原始字节上是看不出来的)。 */ - TEST_ASSERT_TRUE(pca9555_efuse_fault(&g_snap.io[0])); /* CH1 FLT# 低 = 有故障 */ - TEST_ASSERT_FALSE(pca9555_efuse_fault(&g_snap.io[1])); - TEST_ASSERT_FALSE(pca9555_buck_pg(&g_snap.io[1])); /* CH2 Buck PG 低 = 不良 */ - TEST_ASSERT_TRUE(pca9555_buck_pg(&g_snap.io[0])); - TEST_ASSERT_TRUE(pca9555_ina_alert(&g_snap.io[2])); /* CH3 ALERT 低 = 在告警 */ - TEST_ASSERT_FALSE(pca9555_ina_alert(&g_snap.io[3])); - TEST_ASSERT_FALSE(pca9555_efuse_pg(&g_snap.io[3])); /* CH4 eFuse PG 低 */ - TEST_ASSERT_TRUE(pca9555_efuse_pg(&g_snap.io[4])); /* CH5 全好 */ -} - -/* 任何时刻都只许开一路通道,而且一圈走完停在**主干**上。 - 同时开两路在真板上是两片一起应答、读回线与(数值正常、还很稳)—— - `sim_i2c_ambiguous()` 是这件事在宿主机上唯一的痕迹。 */ -static void test_the_mux_never_has_two_channels_open(void) -{ - for (int i = 0; i < 50; i++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); - /* 一圈的最后一次选通是回主干(读输入侧那片),所以收尾时通道是全关的。 */ - TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch()); - TEST_ASSERT_EQUAL_UINT8(0, sim_i2c_mux_mask()); - } - TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fails()); - TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_mux_resets() - g_resets0); /* 一切正常时不该再复位 */ -} - -/* 通道 5 上是 CH224A(PD 受电芯片),通道 6 / 7 悬空。巡检一次也不许选到它们。 - 选到通道 5 再去读 0x40 会一个字节也读不到,而现象是「这一路电压电流突然全是 0」 - —— 看上去像刀片拔了。这里在那三路上各挂一片 0x40,它们必须一次也没被碰过。 */ -static void test_the_unused_channels_are_never_selected(void) -{ - sim_i2c_dev_t *pd = sim_i2c_add_ch(TCA9548A_CH_PD, INA226_ADDR_RAIL, 2); - sim_i2c_dev_t *sp6 = sim_i2c_add_ch(6, INA226_ADDR_RAIL, 2); - sim_i2c_dev_t *sp7 = sim_i2c_add_ch(7, INA226_ADDR_RAIL, 2); - - for (int i = 0; i < 5; i++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - } - TEST_ASSERT_EQUAL_UINT32(0, pd->n_read); - TEST_ASSERT_EQUAL_UINT32(0, sp6->n_read); - TEST_ASSERT_EQUAL_UINT32(0, sp7->n_read); - TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); /* 它们也没和刀片撞上过 */ -} - -/* ---- 节拍 ---------------------------------------------------------------- */ - -/* 500 圈。每一圈都要**正好**落在 20 ms 的格子上,而且真实唤醒时刻(µs 级) - 对理想时刻的偏差始终 < 1 ms —— 这就是 §10.3 的「20 ms ±1 ms」。 - 会漂的实现(干完睡 20)在这里第一圈就出局:R2 一圈自己要占 2.34 ms, - 那样周期是 22.34 ms,500 圈之后差出 1.17 s。 */ -static void test_safety_holds_a_20ms_beat_for_500_cycles(void) -{ - sched_periodic_t tick; - sched_periodic_init(&tick, task_desc(TASK_SAFETY)->period_ms); - - /* 先确认「干活」在 sim 里真的花掉了时间。不然下面 500 圈测的只是 - 「delay 参数传对了没」—— 巡检耗时为零的话,什么实现都能踩准 20 ms。 */ - const uint64_t t0 = hal_time_now_us(); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - TEST_ASSERT_EQUAL_UINT64(t_safety_budget_us(), hal_time_now_us() - t0); - sched_periodic_wait(&tick); - - /* ⚠️ 相位起点是**第一次醒来那一刻**,不是 0:R2 的启动自己就要跑 mux 认件 + - 五片 PCA9555 初始化,上百次事务在虚拟时钟上是真花掉了的。拿绝对 0 当起点, - 测出来的是「启动花了多久」,和节拍没关系。 */ - const uint64_t base_us = hal_time_now_us(); - uint32_t prev_ms = hal_time_now_ms(); - for (int i = 1; i < 500; i++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - sched_periodic_wait(&tick); - - const uint32_t now_ms = hal_time_now_ms(); - TEST_ASSERT_EQUAL_UINT32(20, now_ms - prev_ms); - prev_ms = now_ms; - - const int64_t ideal_us = (int64_t)base_us + (int64_t)i * 20000; - const int64_t err_us = (int64_t)hal_time_now_us() - ideal_us; - TEST_ASSERT_TRUE(err_us > -1000 && err_us < 1000); - } - TEST_ASSERT_EQUAL_UINT32(0, tick.overruns); - TEST_ASSERT_EQUAL_UINT32(500, g_snap.cycles); - TEST_ASSERT_EQUAL_UINT32(0, g_snap.skipped); - TEST_ASSERT_EQUAL_UINT32(0, g_snap.events); -} - -/* 20 ms 的周期必须**长于** INA226 的一帧转换时间(AVG=4 × 2×1.1 ms = 8.8 ms)。 - 短了的话同一帧会被读两次,而现象是「电流特别稳」—— 不是任何一种错误, - 不会 NAK、不会报警、遥测曲线还更好看。R2 一圈占 2.34 ms,离 8.8 ms 还很远, - 所以每一圈拿到的都是新的一帧。 */ -static void test_the_period_outlives_one_ina226_conversion(void) -{ - const uint32_t period_us = task_desc(TASK_SAFETY)->period_ms * 1000u; - TEST_ASSERT_EQUAL_UINT32(8800, (uint32_t)INA226_CONV_US); - TEST_ASSERT_TRUE(period_us > (uint32_t)INA226_CONV_US); - /* 两倍余量:巡检自己占的总线时间加进去也不能逼近一帧。 */ - TEST_ASSERT_TRUE(period_us >= 2u * (uint32_t)INA226_CONV_US); - TEST_ASSERT_TRUE(t_safety_budget_us() < (uint32_t)INA226_CONV_US); - TEST_ASSERT_TRUE(task_table_ok()); /* 任务表自检里钉着同一条 */ -} - -/* ---- 加锁纪律 ------------------------------------------------------------ */ - -/* 一整圈里的每一次事务都持着 I²C 锁;出了这个函数锁必须已经放掉。 - R2 里这一圈要拿六次锁(五路 + 主干),每一次都得规规矩矩。 */ -static void test_every_transaction_happens_under_the_i2c_lock(void) -{ - sim_lock_require(HAL_LOCK_I2C, true); - const uint32_t takes0 = sim_lock_takes(HAL_LOCK_I2C); - for (int i = 0; i < 5; i++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - } - TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); - TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); /* 出门放锁 */ - TEST_ASSERT_EQUAL_INT(-1, sim_lock_owner(HAL_LOCK_I2C)); - /* 一圈六次 `xact` = 六次拿锁(R1 是一把大锁包住整圈,只拿一次)。 - 这个数正是「给 t_actuator 留的缝」有几条 —— 变少了缝就没了。 */ - TEST_ASSERT_EQUAL_UINT32(5u * ((uint32_t)N + 1u), sim_lock_takes(HAL_LOCK_I2C) - takes0); -} - -/* 上一条不是句空话:不拿锁直接读一片 INA226,违规计数必须 +1。 - 检查本身失灵的话,上一条会永远绿。 */ -static void test_the_lock_check_itself_can_fail(void) -{ - sim_lock_require(HAL_LOCK_I2C, true); - ina226_vi_t vi; - (void)ina226_read_vi(rail_cfg(1)->ina_addr, &vi); - TEST_ASSERT_EQUAL_UINT32(2, sim_lock_violations(HAL_LOCK_I2C)); /* 两次事务 */ -} - -/* 拿不到锁:**跳过,不死等**(t_safety.h 规矩 1)。死等的现象是巡检整个停摆, - 而它是优先级最高的那个任务 —— 停摆了没有任何人会发现。 - - ⚠️ R2 的一笔新账:一圈要等**六次**锁,最坏 6 × 5 = 30 ms,**比 20 ms 的周期还长** - (R1 只等一次,5 ms)。这条断言把这个数钉在这里:谁改了超时常量或者 `xact` - 的次数,都得先来正面回答「一圈最坏等多久」这个问题。 */ -static void test_a_busy_bus_skips_the_cycle_instead_of_waiting_forever(void) -{ - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); /* 先跑一圈好的,留下基准 */ - const int32_t mv0 = g_snap.rail[0].bus_mv; - const uint32_t txns0 = hal_i2c_acct_txns(); - const uint32_t en_writes0 = sim_gpio_writes(rail_cfg(1)->efuse_en); - - sim_lock_set_task(1); /* 假装 t_actuator 正在按键,总线在它手里 */ - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); - sim_lock_set_task(0); - - const uint32_t t0 = hal_time_now_ms(); - TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&g_snap)); - - /* 这一圈**什么也没做**:一次事务没发、一根引脚没动、快照原封不动。 */ - TEST_ASSERT_EQUAL_UINT32(0, hal_i2c_acct_txns() - txns0); - TEST_ASSERT_EQUAL_UINT32(0, g_snap.txns); - TEST_ASSERT_EQUAL_UINT32(0, g_snap.bus_us); - TEST_ASSERT_EQUAL_INT32(mv0, g_snap.rail[0].bus_mv); - TEST_ASSERT_EQUAL_UINT32(en_writes0, sim_gpio_writes(rail_cfg(1)->efuse_en)); - TEST_ASSERT_EQUAL_UINT32(0, g_snap.events); - - /* 但这一圈**被记下来了**:不计数的跳过等于悄悄失明。 */ - TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped); - TEST_ASSERT_EQUAL_UINT32(2, g_snap.cycles); - /* ⭐ **整圈的账,不是单次的账。** R2 一圈要拿六次锁,六次各等满 5 ms 就是 - 30 ms —— 比 20 ms 的周期还长,现象是巡检成片丢拍而每一次 `xact` 都老老实实 - 按时返回了。所以 `T_SAFETY_LOCK_BUDGET_MS` 是一条整圈的闸: - 花到 10 ms 就不再试剩下的路。 - - 这里断言的正是那条闸:**只试了 2 次**(5 + 5 = 10 ms 用完预算), - 而不是六次全试一遍。把预算那一段删掉的话,下面两条会变成 6 和 30。 */ - TEST_ASSERT_EQUAL_UINT32(T_SAFETY_LOCK_BUDGET_MS / T_SAFETY_LOCK_TIMEOUT_MS, - i2cbus_lock_timeouts()); - TEST_ASSERT_EQUAL_UINT32(T_SAFETY_LOCK_BUDGET_MS, hal_time_elapsed_ms(t0)); - /* 而且整圈花掉的时间**必须短于巡检周期**,否则节拍本身就保不住了。 */ - TEST_ASSERT_TRUE(hal_time_elapsed_ms(t0) < task_desc(TASK_SAFETY)->period_ms); - - /* 总线还回来,下一圈照常。 */ - sim_lock_set_task(1); - hal_lock_give(HAL_LOCK_I2C); - sim_lock_set_task(0); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped); -} - -/* ---- 器件出问题的时候(§10.2:失联 ≠ 断电) ------------------------------ */ - -/* 一片刀片的 INA226 掉线,**剩下四路的读照常**。中断这一圈的现象是 - 「一片 INA226 掉线,五路判据全瞎」—— 而那四台手机什么毛病都没有。 */ -static void test_one_dead_ina226_does_not_stop_the_other_four(void) -{ - sim_i2c_set_present_ch(ch_of(3), rail_cfg(3)->ina_addr, false); - - TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap)); - - TEST_ASSERT_FALSE(g_snap.rail_ok[2]); - for (int rail = 1; rail <= N; rail++) { - if (rail == 3) { - continue; - } - TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]); - TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv); - TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]); - } - /* 坏掉那一路的 PCA9555 没跟着遭殃(是两颗器件,不是一颗)。 */ - TEST_ASSERT_TRUE(g_snap.io_ok[2]); - TEST_ASSERT_EQUAL_UINT8(k_port1[2], g_snap.io[2].port1); - TEST_ASSERT_TRUE(g_snap.input_ok); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.i2c_errors); - - /* 读 V 那一次就 NAK 了,读 I 那一次不会再发 ⇒ 这一圈**少一次事务**。 - 把这个数写下来,是为了让「失联之后还傻读第二次」也会变红。 */ - TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns() - 1u, g_snap.txns); - TEST_ASSERT_EQUAL_UINT32( - t_safety_budget_us() - (uint32_t)hal_i2c_bits_to_us(hal_i2c_txn_bits(1, 2)), - g_snap.bus_us); -} - -/* 空槽**一个字节也不许发**(2026-09-27 上板实测)。空机时五路无条件各读一次, - 真板上每次 NAK 要十几 ms,槽 1 两次就把整圈预算吃光 —— 槽 2~5 和主干的 0x43 - 一圈都没读到,输入侧监控整个是瞎的。这里把空槽的器件也拔掉:谁碰了它就会 NAK、 - `i2c_errors` 立刻非零;全空那一圈还要求主干那片照读不误。 */ -static void test_empty_slots_are_never_touched_and_the_input_still_reads(void) -{ - const int empty[] = { 2, 4 }; - for (unsigned i = 0; i < sizeof(empty) / sizeof(empty[0]); i++) { - const int rail = empty[i]; - sim_gpio_drive(rail_cfg(rail)->prsnt_n, 1); /* 高 = 不在位 */ - sim_i2c_set_present_ch(ch_of(rail), rail_cfg(rail)->ina_addr, false); - sim_i2c_set_present_ch(ch_of(rail), PCA9555_ADDR, false); - } - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - TEST_ASSERT_EQUAL_UINT32(0, g_snap.i2c_errors); - TEST_ASSERT_EQUAL_UINT32(0, g_snap.skipped); - for (int rail = 1; rail <= N; rail++) { - const bool here = (rail != 2 && rail != 4); - TEST_ASSERT_EQUAL(here, g_snap.rail_ok[rail - 1]); - TEST_ASSERT_EQUAL(here, g_snap.io_ok[rail - 1]); - TEST_ASSERT_EQUAL(here, g_snap.present[rail - 1]); - } - TEST_ASSERT_TRUE(g_snap.input_ok); - - /* 五槽全空(上板时的真实状态):总线上只剩主干那一片。 */ - for (int rail = 1; rail <= N; rail++) { - sim_gpio_drive(rail_cfg(rail)->prsnt_n, 1); - sim_i2c_set_present_ch(ch_of(rail), rail_cfg(rail)->ina_addr, false); - sim_i2c_set_present_ch(ch_of(rail), PCA9555_ADDR, false); - } - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - TEST_ASSERT_EQUAL_UINT32(0, g_snap.i2c_errors); - TEST_ASSERT_TRUE(g_snap.input_ok); - TEST_ASSERT_EQUAL_INT32(12000, g_snap.input.bus_mv); -} - -/* 读失败时保留**上一圈的值**并把 ok 标成 false —— 不清零。 - 清零的话,判据会看到一个 0 V 的「正常数」;而 `ok` 正是让它先看一眼 - 「这数是不是新的」。拿一个没更新的电压去判过压是最难查的那种错。 */ -static void test_a_failed_read_keeps_the_stale_value_and_flags_it(void) -{ - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - TEST_ASSERT_EQUAL_INT32(RAIL_MV(1), g_snap.rail[0].bus_mv); - - sim_i2c_set_present_ch(ch_of(1), rail_cfg(1)->ina_addr, false); - /* 器件掉线之后把寄存器也改掉:万一实现偷偷读到了,值就会跟着变。 */ - put_vi(1, 3000, 0); - - for (int i = 0; i < 5; i++) { - TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap)); - TEST_ASSERT_FALSE(g_snap.rail_ok[0]); - TEST_ASSERT_EQUAL_INT32(RAIL_MV(1), g_snap.rail[0].bus_mv); /* 还是上一圈那个 */ - TEST_ASSERT_EQUAL_INT32(RAIL_UA(1), g_snap.rail[0].current_ua); - } - /* 器件回来了就立刻用新值(`ok` 不是个一去不返的标志)。 */ - sim_i2c_set_present_ch(ch_of(1), rail_cfg(1)->ina_addr, true); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - TEST_ASSERT_TRUE(g_snap.rail_ok[0]); - TEST_ASSERT_EQUAL_INT32(3000, g_snap.rail[0].bus_mv); -} - -/* 同一路上的两颗器件互不牵连:PCA9555 读不到,不影响这一路 INA226 已经读到的值。 - `cycle_rail()` 里「器件读失败不提前返回」那一条就是这个意思 —— - 提前返回的现象是「一片 I/O 扩展器失联,这一路的过压判据也跟着停了」。 */ -static void test_a_dead_pca9555_does_not_spoil_the_same_rails_ina226(void) -{ - sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, false); - - TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap)); - - TEST_ASSERT_FALSE(g_snap.io_ok[1]); - TEST_ASSERT_TRUE(g_snap.rail_ok[1]); /* INA226 是好的,值是新的 */ - TEST_ASSERT_EQUAL_INT32(RAIL_MV(2), g_snap.rail[1].bus_mv); - TEST_ASSERT_EQUAL_INT32(RAIL_UA(2), g_snap.rail[1].current_ua); - for (int rail = 1; rail <= N; rail++) { - if (rail != 2) { - TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]); - } - } - TEST_ASSERT_EQUAL_UINT32(1, g_snap.i2c_errors); -} - -/* 器件掉线之后节拍照样是 20 ms(NAK 的事务也占总线时间,只是占得更少)。 - 测的是「巡检不会因为总线出错就乱了节奏」。 */ -static void test_the_beat_survives_a_dead_blade(void) -{ - sim_i2c_set_present_ch(ch_of(5), rail_cfg(5)->ina_addr, false); - sim_i2c_set_present_ch(ch_of(5), PCA9555_ADDR, false); - - sched_periodic_t tick; - sched_periodic_init(&tick, 20); - - uint32_t prev = hal_time_now_ms(); - for (int i = 0; i < 50; i++) { - (void)t_safety_step(&g_snap); - sched_periodic_wait(&tick); - TEST_ASSERT_EQUAL_UINT32(20, hal_time_now_ms() - prev); - prev = hal_time_now_ms(); - } - TEST_ASSERT_EQUAL_UINT32(0, tick.overruns); - TEST_ASSERT_EQUAL_UINT32(100, g_snap.i2c_errors); /* 每圈两颗器件各一次 */ - /* 别的四路一点没受影响 —— 这才是「失联 ≠ 别的路也不看了」。 */ - for (int rail = 1; rail <= 4; rail++) { - TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]); - TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv); - } -} - -/* 第一圈的占用里不该混进启动期间的事务(游标要在 `t_safety_init()` 时对齐到「现在」)。 - 混进去的现象是「开机那一下占用率 40 %」,然后再也不复现。 */ -static void test_startup_traffic_is_not_charged_to_the_first_cycle(void) -{ - /* 假装启动时又扫了一遍总线。 */ - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); - (void)hal_i2c_probe(TCA9548A_ADDR); - (void)hal_i2c_probe(INA226_ADDR_INPUT); - hal_lock_give(HAL_LOCK_I2C); - - t_safety_init(&g_snap); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); - TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us); - TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); -} - -int main(void) -{ - UNITY_BEGIN(); - RUN_TEST(test_one_cycle_is_exactly_the_budgeted_transaction_count); - RUN_TEST(test_measured_bus_time_equals_the_static_budget); - RUN_TEST(test_the_bus_account_matches_plan_5_5); - - RUN_TEST(test_each_rail_is_read_on_its_own_mux_channel); - RUN_TEST(test_each_blade_reports_its_own_pca9555_inputs); - RUN_TEST(test_the_mux_never_has_two_channels_open); - RUN_TEST(test_the_unused_channels_are_never_selected); - - RUN_TEST(test_safety_holds_a_20ms_beat_for_500_cycles); - RUN_TEST(test_the_period_outlives_one_ina226_conversion); - - RUN_TEST(test_every_transaction_happens_under_the_i2c_lock); - RUN_TEST(test_the_lock_check_itself_can_fail); - RUN_TEST(test_a_busy_bus_skips_the_cycle_instead_of_waiting_forever); - - RUN_TEST(test_one_dead_ina226_does_not_stop_the_other_four); - RUN_TEST(test_empty_slots_are_never_touched_and_the_input_still_reads); - RUN_TEST(test_a_failed_read_keeps_the_stale_value_and_flags_it); - RUN_TEST(test_a_dead_pca9555_does_not_spoil_the_same_rails_ina226); - RUN_TEST(test_the_beat_survives_a_dead_blade); - RUN_TEST(test_startup_traffic_is_not_charged_to_the_first_cycle); - return UNITY_END(); -} +/* A10-R2 之一:`t_safety` **一圈的形状与账**(需求 §10.3、计划 §5.5)。 + 判据本身在 `test_t_safety_rules.c` 里,这里只管「这一圈发了什么、发给了谁」。 + + ---- R1 → R2,这个文件测的东西换了重心 ------------------------------------ + + R1:一条总线、六片不同地址的 INA226,一圈 13 次事务。那时唯一会出事的形态是 + 「地址算错」,而地址算错**会 NAK**,一眼就能看见。 + + R2:五片刀片完全同构(INA226 全是 0x40、PCA9555 全是 0x20),区分它们的 + 唯一手段是底板 TCA9548A 的通道。于是出现了一种 R1 根本没有的错误形态: + **通道选错 ⇒ 读到另一台手机的电压电流**,而量纲、范围、变化趋势全都正常, + 没有任何一层会报错、会 NAK、会计数。⭐ 本文件里带星的那两条就是它的拦网。 + + ⚠️ 两条能成立的前提: + · 假总线里有一颗**真的 mux**(`sim_i2c_add_mux`)—— 没有它,五片刀片会 + 塌成一片,「选错通道」在宿主机上一次也复现不出来; + · 假 I²C **真的把虚拟时钟花掉**(`sim_i2c_set_bus_time`,默认开)—— + 没有它,「20 ms 周期」就退化成「测 delay 参数传对了没」,而真正会出事的是 + 「巡检自己占掉了 2.34 ms,节拍还准不准」。 */ +#include +#include + +#include "board_pins.h" +#include "hal_gpio.h" +#include "hal_i2c.h" +#include "hal_i2c_acct.h" +#include "hal_lock.h" +#include "hal_time.h" +#include "i2cbus.h" +#include "ina226.h" +#include "pca9555.h" +#include "rail.h" +#include "rail_cfg.h" +#include "sched.h" +#include "sim_hal.h" +#include "slot.h" +#include "t_actuator.h" +#include "t_safety.h" +#include "tasks.h" +#include "tca9548a.h" +#include "unity.h" + +#define N PHONERAIL_RAIL_COUNT + +/* 主干上那两片 DAC(`rail.h` 的 U6 / U7)。巡检一次也不该碰它们 —— + 设定值是影子里的(`rail_setpoint_mv()`),去读 DAC 就是每圈多五次事务。 */ +#define DAC_ADDR_A 0x4A +#define DAC_ADDR_B 0x4B + +/* ⭐ 五路预置成**互不相同**的电压:CH1 3600 → CH5 4000 mV。 + 只有值各不相同,「读到了另一台手机的数据」才留得下痕迹。 */ +#define RAIL_MV(rail) (3600 + 100 * ((rail) - 1)) +/* 电流也各不相同:CH1 1 A → CH5 5 A。 */ +#define RAIL_UA(rail) ((rail) * 1000000) + +/* 五片刀片的 Port1(四个故障/PG 信号,开漏上拉 ⇒ 静息 0x0F)各驱动成不同的图案, + 每一片**恰好有一个信号在响**,而且响的不是同一个。 + 全驱成一样的话,「五路读的都是同一片」这条错就看不出来。 */ +static const uint8_t k_port1[N] = { + (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_EFUSE_FLT)), /* CH1:eFuse 报故障 */ + (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_BUCK_PG)), /* CH2:Buck PG 掉了 */ + (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_INA_ALERT)), /* CH3:INA226 在告警 */ + (uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_EFUSE_PG)), /* CH4:eFuse PG 掉了 */ + 0x0Fu, /* CH5:什么事都没有 */ +}; + +static safety_snap_t g_snap; + +/* `board_up()` 跑完那一刻的基准值。启动本身会在总线和引脚上留下痕迹, + 拿绝对值去断言等于把启动的账算进第一圈(那正是 `t_safety_init()` 对齐游标要防的事)。 */ +static uint32_t g_resets0; /* `MUX_RST#` 复位次数 —— `i2cbus_init()` 必定留下一次,见下 */ + +static int ch_of(int rail) { return (int)rail_cfg(rail)->mux_ch; } + +static sim_i2c_dev_t *ina_of(int rail) +{ + return sim_i2c_dev_ch(ch_of(rail), rail_cfg(rail)->ina_addr); +} +static sim_i2c_dev_t *pca_of(int rail) { return sim_i2c_dev_ch(ch_of(rail), PCA9555_ADDR); } +static sim_i2c_dev_t *mux_dev(void) { return sim_i2c_dev(TCA9548A_ADDR); } + +/* 从**假器件的寄存器**注入,不去改快照 —— 改快照等于跳过驱动和 mux, + 那样连「读的是哪一片」都证明不了。 */ +static void put_vi(int rail, int mv, int ua) +{ + const int ch = ch_of(rail); + const uint8_t addr = rail_cfg(rail)->ina_addr; + TEST_ASSERT_EQUAL_INT(0, mv % 5); /* 母线 LSB 1.25 mV:不是 5 的倍数就注不准 */ + sim_i2c_set_reg_ch(ch, addr, INA226_REG_BUS_V, (uint16_t)(mv * 4 / 5)); + sim_i2c_set_reg_ch(ch, addr, INA226_REG_CURRENT, + (uint16_t)(int16_t)(ua / INA226_CURRENT_LSB_UA)); +} + +/* 假板子:主干(mux + 输入侧 INA226 + 两片 DAC)+ 五片刀片(各 INA226 + PCA9555)。 */ +static void board_up(void) +{ + const hal_i2c_cfg_t cfg = { .sda_gpio = BOARD_PIN_I2C_SDA, + .scl_gpio = BOARD_PIN_I2C_SCL, + .freq_hz = 400000 }; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); + + sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N); + + for (int rail = 1; rail <= N; rail++) { + const int ch = ch_of(rail); + sim_i2c_add_ch(ch, rail_cfg(rail)->ina_addr, 2); + put_vi(rail, RAIL_MV(rail), RAIL_UA(rail)); + sim_pca9555_attach_ch(rail - 1, ch, PCA9555_ADDR); + sim_pca9555_drive(rail - 1, 1, k_port1[rail - 1]); + } + + sim_i2c_add(INA226_ADDR_INPUT, 2); + sim_i2c_set_reg(INA226_ADDR_INPUT, INA226_REG_BUS_V, (uint16_t)(12000 * 4 / 5)); + sim_i2c_set_reg(INA226_ADDR_INPUT, INA226_REG_CURRENT, (uint16_t)(2000000 / 500)); + sim_i2c_add(DAC_ADDR_A, 2); + sim_i2c_add(DAC_ADDR_B, 2); + + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_prsnt_init()); + + /* ⚠️ `PRSNT#` 配的是**上拉输入**,没人驱动就是高 = 刀片不在位。 + 不驱低的话每一圈都会触发「刀片不在位」判据,而现象是「所有用例都多出几条 + 事件、五路自己关掉」—— 看上去像判据写错了,其实是假板子插反了。 */ + for (int rail = 1; rail <= N; rail++) { + sim_gpio_drive(rail_cfg(rail)->prsnt_n, 0); + } + slot_prsnt_poll(); + + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail)); + } + t_actuator_init(NULL, NULL); + rail_setpoint_reset(); + t_safety_init(&g_snap); + + /* ⚠️ 这里一定是 1,不是 0:`i2cbus_init()` 先把 `MUX_RST#` 配成输出(两个 HAL + 都保证「配完是 0」)再写 1,中间那一瞬间对真器件就是一次复位脉冲,假 mux + 如实照做。这个窗口是已知且可接受的(那时还没人用总线),但**不能当它不存在** + —— 所以后面的用例一律和这个基准比差值。 */ + g_resets0 = sim_i2c_mux_resets(); + TEST_ASSERT_EQUAL_UINT32(1, g_resets0); +} + +void setUp(void) +{ + sim_hal_reset(); + board_up(); +} + +/* 每个用例收尾都查一次「有没有同时开两路通道」。真板上那是两片同地址的 INA226 + 一起应答、读回来是线与 —— 数值正常、还很稳,没有任何一层会报错。 + 所以这个数**必须恒为 0**,放在 tearDown 里等于给每一条用例都加上这条断言。 */ +void tearDown(void) +{ + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); +} + +/* ---- 一圈的事务账(计划 §5.5) ------------------------------------------- */ + +/* 一圈正好 `t_safety_cycle_txns()` 次事务,而且**每一片器件被读的次数是钉死的**。 + 顺手多读一个寄存器(比如 check 一下 Calibration)会当场变红, + 然后写的人才会想起来去把预算那一行也改了。 */ +static void test_one_cycle_is_exactly_the_budgeted_transaction_count(void) +{ + uint32_t ina_rd[N], pca_rd[N], pca_wr[N]; + for (int rail = 1; rail <= N; rail++) { + ina_rd[rail - 1] = ina_of(rail)->n_read; + pca_rd[rail - 1] = pca_of(rail)->n_read; + pca_wr[rail - 1] = pca_of(rail)->n_write; + } + const uint32_t in_rd = sim_i2c_dev(INA226_ADDR_INPUT)->n_read; + const uint32_t mux_wr = mux_dev()->n_write; + const uint32_t mux_rd = mux_dev()->n_read; + const uint32_t dac_rd = sim_i2c_dev(DAC_ADDR_A)->n_read + sim_i2c_dev(DAC_ADDR_B)->n_read; + + const uint32_t before = hal_i2c_acct_txns(); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), hal_i2c_acct_txns() - before); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); + + /* 逐片对账:五路各 2 次 INA226 寄存器读 + 1 次 PCA9555 输入读; + 主干那片 INA226 2 次;mux 被写 6 次(五路各一次 + 回主干一次)。 + ⚠️ 比的是**这一圈的增量**:`slot_init()` 自己就读了 PCA9555 六次(写一个回读一个), + 拿绝对值比等于把启动的账算进巡检。 */ + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_UINT32(2, ina_of(rail)->n_read - ina_rd[rail - 1]); + TEST_ASSERT_EQUAL_UINT32(1, pca_of(rail)->n_read - pca_rd[rail - 1]); + /* 「读输入」在线上是「写一个寄存器号 + 读两字节」,所以写计数也会 +1。 + 真正要拦的是**写输出锁存器**:平安无事的一圈里,最后落到这一片上的写 + 只能是那一个字节的寄存器指针(0x00),不能是 OUTPUT0(0x02)。 */ + TEST_ASSERT_EQUAL_UINT32(1, pca_of(rail)->n_write - pca_wr[rail - 1]); + TEST_ASSERT_EQUAL_UINT8(PCA9555_REG_INPUT0, pca_of(rail)->last_write[0]); + TEST_ASSERT_EQUAL_UINT8(1, pca_of(rail)->last_write_len); + TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(rail - 1, 0)); /* 键与 VBUS 全是 0 */ + } + TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_dev(INA226_ADDR_INPUT)->n_read - in_rd); + TEST_ASSERT_EQUAL_UINT32((uint32_t)N + 1u, mux_dev()->n_write - mux_wr); + TEST_ASSERT_EQUAL_UINT32(0, mux_dev()->n_read - mux_rd); /* 选完不回读,那是 probe 的事 */ + + /* 设定值从影子里取(`rail_setpoint_mv()`),判据一次也不该去读 DAC。 + 读一次就是每圈多五次事务,把 §10.3 的预算直接顶穿。 */ + TEST_ASSERT_EQUAL_UINT32( + 0, sim_i2c_dev(DAC_ADDR_A)->n_read + sim_i2c_dev(DAC_ADDR_B)->n_read - dac_rd); +} + +/* 静态预算与实测必须**一模一样**。不一样只有两种可能:巡检里多读/少读了东西, + 或者有别的任务在同一圈里插了事务进来 —— 两种都该被这条拦住。 + (这也是为什么预算函数不是一个写死的常数:它按同一个算式从事务形状来。) */ +static void test_measured_bus_time_equals_the_static_budget(void) +{ + for (int i = 0; i < 10; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); + } +} + +/* 计划 §5.5 那张表的可执行版本:23 次事务 ≈ 2.5 ms / 12.5 %。 + 这里按线上真实发生的位数算,得 6×20 + 17×48 = 936 位 = 2340 µs = 11.7 %。 + §5.5 手算的 2.48 ms 用的是每次事务的近似值,差的那 140 µs 是近似的账。 + ⚠️ 这一条同时钉住「R1 的 13 次 / 1.56 ms 已经不是这块板子的数了」—— + 把预算函数改回 R1 那版,这里第一行就红。 */ +static void test_the_bus_account_matches_plan_5_5(void) +{ + TEST_ASSERT_EQUAL_UINT32(23, t_safety_cycle_txns()); + TEST_ASSERT_EQUAL_UINT32(20, hal_i2c_txn_bits(1, 0)); /* mux 选通:裸写一个字节 */ + TEST_ASSERT_EQUAL_UINT32(48, hal_i2c_txn_bits(1, 2)); /* 寄存器读 */ + TEST_ASSERT_EQUAL_UINT32(2340, t_safety_budget_us()); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + + const uint32_t period = task_desc(TASK_SAFETY)->period_ms; + const uint32_t pm = t_safety_load_permille(g_snap.bus_us, period); + TEST_ASSERT_EQUAL_UINT32(117, pm); + /* 计划 §5.5 的「≈12.4 %」:11 %~13 % 都算对得上。超出就是巡检里多读/少读了东西。 */ + TEST_ASSERT_TRUE(pm >= 110 && pm <= 130); + /* 一圈占的总线时间必须远小于周期,否则巡检自己就把节拍吃掉了。 */ + TEST_ASSERT_TRUE(g_snap.bus_us < period * 1000u / 4u); + + char line[160]; + const int n = t_safety_format(&g_snap, line, sizeof(line)); + TEST_ASSERT_TRUE(n > 0 && n < (int)sizeof(line)); + TEST_ASSERT_NOT_NULL(strstr(line, "txns=23")); + TEST_ASSERT_NOT_NULL(strstr(line, "bus=2340 us/20 ms")); + TEST_ASSERT_NOT_NULL(strstr(line, "load=11.7%")); + printf(" %s(计划 §5.5:≈2.5 ms / 12.4 %%)\n", line); +} + +/* ---- ⭐ R2 最危险的错误形态:读到了另一台手机 ---------------------------- */ + +/* 五个通道上的假 INA226 预置成**互不相同**的电压与电流,跑一圈,逐路对上。 + 通道选错的后果是「读到另一台手机的数据」—— 量纲、范围、变化趋势全都正常, + 遥测曲线好看得很,五台机器的过压判据却全部盯错了对象。 + R1 里这条错误不存在(六片六个地址,错了就 NAK);R2 里它不报错、不 NAK、 + 不计数,**只有值本身能拆穿它**。 */ +static void test_each_rail_is_read_on_its_own_mux_channel(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]); + TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv); + TEST_ASSERT_EQUAL_INT32(RAIL_UA(rail), g_snap.rail[rail - 1].current_ua); + } + /* 主干那片(0x43)不在任何通道后面,它的值不许混进五路里。 */ + TEST_ASSERT_TRUE(g_snap.input_ok); + TEST_ASSERT_EQUAL_INT32(12000, g_snap.input.bus_mv); + + /* 换一组值再跑一圈:万一上面那组恰好被「按顺序发牌」蒙对,换一组就露馅。 */ + for (int rail = 1; rail <= N; rail++) { + put_vi(rail, 4000 - 100 * (rail - 1), -(rail * 100000)); /* 顺序反过来、电流转负 */ + } + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT32(4000 - 100 * (rail - 1), g_snap.rail[rail - 1].bus_mv); + TEST_ASSERT_EQUAL_INT32(-(rail * 100000), g_snap.rail[rail - 1].current_ua); + } +} + +/* 同一件事,换 PCA9555 那一路:五片的四个故障信号各响一个,逐片对上。 + INA226 那条对上了、这条没对上,说明「选通道」和「读哪一片」在两条路径上 + 各自算了一遍 —— 而 `slot.c` 存在的全部理由就是让这两件事只算一次。 */ +static void test_each_blade_reports_its_own_pca9555_inputs(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]); + TEST_ASSERT_EQUAL_UINT8(k_port1[rail - 1], g_snap.io[rail - 1].port1); + } + /* 换成语义判读再验一遍(极性写反在原始字节上是看不出来的)。 */ + TEST_ASSERT_TRUE(pca9555_efuse_fault(&g_snap.io[0])); /* CH1 FLT# 低 = 有故障 */ + TEST_ASSERT_FALSE(pca9555_efuse_fault(&g_snap.io[1])); + TEST_ASSERT_FALSE(pca9555_buck_pg(&g_snap.io[1])); /* CH2 Buck PG 低 = 不良 */ + TEST_ASSERT_TRUE(pca9555_buck_pg(&g_snap.io[0])); + TEST_ASSERT_TRUE(pca9555_ina_alert(&g_snap.io[2])); /* CH3 ALERT 低 = 在告警 */ + TEST_ASSERT_FALSE(pca9555_ina_alert(&g_snap.io[3])); + TEST_ASSERT_FALSE(pca9555_efuse_pg(&g_snap.io[3])); /* CH4 eFuse PG 低 */ + TEST_ASSERT_TRUE(pca9555_efuse_pg(&g_snap.io[4])); /* CH5 全好 */ +} + +/* 任何时刻都只许开一路通道,而且一圈走完停在**主干**上。 + 同时开两路在真板上是两片一起应答、读回线与(数值正常、还很稳)—— + `sim_i2c_ambiguous()` 是这件事在宿主机上唯一的痕迹。 */ +static void test_the_mux_never_has_two_channels_open(void) +{ + for (int i = 0; i < 50; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); + /* 一圈的最后一次选通是回主干(读输入侧那片),所以收尾时通道是全关的。 */ + TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch()); + TEST_ASSERT_EQUAL_UINT8(0, sim_i2c_mux_mask()); + } + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fails()); + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_mux_resets() - g_resets0); /* 一切正常时不该再复位 */ +} + +/* 通道 5 上是 CH224A(PD 受电芯片),通道 6 / 7 悬空。巡检一次也不许选到它们。 + 选到通道 5 再去读 0x40 会一个字节也读不到,而现象是「这一路电压电流突然全是 0」 + —— 看上去像刀片拔了。这里在那三路上各挂一片 0x40,它们必须一次也没被碰过。 */ +static void test_the_unused_channels_are_never_selected(void) +{ + sim_i2c_dev_t *pd = sim_i2c_add_ch(TCA9548A_CH_PD, INA226_ADDR_RAIL, 2); + sim_i2c_dev_t *sp6 = sim_i2c_add_ch(6, INA226_ADDR_RAIL, 2); + sim_i2c_dev_t *sp7 = sim_i2c_add_ch(7, INA226_ADDR_RAIL, 2); + + for (int i = 0; i < 5; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + } + TEST_ASSERT_EQUAL_UINT32(0, pd->n_read); + TEST_ASSERT_EQUAL_UINT32(0, sp6->n_read); + TEST_ASSERT_EQUAL_UINT32(0, sp7->n_read); + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); /* 它们也没和刀片撞上过 */ +} + +/* ---- 节拍 ---------------------------------------------------------------- */ + +/* 500 圈。每一圈都要**正好**落在 20 ms 的格子上,而且真实唤醒时刻(µs 级) + 对理想时刻的偏差始终 < 1 ms —— 这就是 §10.3 的「20 ms ±1 ms」。 + 会漂的实现(干完睡 20)在这里第一圈就出局:R2 一圈自己要占 2.34 ms, + 那样周期是 22.34 ms,500 圈之后差出 1.17 s。 */ +static void test_safety_holds_a_20ms_beat_for_500_cycles(void) +{ + sched_periodic_t tick; + sched_periodic_init(&tick, task_desc(TASK_SAFETY)->period_ms); + + /* 先确认「干活」在 sim 里真的花掉了时间。不然下面 500 圈测的只是 + 「delay 参数传对了没」—— 巡检耗时为零的话,什么实现都能踩准 20 ms。 */ + const uint64_t t0 = hal_time_now_us(); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_UINT64(t_safety_budget_us(), hal_time_now_us() - t0); + sched_periodic_wait(&tick); + + /* ⚠️ 相位起点是**第一次醒来那一刻**,不是 0:R2 的启动自己就要跑 mux 认件 + + 五片 PCA9555 初始化,上百次事务在虚拟时钟上是真花掉了的。拿绝对 0 当起点, + 测出来的是「启动花了多久」,和节拍没关系。 */ + const uint64_t base_us = hal_time_now_us(); + uint32_t prev_ms = hal_time_now_ms(); + for (int i = 1; i < 500; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + sched_periodic_wait(&tick); + + const uint32_t now_ms = hal_time_now_ms(); + TEST_ASSERT_EQUAL_UINT32(20, now_ms - prev_ms); + prev_ms = now_ms; + + const int64_t ideal_us = (int64_t)base_us + (int64_t)i * 20000; + const int64_t err_us = (int64_t)hal_time_now_us() - ideal_us; + TEST_ASSERT_TRUE(err_us > -1000 && err_us < 1000); + } + TEST_ASSERT_EQUAL_UINT32(0, tick.overruns); + TEST_ASSERT_EQUAL_UINT32(500, g_snap.cycles); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.skipped); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.events); +} + +/* 20 ms 的周期必须**长于** INA226 的一帧转换时间(AVG=4 × 2×1.1 ms = 8.8 ms)。 + 短了的话同一帧会被读两次,而现象是「电流特别稳」—— 不是任何一种错误, + 不会 NAK、不会报警、遥测曲线还更好看。R2 一圈占 2.34 ms,离 8.8 ms 还很远, + 所以每一圈拿到的都是新的一帧。 */ +static void test_the_period_outlives_one_ina226_conversion(void) +{ + const uint32_t period_us = task_desc(TASK_SAFETY)->period_ms * 1000u; + TEST_ASSERT_EQUAL_UINT32(8800, (uint32_t)INA226_CONV_US); + TEST_ASSERT_TRUE(period_us > (uint32_t)INA226_CONV_US); + /* 两倍余量:巡检自己占的总线时间加进去也不能逼近一帧。 */ + TEST_ASSERT_TRUE(period_us >= 2u * (uint32_t)INA226_CONV_US); + TEST_ASSERT_TRUE(t_safety_budget_us() < (uint32_t)INA226_CONV_US); + TEST_ASSERT_TRUE(task_table_ok()); /* 任务表自检里钉着同一条 */ +} + +/* ---- 加锁纪律 ------------------------------------------------------------ */ + +/* 一整圈里的每一次事务都持着 I²C 锁;出了这个函数锁必须已经放掉。 + R2 里这一圈要拿六次锁(五路 + 主干),每一次都得规规矩矩。 */ +static void test_every_transaction_happens_under_the_i2c_lock(void) +{ + sim_lock_require(HAL_LOCK_I2C, true); + const uint32_t takes0 = sim_lock_takes(HAL_LOCK_I2C); + for (int i = 0; i < 5; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + } + TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); /* 出门放锁 */ + TEST_ASSERT_EQUAL_INT(-1, sim_lock_owner(HAL_LOCK_I2C)); + /* 一圈六次 `xact` = 六次拿锁(R1 是一把大锁包住整圈,只拿一次)。 + 这个数正是「给 t_actuator 留的缝」有几条 —— 变少了缝就没了。 */ + TEST_ASSERT_EQUAL_UINT32(5u * ((uint32_t)N + 1u), sim_lock_takes(HAL_LOCK_I2C) - takes0); +} + +/* 上一条不是句空话:不拿锁直接读一片 INA226,违规计数必须 +1。 + 检查本身失灵的话,上一条会永远绿。 */ +static void test_the_lock_check_itself_can_fail(void) +{ + sim_lock_require(HAL_LOCK_I2C, true); + ina226_vi_t vi; + (void)ina226_read_vi(rail_cfg(1)->ina_addr, &vi); + TEST_ASSERT_EQUAL_UINT32(2, sim_lock_violations(HAL_LOCK_I2C)); /* 两次事务 */ +} + +/* 拿不到锁:**跳过,不死等**(t_safety.h 规矩 1)。死等的现象是巡检整个停摆, + 而它是优先级最高的那个任务 —— 停摆了没有任何人会发现。 + + ⚠️ R2 的一笔新账:一圈要等**六次**锁,最坏 6 × 5 = 30 ms,**比 20 ms 的周期还长** + (R1 只等一次,5 ms)。这条断言把这个数钉在这里:谁改了超时常量或者 `xact` + 的次数,都得先来正面回答「一圈最坏等多久」这个问题。 */ +static void test_a_busy_bus_skips_the_cycle_instead_of_waiting_forever(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); /* 先跑一圈好的,留下基准 */ + const int32_t mv0 = g_snap.rail[0].bus_mv; + const uint32_t txns0 = hal_i2c_acct_txns(); + const uint32_t en_writes0 = sim_gpio_writes(rail_cfg(1)->efuse_en); + + sim_lock_set_task(1); /* 假装 t_actuator 正在按键,总线在它手里 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + sim_lock_set_task(0); + + const uint32_t t0 = hal_time_now_ms(); + TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&g_snap)); + + /* 这一圈**什么也没做**:一次事务没发、一根引脚没动、快照原封不动。 */ + TEST_ASSERT_EQUAL_UINT32(0, hal_i2c_acct_txns() - txns0); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.txns); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.bus_us); + TEST_ASSERT_EQUAL_INT32(mv0, g_snap.rail[0].bus_mv); + TEST_ASSERT_EQUAL_UINT32(en_writes0, sim_gpio_writes(rail_cfg(1)->efuse_en)); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.events); + + /* 但这一圈**被记下来了**:不计数的跳过等于悄悄失明。 */ + TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped); + TEST_ASSERT_EQUAL_UINT32(2, g_snap.cycles); + /* ⭐ **整圈的账,不是单次的账。** R2 一圈要拿六次锁,六次各等满 5 ms 就是 + 30 ms —— 比 20 ms 的周期还长,现象是巡检成片丢拍而每一次 `xact` 都老老实实 + 按时返回了。所以 `T_SAFETY_LOCK_BUDGET_MS` 是一条整圈的闸: + 花到 10 ms 就不再试剩下的路。 + + 这里断言的正是那条闸:**只试了 2 次**(5 + 5 = 10 ms 用完预算), + 而不是六次全试一遍。把预算那一段删掉的话,下面两条会变成 6 和 30。 */ + TEST_ASSERT_EQUAL_UINT32(T_SAFETY_LOCK_BUDGET_MS / T_SAFETY_LOCK_TIMEOUT_MS, + i2cbus_lock_timeouts()); + TEST_ASSERT_EQUAL_UINT32(T_SAFETY_LOCK_BUDGET_MS, hal_time_elapsed_ms(t0)); + /* 而且整圈花掉的时间**必须短于巡检周期**,否则节拍本身就保不住了。 */ + TEST_ASSERT_TRUE(hal_time_elapsed_ms(t0) < task_desc(TASK_SAFETY)->period_ms); + + /* 总线还回来,下一圈照常。 */ + sim_lock_set_task(1); + hal_lock_give(HAL_LOCK_I2C); + sim_lock_set_task(0); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped); +} + +/* ---- 器件出问题的时候(§10.2:失联 ≠ 断电) ------------------------------ */ + +/* 一片刀片的 INA226 掉线,**剩下四路的读照常**。中断这一圈的现象是 + 「一片 INA226 掉线,五路判据全瞎」—— 而那四台手机什么毛病都没有。 */ +static void test_one_dead_ina226_does_not_stop_the_other_four(void) +{ + sim_i2c_set_present_ch(ch_of(3), rail_cfg(3)->ina_addr, false); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap)); + + TEST_ASSERT_FALSE(g_snap.rail_ok[2]); + for (int rail = 1; rail <= N; rail++) { + if (rail == 3) { + continue; + } + TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]); + TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv); + TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]); + } + /* 坏掉那一路的 PCA9555 没跟着遭殃(是两颗器件,不是一颗)。 */ + TEST_ASSERT_TRUE(g_snap.io_ok[2]); + TEST_ASSERT_EQUAL_UINT8(k_port1[2], g_snap.io[2].port1); + TEST_ASSERT_TRUE(g_snap.input_ok); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.i2c_errors); + + /* 读 V 那一次就 NAK 了,读 I 那一次不会再发 ⇒ 这一圈**少一次事务**。 + 把这个数写下来,是为了让「失联之后还傻读第二次」也会变红。 */ + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns() - 1u, g_snap.txns); + TEST_ASSERT_EQUAL_UINT32( + t_safety_budget_us() - (uint32_t)hal_i2c_bits_to_us(hal_i2c_txn_bits(1, 2)), + g_snap.bus_us); +} + +/* 空槽**一个字节也不许发**(2026-09-27 上板实测)。空机时五路无条件各读一次, + 真板上每次 NAK 要十几 ms,槽 1 两次就把整圈预算吃光 —— 槽 2~5 和主干的 0x43 + 一圈都没读到,输入侧监控整个是瞎的。这里把空槽的器件也拔掉:谁碰了它就会 NAK、 + `i2c_errors` 立刻非零;全空那一圈还要求主干那片照读不误。 */ +static void test_empty_slots_are_never_touched_and_the_input_still_reads(void) +{ + const int empty[] = { 2, 4 }; + for (unsigned i = 0; i < sizeof(empty) / sizeof(empty[0]); i++) { + const int rail = empty[i]; + sim_gpio_drive(rail_cfg(rail)->prsnt_n, 1); /* 高 = 不在位 */ + sim_i2c_set_present_ch(ch_of(rail), rail_cfg(rail)->ina_addr, false); + sim_i2c_set_present_ch(ch_of(rail), PCA9555_ADDR, false); + } + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.i2c_errors); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.skipped); + for (int rail = 1; rail <= N; rail++) { + const bool here = (rail != 2 && rail != 4); + TEST_ASSERT_EQUAL(here, g_snap.rail_ok[rail - 1]); + TEST_ASSERT_EQUAL(here, g_snap.io_ok[rail - 1]); + TEST_ASSERT_EQUAL(here, g_snap.present[rail - 1]); + } + TEST_ASSERT_TRUE(g_snap.input_ok); + + /* 五槽全空(上板时的真实状态):总线上只剩主干那一片。 */ + for (int rail = 1; rail <= N; rail++) { + sim_gpio_drive(rail_cfg(rail)->prsnt_n, 1); + sim_i2c_set_present_ch(ch_of(rail), rail_cfg(rail)->ina_addr, false); + sim_i2c_set_present_ch(ch_of(rail), PCA9555_ADDR, false); + } + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.i2c_errors); + TEST_ASSERT_TRUE(g_snap.input_ok); + TEST_ASSERT_EQUAL_INT32(12000, g_snap.input.bus_mv); +} + +/* 把第 rail 槽的刀片拔走 / 插回(PRSNT# + 两颗器件一起)。 */ +static void seat_blade(int rail, bool in) +{ + sim_gpio_drive(rail_cfg(rail)->prsnt_n, in ? 0 : 1); + sim_i2c_set_present_ch(ch_of(rail), rail_cfg(rail)->ina_addr, in); + sim_i2c_set_present_ch(ch_of(rail), PCA9555_ADDR, in); +} + +/* ⭐ 热插拔(0.1.10,板 B 实测):带电插进来的刀片寄存器是乱的 —— VBUS_EN 被配成输出高 + (TPS22918 常开、C67 = 5 V,影子却是 off),INA226 的 CALIB 是 0(电流恒读 0)。 + 插到底、稳住 T_SAFETY_INSERT_N 圈之后,巡检必须把它重做成开机时的样子;只做一次。 */ +static void test_a_hot_plugged_blade_is_initialised_like_at_boot(void) +{ + const int rail = 3; + const int ch = ch_of(rail); + const uint8_t ina = rail_cfg(rail)->ina_addr; + + /* 开机就在位的刀片:跑多少圈都不重做。 */ + for (int i = 0; i < 3 * T_SAFETY_INSERT_N; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + } + TEST_ASSERT_EQUAL_UINT32(0, g_snap.blade_inits); + + seat_blade(rail, false); + (void)t_safety_step(&g_snap); + + /* 插回来的是一片「上电复位没做干净」的刀片。 */ + sim_i2c_set_reg_ch(ch, PCA9555_ADDR, PCA9555_REG_OUTPUT0, 0xFF); + sim_i2c_set_reg_ch(ch, PCA9555_ADDR, PCA9555_REG_CONFIG0, 0xEF); /* P0.4 = 输出 */ + sim_i2c_set_reg_ch(ch, ina, INA226_REG_CALIB, 0); + seat_blade(rail, true); + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(rail - 1, 0, PCA9555_BIT_VBUS_EN)); + TEST_ASSERT_FALSE(slot_vbus_is_on(rail)); /* 影子说断着 —— 这正是危险所在 */ + + for (int i = 0; i < T_SAFETY_INSERT_N - 1; i++) { + (void)t_safety_step(&g_snap); + } + TEST_ASSERT_EQUAL_UINT32(0, g_snap.blade_inits); /* 还在等金手指插稳 */ + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(rail - 1, 0, PCA9555_BIT_VBUS_EN)); + /* 乱寄存器本身就是假器件认的危险态(锁存器 0xFF + Port0 有输出位),它一直在记数; + 要的是初始化之后**不再**增加。 */ + TEST_ASSERT_TRUE(sim_pca9555_unsafe_enable() > 0); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + const uint32_t unsafe0 = sim_pca9555_unsafe_enable(); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.blade_inits); + TEST_ASSERT_EQUAL_HEX8(PCA9555_CONFIG0_INIT, sim_pca9555_config(rail - 1, 0)); + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(rail - 1, 0, PCA9555_BIT_VBUS_EN)); + for (int bit = 0; bit < 4; bit++) { + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(rail - 1, 0, bit)); /* 三个键 + EDL 全松 */ + } + TEST_ASSERT_EQUAL_HEX16(INA226_CALIB, sim_i2c_get_reg_ch(ch, ina, INA226_REG_CALIB)); + + for (int i = 0; i < 3 * T_SAFETY_INSERT_N; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + } + TEST_ASSERT_EQUAL_UINT32(1, g_snap.blade_inits); /* 只做一次 */ + TEST_ASSERT_EQUAL_UINT32(unsafe0, sim_pca9555_unsafe_enable()); +} + +/* 初始化那一下没成(弹片还在抖、NAK):再等 T_SAFETY_INSERT_N 圈重试,而不是就此放弃; + 插到一半又拔走的,作废,不对空槽发字节。 */ +static void test_a_failed_blade_init_is_retried_and_dropped_when_pulled(void) +{ + const int rail = 2; + seat_blade(rail, false); + (void)t_safety_step(&g_snap); + seat_blade(rail, true); + for (int i = 0; i < T_SAFETY_INSERT_N - 1; i++) { + (void)t_safety_step(&g_snap); + } + sim_i2c_inject_nak_ch(ch_of(rail), PCA9555_ADDR, 1); /* 落在初始化的第一个字节上 */ + (void)t_safety_step(&g_snap); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.blade_inits); + for (int i = 0; i < T_SAFETY_INSERT_N; i++) { + (void)t_safety_step(&g_snap); + } + TEST_ASSERT_EQUAL_UINT32(1, g_snap.blade_inits); + + /* 插进来没稳就拔走:need_init 作废,空槽一个字节也不发。 */ + seat_blade(rail, false); + (void)t_safety_step(&g_snap); + seat_blade(rail, true); + (void)t_safety_step(&g_snap); + seat_blade(rail, false); + const uint32_t err0 = g_snap.i2c_errors; + for (int i = 0; i < 3 * T_SAFETY_INSERT_N; i++) { + (void)t_safety_step(&g_snap); + } + TEST_ASSERT_EQUAL_UINT32(err0, g_snap.i2c_errors); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.blade_inits); +} + +/* 读失败时保留**上一圈的值**并把 ok 标成 false —— 不清零。 + 清零的话,判据会看到一个 0 V 的「正常数」;而 `ok` 正是让它先看一眼 + 「这数是不是新的」。拿一个没更新的电压去判过压是最难查的那种错。 */ +static void test_a_failed_read_keeps_the_stale_value_and_flags_it(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_INT32(RAIL_MV(1), g_snap.rail[0].bus_mv); + + sim_i2c_set_present_ch(ch_of(1), rail_cfg(1)->ina_addr, false); + /* 器件掉线之后把寄存器也改掉:万一实现偷偷读到了,值就会跟着变。 */ + put_vi(1, 3000, 0); + + for (int i = 0; i < 5; i++) { + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap)); + TEST_ASSERT_FALSE(g_snap.rail_ok[0]); + TEST_ASSERT_EQUAL_INT32(RAIL_MV(1), g_snap.rail[0].bus_mv); /* 还是上一圈那个 */ + TEST_ASSERT_EQUAL_INT32(RAIL_UA(1), g_snap.rail[0].current_ua); + } + /* 器件回来了就立刻用新值(`ok` 不是个一去不返的标志)。 */ + sim_i2c_set_present_ch(ch_of(1), rail_cfg(1)->ina_addr, true); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_TRUE(g_snap.rail_ok[0]); + TEST_ASSERT_EQUAL_INT32(3000, g_snap.rail[0].bus_mv); +} + +/* 同一路上的两颗器件互不牵连:PCA9555 读不到,不影响这一路 INA226 已经读到的值。 + `cycle_rail()` 里「器件读失败不提前返回」那一条就是这个意思 —— + 提前返回的现象是「一片 I/O 扩展器失联,这一路的过压判据也跟着停了」。 */ +static void test_a_dead_pca9555_does_not_spoil_the_same_rails_ina226(void) +{ + sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, false); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap)); + + TEST_ASSERT_FALSE(g_snap.io_ok[1]); + TEST_ASSERT_TRUE(g_snap.rail_ok[1]); /* INA226 是好的,值是新的 */ + TEST_ASSERT_EQUAL_INT32(RAIL_MV(2), g_snap.rail[1].bus_mv); + TEST_ASSERT_EQUAL_INT32(RAIL_UA(2), g_snap.rail[1].current_ua); + for (int rail = 1; rail <= N; rail++) { + if (rail != 2) { + TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]); + } + } + TEST_ASSERT_EQUAL_UINT32(1, g_snap.i2c_errors); +} + +/* 器件掉线之后节拍照样是 20 ms(NAK 的事务也占总线时间,只是占得更少)。 + 测的是「巡检不会因为总线出错就乱了节奏」。 */ +static void test_the_beat_survives_a_dead_blade(void) +{ + sim_i2c_set_present_ch(ch_of(5), rail_cfg(5)->ina_addr, false); + sim_i2c_set_present_ch(ch_of(5), PCA9555_ADDR, false); + + sched_periodic_t tick; + sched_periodic_init(&tick, 20); + + uint32_t prev = hal_time_now_ms(); + for (int i = 0; i < 50; i++) { + (void)t_safety_step(&g_snap); + sched_periodic_wait(&tick); + TEST_ASSERT_EQUAL_UINT32(20, hal_time_now_ms() - prev); + prev = hal_time_now_ms(); + } + TEST_ASSERT_EQUAL_UINT32(0, tick.overruns); + TEST_ASSERT_EQUAL_UINT32(100, g_snap.i2c_errors); /* 每圈两颗器件各一次 */ + /* 别的四路一点没受影响 —— 这才是「失联 ≠ 别的路也不看了」。 */ + for (int rail = 1; rail <= 4; rail++) { + TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]); + TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv); + } +} + +/* 第一圈的占用里不该混进启动期间的事务(游标要在 `t_safety_init()` 时对齐到「现在」)。 + 混进去的现象是「开机那一下占用率 40 %」,然后再也不复现。 */ +static void test_startup_traffic_is_not_charged_to_the_first_cycle(void) +{ + /* 假装启动时又扫了一遍总线。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + (void)hal_i2c_probe(TCA9548A_ADDR); + (void)hal_i2c_probe(INA226_ADDR_INPUT); + hal_lock_give(HAL_LOCK_I2C); + + t_safety_init(&g_snap); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_one_cycle_is_exactly_the_budgeted_transaction_count); + RUN_TEST(test_measured_bus_time_equals_the_static_budget); + RUN_TEST(test_the_bus_account_matches_plan_5_5); + + RUN_TEST(test_each_rail_is_read_on_its_own_mux_channel); + RUN_TEST(test_each_blade_reports_its_own_pca9555_inputs); + RUN_TEST(test_the_mux_never_has_two_channels_open); + RUN_TEST(test_the_unused_channels_are_never_selected); + + RUN_TEST(test_safety_holds_a_20ms_beat_for_500_cycles); + RUN_TEST(test_the_period_outlives_one_ina226_conversion); + + RUN_TEST(test_every_transaction_happens_under_the_i2c_lock); + RUN_TEST(test_the_lock_check_itself_can_fail); + RUN_TEST(test_a_busy_bus_skips_the_cycle_instead_of_waiting_forever); + + RUN_TEST(test_one_dead_ina226_does_not_stop_the_other_four); + RUN_TEST(test_empty_slots_are_never_touched_and_the_input_still_reads); + RUN_TEST(test_a_hot_plugged_blade_is_initialised_like_at_boot); + RUN_TEST(test_a_failed_blade_init_is_retried_and_dropped_when_pulled); + RUN_TEST(test_a_failed_read_keeps_the_stale_value_and_flags_it); + RUN_TEST(test_a_dead_pca9555_does_not_spoil_the_same_rails_ina226); + RUN_TEST(test_the_beat_survives_a_dead_blade); + RUN_TEST(test_startup_traffic_is_not_charged_to_the_first_cycle); + return UNITY_END(); +} diff --git a/firmware/test/test_t_safety_rules.c b/firmware/test/test_t_safety_rules.c index cfb49b9..01c61b4 100644 --- a/firmware/test/test_t_safety_rules.c +++ b/firmware/test/test_t_safety_rules.c @@ -1,1325 +1,1519 @@ -/* A10-R2 之二:安全巡检的**判据**(SR-5 / SW-8 + R2 新增的两条)。 - 一圈的形状与总线账在 `test_t_safety.c` 里,这里只管「看见什么、做什么、按什么顺序」。 - - 计划里这一包的验收原话是「注入过压 → 20 ms 内关路并记事件;注入 −60 mA → 断 VBUS」, - R2 又加了两条:「刀片不在位 ⇒ 不许带电」「mux 失联 ⇒ 五路全断」(计划 §5.8)。 - - ---- R2 把这一层最要紧的一件事换掉了 ------------------------------------ - - R1:`EFUSE_EN` 与 `VBUS_EN` 是一对 GPIO,断哪个都是几百纳秒,顺序靠 - `sim_gpio_write_seq()` 两个序号一比就证明了。 - - R2:**`VBUS_EN` 搬到了刀片 PCA9555 的 P0.4 上**(计划 §5.4)。于是 - · 断 VBUS 变成一次跨 mux 的 I²C 事务,**必须在锁内、在本路的 `xact` 里做** - —— 放锁之后再拿一次锁,中间隔着一次锁争用,而且那时通道可能已经被别人 - 切走了,那一写就落到**另一片刀片**上:另一台手机的 USB 被拔了, - 出事那一台的 VBUS 还通着。两边都不报错。 - · `EFUSE_EN` 相反,它仍是 GPIO 直连,**放锁之后**关才对。 - ⭐ 带星的三条用例就是这件事的验收:不光要证明「顺序对」,还要证明 - **断 VBUS 那一写发生时,mux 正选在这一路上**。 - - ⚠️ 断言一律打在假器件的可观测面上:`EFUSE_EN` 看 `sim_gpio_level()` 与全局写 - 序号,`VBUS_EN` 看 `sim_pca9555_pin()`(栅极上的实际电平)与一个挂在假 PCA9555 - 上的钩子,电压电流从**假 INA226 的寄存器**注入。对着驱动自己的影子断言等于 - 自己证明自己 —— 影子是写成功才更新的,「写到了另一片上」它一个字都不会说。 - - 本文件里的 `beat()` 就是真板上两个任务合起来干的事:推一步执行器、跑一圈巡检、 - 睡到下一个 20 ms 格子。判据的「持续 20 ms」只有在真的按 20 ms 打拍子时才有意义。 */ -#include - -#include "board_pins.h" -#include "hal_gpio.h" -#include "hal_i2c.h" -#include "hal_i2c_acct.h" -#include "hal_lock.h" -#include "hal_time.h" -#include "i2cbus.h" -#include "ina226.h" -#include "pca9555.h" -#include "phonerail.h" -#include "rail.h" -#include "rail_cfg.h" -#include "sched.h" -#include "sim_hal.h" -#include "slot.h" -#include "t_actuator.h" -#include "t_safety.h" -#include "tasks.h" -#include "tca9548a.h" -#include "unity.h" - -#define N PHONERAIL_RAIL_COUNT - -/* 稳态:五路都设在 3900 mV(`RAIL_MV_SAFE`),电流 1 A 正向。 */ -#define NOMINAL_MV 3900 - -#define DAC_ADDR_A 0x4A -#define DAC_ADDR_B 0x4B - -static safety_snap_t g_snap; -static sched_periodic_t g_tick; - -static int ch_of(int rail) { return (int)rail_cfg(rail)->mux_ch; } - -/* ---- ⭐ 盯住每一片刀片的 Port0 是「什么时候、在哪个通道上」被写的 -------- - - 这是 R2 规矩 3 唯一的直接凭据。只看终值的话,下面三种实现长得一模一样: - ① 锁内、通道选在本路上写(对的); - ② 放锁之后重新拿锁再写(此刻通道可能已经被 `t_actuator` 切走了); - ③ 干脆写到了另一片上(那台手机的 USB 被拔,出事这台的 VBUS 还通着)。 - 所以在假器件的写钩子里把「这一刻 mux 选在几号通道、锁在不在手上、本路的 - `EFUSE_EN` 写到第几号了」一起记下来。 */ -typedef struct { - uint32_t n; /* 这一片的 Port0 被写过几次 */ - int mux_ch; /* 最后一次写发生时,假 mux 实际选通的是哪一路 */ - bool lock_held; /* 最后一次写发生时,I²C 锁在不在自己手上 */ - uint32_t efuse_seq; /* 那一刻本路 `EFUSE_EN` 的全局写序号(0 = 还没被写过) */ - /* ⭐ 那一刻 I²C 锁**总共被拿过几次**。这个数才是「锁内还是锁外」的硬凭据: - `hal_lock_held()` 在「放锁之后重新拿一次锁再写」的实现里**同样是 true** - (那时锁确实在自己手上),通道也同样是选对的 —— 两条断言都拦不住它。 - 而「重新拿一次锁」必然让本圈的拿锁次数多出一次,这个数骗不了人。 */ - uint32_t lock_takes; - uint8_t value; /* 落进输出锁存器的字节 */ -} out0_watch_t; - -static out0_watch_t g_w[N]; -static sim_i2c_hook_t g_inner_hook; /* 假 PCA9555 自己那一份,必须接着跑 */ - -static void watch_hook(sim_i2c_dev_t *d, sim_i2c_ev_t ev) -{ - if (g_inner_hook != NULL) { - g_inner_hook(d, ev); /* 先让假器件把输入寄存器合成好 / 查危险方向 */ - } - if (ev != SIM_I2C_EV_POST_WRITE) { - return; - } - /* 只关心「写输出锁存器」那一种写;读输入时线上也有一个字节的寄存器指针。 */ - if (d->last_write_len < 2 || d->last_write[0] != PCA9555_REG_OUTPUT0) { - return; - } - for (int rail = 1; rail <= N; rail++) { - if (ch_of(rail) != d->bus) { - continue; - } - out0_watch_t *w = &g_w[rail - 1]; - w->n++; - w->mux_ch = sim_i2c_mux_ch(); - w->lock_held = hal_lock_held(HAL_LOCK_I2C); - w->efuse_seq = sim_gpio_write_seq(rail_cfg(rail)->efuse_en); - w->lock_takes = sim_lock_takes(HAL_LOCK_I2C); - w->value = d->last_write[1]; - } -} - -static void watch_reset(void) { memset(g_w, 0, sizeof(g_w)); } - -/* ---- 事件出口:把 A11 的环形缓冲换成一个能断言的小盒子 ------------------- */ - -static safety_event_t g_ev[8]; -static int g_n_ev; -/* 回调被调的那一刻锁都不该被持着(t_safety.h:回调是放锁之后才调的)。 */ -static bool g_ev_held_lock; -/* 事件被记下来的那一刻,动作**必须已经做完了**(先保命,再记账)。 - 顺序反过来在功能上看不出任何区别 —— 除非像这里一样,在记录的那一刻回头看线。 */ -static bool g_ev_before_action; - -static void catch_event(void *ctx, const safety_event_t *ev) -{ - (void)ctx; - if (hal_lock_held(HAL_LOCK_I2C)) { - g_ev_held_lock = true; - } - switch ((safety_ev_t)ev->type) { - case SAFETY_EV_OVER_VOLT: - case SAFETY_EV_UNDER_VOLT: - case SAFETY_EV_SLOT_EMPTY: - case SAFETY_EV_BLADE_MUTE: - if (rail_efuse_is_on(ev->rail)) { - g_ev_before_action = true; /* 记这条时电早该断了 */ - } - break; - case SAFETY_EV_REVERSE: - case SAFETY_EV_PUSHED_UP: - if (slot_vbus_is_on(ev->rail)) { - g_ev_before_action = true; /* SW-8:记这条时 VBUS 早该断了 */ - } - break; - case SAFETY_EV_MUX_LOST: - for (int rail = 1; rail <= N; rail++) { - if (rail_efuse_is_on(rail)) { - g_ev_before_action = true; /* 五路早该全断了 */ - } - } - break; - default: - break; - } - if (g_n_ev < (int)(sizeof(g_ev) / sizeof(g_ev[0]))) { - g_ev[g_n_ev] = *ev; - } - g_n_ev++; -} - -/* ---- 注入与观测 ---------------------------------------------------------- */ - -/* 从**那一路通道上**那片假 INA226 的寄存器注入。直接改快照等于跳过驱动和 mux, - 那样连「读的是哪一片」都证明不了。 */ -static void inject(int rail, int mv, int ua) -{ - TEST_ASSERT_EQUAL_INT(0, mv % 5); - sim_i2c_set_reg_ch(ch_of(rail), rail_cfg(rail)->ina_addr, INA226_REG_BUS_V, - (uint16_t)(mv * 4 / 5)); - sim_i2c_set_reg_ch(ch_of(rail), rail_cfg(rail)->ina_addr, INA226_REG_CURRENT, - (uint16_t)(int16_t)(ua / INA226_CURRENT_LSB_UA)); -} - -static int en_level(int rail) { return sim_gpio_level(rail_cfg(rail)->efuse_en); } -static uint32_t en_writes(int rail) { return sim_gpio_writes(rail_cfg(rail)->efuse_en); } - -/* `VBUS_EN` 在刀片 PCA9555 的 P0.4 上。看的是**栅极上的实际电平**, - 不是 `slot_vbus_is_on()`(那是驱动自己的影子)。 */ -static int vbus_pin(int rail) -{ - return sim_pca9555_pin(rail - 1, 0, PCA9555_BIT_VBUS_EN); -} - -/* ---- 假板子 -------------------------------------------------------------- */ - -static void board_up(void) -{ - const hal_i2c_cfg_t cfg = { .sda_gpio = BOARD_PIN_I2C_SDA, - .scl_gpio = BOARD_PIN_I2C_SCL, - .freq_hz = 400000 }; - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg)); - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); - - sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N); - for (int rail = 1; rail <= N; rail++) { - const int ch = ch_of(rail); - sim_i2c_add_ch(ch, rail_cfg(rail)->ina_addr, 2); - inject(rail, NOMINAL_MV, 1000000); /* 3.900 V / 1 A,稳稳当当 */ - sim_pca9555_attach_ch(rail - 1, ch, PCA9555_ADDR); - sim_pca9555_drive(rail - 1, 1, 0x0F); /* 四个开漏信号静息全高 = 没有故障 */ - } - sim_i2c_add(INA226_ADDR_INPUT, 2); - sim_i2c_add(DAC_ADDR_A, 2); - sim_i2c_add(DAC_ADDR_B, 2); - - /* 钩子要挂在假 PCA9555 自己那一份**之后**(链上去,不是顶掉 —— - 顶掉的话输入寄存器就没人合成,五路的 PG/FLT 会全读成 0)。 */ - g_inner_hook = sim_i2c_dev_ch(ch_of(1), PCA9555_ADDR)->hook; - TEST_ASSERT_NOT_NULL(g_inner_hook); - for (int rail = 1; rail <= N; rail++) { - sim_i2c_dev_ch(ch_of(rail), PCA9555_ADDR)->hook = watch_hook; - } - - TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); /* 五根 EN 线,全部拉低 */ - TEST_ASSERT_EQUAL_INT(HAL_OK, slot_prsnt_init()); - - /* ⚠️ `PRSNT#` 是上拉输入,没人驱动就是高 = 刀片不在位。不驱低的话每一圈都会 - 触发「刀片不在位」判据 —— 现象是所有用例都莫名多出五条事件。 */ - for (int rail = 1; rail <= N; rail++) { - sim_gpio_drive(rail_cfg(rail)->prsnt_n, 0); - } - slot_prsnt_poll(); - - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail)); - } - rail_setpoint_reset(); /* 五路设定值回到 NVM 默认 */ - t_actuator_init(NULL, NULL); /* PG「永远读不到」—— 硬重启会走完并记 NO_PG */ - - g_n_ev = 0; - g_ev_held_lock = false; - g_ev_before_action = false; - memset(g_ev, 0, sizeof(g_ev)); - watch_reset(); /* `slot_init()` 写过一次 OUTPUT0,不算在用例头上 */ - t_safety_set_event_sink(catch_event, NULL); - - t_safety_init(&g_snap); - sched_periodic_init(&g_tick, task_desc(TASK_SAFETY)->period_ms); -} - -void setUp(void) -{ - sim_hal_reset(); - board_up(); -} - -/* 每条用例收尾都查三件事:没有两路通道同时开着(那会读到两片的线与)、 - 没有把接地脚配成输出、事件回调没在持锁时被调。 */ -void tearDown(void) -{ - TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); - TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); - TEST_ASSERT_FALSE(g_ev_held_lock); - TEST_ASSERT_FALSE(g_ev_before_action); -} - -/* 一拍 = 真板上两个任务在这 20 ms 里干的事。执行器要先推,因为判据打断的正是它。 */ -static void beat(void) -{ - (void)t_actuator_step(); - (void)t_safety_step(&g_snap); - sched_periodic_wait(&g_tick); -} - -static void beats(int n) -{ - for (int i = 0; i < n; i++) { - beat(); - } -} - -/* 开一路电并熬过缓启动窗口。判据在这个窗口里是**不武装**的(斜坡还没走完, - 这时候比电压等于每次上电都自己把自己关掉)。 */ -static void power_up(int rail) -{ - const int before = g_n_ev; - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, true)); - beats(1 + (T_SAFETY_SETTLE_MS / (int)task_desc(TASK_SAFETY)->period_ms) + 1); - TEST_ASSERT_TRUE(rail_efuse_is_on(rail)); - TEST_ASSERT_EQUAL_INT(1, en_level(rail)); - TEST_ASSERT_EQUAL_INT(before, g_n_ev); /* 上电加稳态几拍,不该冒出新事件 */ -} - -/* 把这一路的 VBUS 通上(执行器路径:自带一次 `xact`)。 */ -static void vbus_on(int rail) -{ - TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(rail, true)); - TEST_ASSERT_EQUAL_INT(1, vbus_pin(rail)); -} - -/* ---- 常量与文档对账(改了任何一个就是改了需求) -------------------------- */ - -static void test_thresholds_match_the_documents(void) -{ - /* SR-5 / SW-2 的两个数,逐字来自需求 §9 那两行。 */ - TEST_ASSERT_EQUAL_INT(100, T_SAFETY_DEV_MV); - TEST_ASSERT_EQUAL_INT(250, T_SAFETY_UNDER_MV); /* 欠压一侧,2026-09-28 放宽 */ - TEST_ASSERT_EQUAL_INT(20, T_SAFETY_DEV_HOLD_MS); - - /* 判据数的是**圈**,SR-5 写的是**毫秒** —— 这一条把两者钉在一起。 - 巡检周期哪天改了,它会当场对不上,而不是悄悄把「持续 20 ms」变成 40 ms。 */ - TEST_ASSERT_EQUAL_INT(2, T_SAFETY_DEV_HOLD_CYCLES); - TEST_ASSERT_EQUAL_UINT32((T_SAFETY_DEV_HOLD_CYCLES - 1) * task_desc(TASK_SAFETY)->period_ms, - (uint32_t)T_SAFETY_DEV_HOLD_MS); - - /* SW-8 的两个数:设计文档 §15.2 对策 2 的 −50 mA,需求 SW-8 的 200 ms 预算。 */ - TEST_ASSERT_EQUAL_INT32(-50000, T_SAFETY_REV_UA); - TEST_ASSERT_EQUAL_INT(200, T_SAFETY_REV_BUDGET_MS); - /* R2 里断 VBUS 多了一次 I²C 事务(≈0.16 ms),判据延迟变成「一圈 + 一次事务」。 - 它必须离 200 ms 的预算远得看得出来 —— 计划 §5.4 说的「仍有 10 倍余量」。 */ - TEST_ASSERT_TRUE((int)task_desc(TASK_SAFETY)->period_ms * 2 + - (int)(t_safety_budget_us() / 1000u) < - T_SAFETY_REV_BUDGET_MS / 2); - - /* 缓启动窗口必须盖得住 tON + 斜坡那 17 ms(§7.4),而且要是周期的整数倍以上 - —— 比一个周期还短的窗口等于没有。 */ - TEST_ASSERT_TRUE(T_SAFETY_SETTLE_MS > (int)ACT_HR_RAMP_MS); - TEST_ASSERT_TRUE(T_SAFETY_SETTLE_MS >= 2 * (int)task_desc(TASK_SAFETY)->period_ms); - - /* §12.1 注入的是 ±150 mV / −60 mA,两个都必须落在阈值**外侧**, - 否则验收测的是「阈值刚好卡在注入值上」。 */ - TEST_ASSERT_TRUE(150 > T_SAFETY_DEV_MV); - TEST_ASSERT_TRUE(300 > T_SAFETY_UNDER_MV); /* 欠压注入 −300 mV */ - TEST_ASSERT_TRUE(-60000 < T_SAFETY_REV_UA); - - /* ⭐ R2 新增:mux 连续选不中多少次才算失联。 - 1 的话一次总线毛刺就停五台手机;太大的话失联之后还有一大截时间在拿旧数据 - 跑判据。这个数是「先按 3 写、上板实测误码率之后再定」的待定项(计划 §11), - 改它必须连同这条注释一起改。 */ - TEST_ASSERT_EQUAL_INT(3, T_SAFETY_MUX_FAIL_N); - TEST_ASSERT_TRUE(T_SAFETY_MUX_FAIL_N > 1); - - /* 两条新事件必须排在枚举**末尾**:evlog 里存的是这几个数字,插在中间会让 - 已经落盘的旧记录改变含义(`t_safety.h` 那句「flash 格式」)。 */ - TEST_ASSERT_EQUAL_INT(SAFETY_EV_PUSHED_UP + 1, SAFETY_EV_MUX_LOST); - TEST_ASSERT_EQUAL_INT(SAFETY_EV_MUX_LOST + 1, SAFETY_EV_SLOT_EMPTY); - TEST_ASSERT_EQUAL_INT(SAFETY_EV_SLOT_EMPTY + 1, SAFETY_EV_BLADE_MUTE); - - /* 0.1.4 刀片不应答:连续 10 圈 = 200 ms。必须 > 1(一圈毛刺不许关一台手机)。 */ - TEST_ASSERT_EQUAL_INT(10, T_SAFETY_BLADE_MUTE_N); - TEST_ASSERT_EQUAL_UINT32(200u, T_SAFETY_BLADE_MUTE_N * task_desc(TASK_SAFETY)->period_ms); -} - -/* ---- 验收主项一:注入过压 → 20 ms 内关路并记事件(SR-5) ----------------- */ - -static void test_overvoltage_cuts_the_rail_within_one_period_and_logs_it(void) -{ - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); /* §12.1 的 +150 mV */ - - beat(); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 第一次看见:只起表,不动电 */ - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); /* 满 20 ms:这一拍断电 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type); - TEST_ASSERT_EQUAL_UINT8(1, g_ev[0].rail); - TEST_ASSERT_EQUAL_INT32(NOMINAL_MV + 150, g_ev[0].bus_mv); /* 记的是实测值 */ - TEST_ASSERT_EQUAL_INT32(1000000, g_ev[0].current_ua); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.events); -} - -/* 从**第一次看见**到断电正好一个周期。 - ⚠️ 这一条把假总线的耗时关掉(sim_hal.h 允许的那个正当理由:拿虚拟时钟当纯 - 计数器)—— 开着的话每一拍还要加上巡检自己占的 2.34 ms,量出来是 18/22 ms - 之间跳,那几毫秒是巡检花的,不是判据晚了。 */ -static void test_the_cut_lands_exactly_one_hold_window_after_first_sight(void) -{ - sim_i2c_set_bus_time(false); - power_up(2); - inject(2, NOMINAL_MV - 300, 1000000); - - const uint32_t t_first_sight = hal_time_now_ms(); - beat(); - beat(); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(T_SAFETY_DEV_HOLD_MS, g_ev[0].t_ms - t_first_sight); -} - -/* ⭐ 同一件事,把总线耗时打开(也就是真板上的样子):判据必须**照样在第二圈** - 断电。R2 一圈占 2.34 ms,比 R1 的 1.56 ms 更长,相位残差更大。 - - 判据一旦改回按墙钟算 `>= 20 ms`,落在 19 ms 那一侧的情况就要等到第三圈 —— - 40 ms,超了 §12.1 的验收,而且是**间歇性**的(同一个故障注两次,一次过一次不过)。 - 跑 20 轮,各种亚毫秒相位都会踩到。 */ -static void test_the_cut_is_never_late_because_of_sub_millisecond_jitter(void) -{ - power_up(2); - for (int i = 0; i < 20; i++) { - /* 每一轮都从「刚开电」重来,让第一次看见落在不同的亚毫秒相位上。 */ - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(2, true)); - inject(2, NOMINAL_MV, 1000000); - beats(6); /* 熬过缓启动窗口 */ - g_n_ev = 0; - - inject(2, NOMINAL_MV + 150, 1000000); - const uint32_t t0 = hal_time_now_ms(); - beat(); - TEST_ASSERT_EQUAL_INT(1, en_level(2)); /* 第一圈只起表 */ - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(2)); /* 第二圈必须断,一次都不许拖 */ - - /* 墙钟量出来在 20 ms 上下几毫秒:一圈自己占 2.34 ms 总线,加上毫秒截断。 - 那几毫秒是巡检花的,不是判据晚的 —— 上面两条断言才是主判据。 */ - const uint32_t late = g_ev[0].t_ms - t0; - TEST_ASSERT_TRUE(late >= T_SAFETY_DEV_HOLD_MS - 3 && late <= T_SAFETY_DEV_HOLD_MS + 3); - } -} - -/* 偏低同样关路(SR-5 是 ±,不是「过压」)。掉压的现因多半是排线接触不良, - 而那正是这块板子最常见的故障。 */ -static void test_undervoltage_cuts_the_rail_too(void) -{ - power_up(3); - inject(3, NOMINAL_MV - 300, 1000000); - beats(2); - - TEST_ASSERT_EQUAL_INT(0, en_level(3)); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); -} - -/* 「持续」的意思是连续。只闪了一圈就回到范围内的偏差不许关路 —— - 不然一次负载阶跃(0→4 A)就能把五台手机全关掉。 */ -static void test_a_one_cycle_glitch_does_not_cut_anything(void) -{ - power_up(1); - for (int i = 0; i < 20; i++) { - inject(1, NOMINAL_MV + 150, 1000000); - beat(); - inject(1, NOMINAL_MV, 1000000); - beat(); - } - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); -} - -/* 阈值是「**超过** 100 mV」:正好 100 mV 不算。 - 写成 >= 的话,SW-4 允许运维把某一路微调 100 mV 之后那一路就再也开不起来。 */ -static void test_exactly_one_hundred_millivolts_is_still_in_range(void) -{ - power_up(1); - inject(1, NOMINAL_MV + T_SAFETY_DEV_MV, 1000000); - beats(10); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - - inject(1, NOMINAL_MV - T_SAFETY_UNDER_MV, 1000000); - beats(10); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); -} - -/* ⭐ 上板实测那一幕:手机开机峰值 2.1 A 时读回 −115 mV。放宽之前这里会关路, - 把正在开机的手机直接断电。过压那一侧同样的偏差照旧要关。 */ -static void test_boot_current_droop_does_not_cut_the_rail(void) -{ - power_up(1); - inject(1, NOMINAL_MV - 115, 2136000); - beats(10); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - - inject(1, NOMINAL_MV + 115, 1000000); - beats(3); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type); -} - -/* 比的是**该路 DAC 的设定值**,不是 3900 这个常数。SW-4 允许单路微调, - 拿常数比的话,一台被调到 4.010 V 的手机会被当成过压关掉 —— 而且只有那一路。 */ -static void test_the_reference_is_the_setpoint_not_a_constant(void) -{ - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_set_mv(1, RAIL_MV_MAX)); - TEST_ASSERT_EQUAL_INT(RAIL_MV_MAX, rail_setpoint_mv(1)); - - inject(1, RAIL_MV_MAX, 1000000); /* 实测跟着设定走 */ - power_up(1); - beats(10); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - - /* 调到最低档,实测仍是 3900:拿设定值比才是「偏高」。 - (原来是调到最高档再回 3900 判偏低 110 mV —— 欠压放宽到 −250 mV 之后那已经不越界了。) */ - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_set_mv(1, RAIL_MV_MIN)); - inject(1, NOMINAL_MV, 1000000); - beats(2); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type); -} - -/* ---- 判据什么时候**不**武装(三道闸,每一道都是一次真事故的预防) -------- */ - -/* 关着的路不判据。不设这一条的现象是:巡检每 20 ms 就「发现」五路各偏低 - 3900 mV,然后没完没了地去关已经关着的路、刷满事件日志。 */ -static void test_a_rail_that_is_off_is_never_judged(void) -{ - inject(1, 0, 0); /* 关着的路就是 0 V —— 离设定值差着 3900 mV */ - beats(50); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(1, en_writes(1)); /* 只有 rail_en_init() 那一次 */ - TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); /* 也没去写刀片的输出锁存器 */ -} - -/* 刚开电的那几十毫秒不判据:tON 5.1 ms + 斜坡 11.5 ms 期间电压本来就在爬。 - 没有这个窗口的现象是**每次上电都自己把自己关掉**,而且五路一起。 */ -static void test_the_soft_start_window_is_not_judged(void) -{ - inject(1, 1200, 100000); /* 斜坡爬到一半 */ - TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(1, true)); - - const int in_window = T_SAFETY_SETTLE_MS / (int)task_desc(TASK_SAFETY)->period_ms; - beats(in_window); /* 窗口之内:不管电压多离谱都不判 */ - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - - /* 窗口一过,判据武装,还是这么低就该关了。 */ - beats(3); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); -} - -/* 器件失联的那一路不判据(§10.10:「保持供电,标记为盲跑,**禁用该路的 - SR-5 / SW-8 判据**」)。快照里留着的是上一圈的值,拿它去关电是最难查的那种错。 */ -static void test_a_blind_rail_keeps_its_power(void) -{ - power_up(4); - - /* ⚠️ 顺序是有讲究的:**先让坏值真的读进快照一次**,再让器件失联。 - 反过来写(先失联再改寄存器)的话快照里躺着的是个正常值, - 「盲跑那一路照样判据」这个错就一点痕迹都不会留下 —— 测试永远绿。 */ - inject(4, NOMINAL_MV - 300, 1000000); - beat(); - TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* 第一个样本只起表 */ - sim_i2c_set_present_ch(ch_of(4), rail_cfg(4)->ina_addr, false); - - beats(50); /* 1 秒,远超两条判据各自的窗口 */ - TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* §10.10:盲跑 = 保持供电 */ - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - TEST_ASSERT_FALSE(g_snap.rail_ok[3]); - /* 快照里躺着的确实是那个越界的旧值 —— 这是上面那句「顺序有讲究」的凭据。 */ - TEST_ASSERT_EQUAL_INT32(NOMINAL_MV - 300, g_snap.rail[3].bus_mv); - - /* 器件回来了,值还是坏的 —— 这时才该关,而且要重新数满 20 ms。 */ - sim_i2c_set_present_ch(ch_of(4), rail_cfg(4)->ina_addr, true); - beat(); - TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* 第一个新鲜样本只起表 */ - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(4)); -} - -/* ---- 0.1.4:整片刀片不应答 ⇒ 关路(§10.10 新增那一行) ------------------ */ - -static void mute_blade(int rail, bool mute) -{ - sim_i2c_set_present_ch(ch_of(rail), rail_cfg(rail)->ina_addr, !mute); - sim_i2c_set_present_ch(ch_of(rail), PCA9555_ADDR, !mute); -} - -/* 2026-09-28 板 B 第 2 槽的现场:`PRSNT#` 卡低(假在位)、刀片已经拔走、eFuse 还开着。 - 两颗都不答应满 10 圈 ⇒ 关这一路、记一条 BLADE_MUTE;别的路一根线都不动。 */ -static void test_a_mute_blade_is_cut_after_ten_cycles(void) -{ - power_up(2); - power_up(3); - const uint32_t other = en_writes(3); - const uint32_t out0 = g_w[1].n; - mute_blade(2, true); /* PRSNT2# 仍是低:slot_present(2) 照旧为真 */ - - beats(T_SAFETY_BLADE_MUTE_N - 1); - TEST_ASSERT_EQUAL_INT(1, en_level(2)); /* 9 圈:还没到 */ - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(2)); /* 第 10 圈:关 */ - TEST_ASSERT_FALSE(rail_efuse_is_on(2)); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_INT(SAFETY_EV_BLADE_MUTE, g_ev[0].type); - TEST_ASSERT_EQUAL_INT(2, g_ev[0].rail); - /* 没有去写那片刚 NAK 过的 PCA9555(断 VBUS 那一写只会白等超时) */ - TEST_ASSERT_EQUAL_UINT32(out0, g_w[1].n); - /* 第 3 路一根线都没动 */ - TEST_ASSERT_EQUAL_INT(1, en_level(3)); - TEST_ASSERT_EQUAL_UINT32(other, en_writes(3)); - - beats(50); /* 关掉之后不再刷事件 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); -} - -/* 只差一圈就恢复了:从头数,不关。 */ -static void test_a_blade_that_answers_again_restarts_the_count(void) -{ - power_up(2); - mute_blade(2, true); - beats(T_SAFETY_BLADE_MUTE_N - 1); - mute_blade(2, false); - beat(); - mute_blade(2, true); - beats(T_SAFETY_BLADE_MUTE_N - 1); - TEST_ASSERT_EQUAL_INT(1, en_level(2)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(2)); -} - -/* §10.10 原有的两行不变:只有一颗不答应 ⇒ 盲跑、保持供电。 */ -static void test_one_mute_chip_on_the_blade_still_keeps_power(void) -{ - power_up(2); - sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, false); /* 只有 PCA9555 不答应 */ - beats(50); - TEST_ASSERT_EQUAL_INT(1, en_level(2)); - sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, true); - sim_i2c_set_present_ch(ch_of(2), rail_cfg(2)->ina_addr, false); /* 只有 INA226 不答应 */ - beats(50); - TEST_ASSERT_EQUAL_INT(1, en_level(2)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); -} - -/* 关着的路不数:空槽假在位、没人开它,不该每 200 ms 刷一条事件。 */ -static void test_a_mute_blade_that_is_off_is_left_alone(void) -{ - mute_blade(2, true); - beats(50); - TEST_ASSERT_EQUAL_INT(0, en_level(2)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); -} - -/* ---- ⭐ 关路这件事本身:R2 的顺序与「写给了哪一片」 ---------------------- */ - -/* ⭐⭐ 本包最要紧的一条。关路 = **先断 VBUS(锁内、本路通道上)再关 eFuse(锁外)**。 - 三件事一起证: - ① 那一写发生时,假 mux 实际选通的就是这一路 —— 不是别人的通道,也不是全关; - ② 那一刻 I²C 锁在自己手上(放锁之后再拿一次锁去写,中间隔着一次锁争用); - ③ 那一刻本路 `EFUSE_EN` 还没被写 —— 也就是 eFuse 是**之后**才关的。 - 只看两个终值的话,「先关 eFuse 再断 VBUS」和这条长得一模一样,而那个顺序会 - 留下一小段「手机只靠 USB 供着电」的窗口:既不是开,也不是关。 */ -static void test_the_cut_drops_vbus_inside_the_lock_on_this_rails_channel(void) -{ - const int rail = 3; /* CH3 —— DAC 在**另一片**上、mux 通道 2,最容易算错的那一路 */ - power_up(rail); - vbus_on(rail); - watch_reset(); - - const uint32_t en_seq_before = sim_gpio_write_seq(rail_cfg(rail)->efuse_en); - - inject(rail, NOMINAL_MV - 300, 1000000); /* 欠压,不是反灌 */ - beat(); /* 第一圈只起表 */ - TEST_ASSERT_EQUAL_UINT32(0, g_w[rail - 1].n); - - /* 断电那一圈从这里开始数拿锁次数:一圈是五路 + 主干 = 六次 `xact`, - 断 VBUS 那一写必须落在**第 rail 次**那一次里(也就是读这一路的同一次持锁)。 */ - const uint32_t takes_before = sim_lock_takes(HAL_LOCK_I2C); - beat(); - TEST_ASSERT_EQUAL_UINT32((uint32_t)N + 1u, sim_lock_takes(HAL_LOCK_I2C) - takes_before); - - /* 终值:两边都断了。 */ - TEST_ASSERT_EQUAL_INT(0, vbus_pin(rail)); - TEST_ASSERT_EQUAL_INT(0, en_level(rail)); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); - - /* ① 断 VBUS 那一写,落在**这一路自己的通道**上。 */ - TEST_ASSERT_EQUAL_UINT32(1, g_w[rail - 1].n); - TEST_ASSERT_EQUAL_INT(ch_of(rail), g_w[rail - 1].mux_ch); - /* ② 而且是**读这一路的那一次持锁里**写下去的,不是放锁之后重新拿一次锁。 - 重新拿一次锁的代价不是慢:中间隔着一次锁争用,`t_actuator` 完全可能在那 - 一瞬间把通道切到别的刀片上,那一写就落到另一台手机的 PCA9555 上 —— - 别人的 USB 被拔了,出事这一台的 VBUS 还通着,两边都不报错。 */ - TEST_ASSERT_TRUE(g_w[rail - 1].lock_held); - TEST_ASSERT_EQUAL_UINT32((uint32_t)rail, g_w[rail - 1].lock_takes - takes_before); - /* ③ 那一刻 `EFUSE_EN` 还停在断电前的写序号上 ⇒ eFuse 是后关的。 */ - TEST_ASSERT_EQUAL_UINT32(en_seq_before, g_w[rail - 1].efuse_seq); - TEST_ASSERT_TRUE(sim_gpio_write_seq(rail_cfg(rail)->efuse_en) > en_seq_before); - /* 写下去的字节里 VBUS 位是 0(顺带确认没把三个键按下去)。 */ - TEST_ASSERT_EQUAL_UINT8(0, g_w[rail - 1].value); - - /* 另外四片刀片一个字节都没被写过 —— 「写到了另一片上」的直接拦网。 */ - for (int r = 1; r <= N; r++) { - if (r == rail) { - continue; - } - TEST_ASSERT_EQUAL_UINT32(0, g_w[r - 1].n); - TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(r - 1, 0)); - } -} - -/* 只关出事的那一路,另外四路一根线、一个字节都不许动。 */ -static void test_only_the_offending_rail_is_touched(void) -{ - for (int rail = 1; rail <= N; rail++) { - power_up(rail); - vbus_on(rail); - } - watch_reset(); - uint32_t before[N]; - for (int rail = 1; rail <= N; rail++) { - before[rail - 1] = en_writes(rail); - } - - inject(3, NOMINAL_MV + 150, 1000000); - beats(2); - - TEST_ASSERT_EQUAL_INT(0, en_level(3)); - TEST_ASSERT_EQUAL_INT(0, vbus_pin(3)); - for (int rail = 1; rail <= N; rail++) { - if (rail == 3) { - continue; - } - TEST_ASSERT_EQUAL_INT(1, en_level(rail)); - TEST_ASSERT_EQUAL_UINT32(before[rail - 1], en_writes(rail)); - TEST_ASSERT_EQUAL_INT(1, vbus_pin(rail)); /* 别人的 USB 不许被拔 */ - TEST_ASSERT_EQUAL_UINT32(0, g_w[rail - 1].n); - } - /* CH3 的 VBUS 也通着,所以它出的是两条:第一圈 SW-8「被顶高」断 VBUS, - 第二圈 SR-5 关路(两条判据分层,见后面那条专门的用例)。 - 要紧的是这两条**都只指向 CH3**。 */ - TEST_ASSERT_EQUAL_INT(2, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(3, g_ev[0].rail); - TEST_ASSERT_EQUAL_UINT8(3, g_ev[1].rail); -} - -/* 关掉之后判据自己解除武装:不再刷第二条事件,也不再去写那两个东西。 */ -static void test_a_cut_rail_does_not_keep_firing(void) -{ - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); - beats(2); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - - const uint32_t writes = en_writes(1); - watch_reset(); - beats(100); /* 2 秒 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(writes, en_writes(1)); - TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); -} - -/* 被关掉的那一路重新上电之后,确认窗口要**从头数**。 - `dev_n` 关路时不清零的话,这一路下次开电只要看见一个越界样本就立刻跳闸 —— - 少了整个确认窗口,而现象只是「这一路特别容易掉」,查不到根上。 */ -static void test_a_rail_that_comes_back_starts_its_hold_window_over(void) -{ - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); - beats(2); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - - inject(1, NOMINAL_MV, 1000000); /* 修好了,重新上电 */ - power_up(1); - - inject(1, NOMINAL_MV + 150, 1000000); /* 又坏了 */ - beat(); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 第一圈还是只起表,不是立刻跳 */ - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(2, g_n_ev); -} - -/* ⭐ 关路之前必须先 `t_actuator_abort()`。 - 不 abort 的现象最阴:硬重启的 `HR_OFF` 会在 4 s 之后自己把 `EFUSE_EN` 抬回去, - 于是「过压关掉的那一路」几秒钟后又带电了,而事件日志里只有一条「已关路」。 */ -static void test_a_running_hard_reset_cannot_bring_the_rail_back(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(1)); - - /* 断电窗口 4 s 之后 `EFUSE_EN` 被抬回来,序列进入等 PG(PG 永远读不到)。 */ - beats((ACT_HR_OFF_MS + T_SAFETY_SETTLE_MS) / 20 + 2); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(ACT_ST_HR_PG, (int)t_actuator_state(1)); - - inject(1, NOMINAL_MV + 150, 1000000); - beats(2); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); - - /* 再跑 10 秒 —— 电不许自己回来。 */ - beats(500); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); -} - -/* abort 顺带把按下去的键松开了。按键序列跑到一半被过压打断, - 键要是留在按下状态,手机再也起不来,而遥测上五路电压电流全都正常。 - ⚠️ R2 里「键」在刀片 PCA9555 的 P0.0 上,所以这一条顺带也证明了收尾那一次 - I²C 写落到了正确的那一片上。 */ -static void test_a_cut_releases_whatever_key_was_held(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON)); - beat(); - TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_PWR)); - - inject(1, NOMINAL_MV + 150, 1000000); - beats(3); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_PWR)); - TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); - /* 别的四片的键也没被顺手按下(影子记在另一片上的经典后果)。 */ - for (int r = 2; r <= N; r++) { - TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(r - 1, 0)); - } -} - -/* ---- 验收主项二:注入 −60 mA → 断 VBUS(SW-8) --------------------------- */ - -static void test_reverse_current_drops_vbus(void) -{ - power_up(1); - vbus_on(1); - watch_reset(); - const uint32_t en_w = en_writes(1); - inject(1, NOMINAL_MV, -60000); /* §12.1 的 −60 mA */ - - const uint32_t t0 = hal_time_now_ms(); - const uint32_t takes_before = sim_lock_takes(HAL_LOCK_I2C); - beat(); - TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* **只断 VBUS**,直供不动 */ - TEST_ASSERT_EQUAL_UINT32(en_w, en_writes(1)); /* eFuse 那根线一次都没被碰 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_REVERSE, g_ev[0].type); - TEST_ASSERT_EQUAL_INT32(-60000, g_ev[0].current_ua); - - /* ⭐ 这一写同样是锁内、本路通道上的(SW-8 走的是和 SR-5 同一条路)。 */ - TEST_ASSERT_EQUAL_UINT32(1, g_w[0].n); - TEST_ASSERT_EQUAL_INT(ch_of(1), g_w[0].mux_ch); - TEST_ASSERT_TRUE(g_w[0].lock_held); - /* CH1 是本圈第一次 `xact`:断 VBUS 那一写就在那一次持锁里。 */ - TEST_ASSERT_EQUAL_UINT32(1, g_w[0].lock_takes - takes_before); - TEST_ASSERT_EQUAL_UINT32((uint32_t)N + 1u, sim_lock_takes(HAL_LOCK_I2C) - takes_before); - - /* SW-8 的预算是 200 ms,实际是一圈 + 一次事务。 */ - TEST_ASSERT_TRUE(hal_time_elapsed_ms(t0) < (uint32_t)T_SAFETY_REV_BUDGET_MS); -} - -/* 反灌**不等确认窗口**(与过压判据有意不对称):一圈看见就断。 - 误断 VBUS 只丢一次 USB 数据,手机还由直供电着;漏判会把能量泵回 12V 母线, - **连累另外四台**。 */ -static void test_reverse_current_is_not_debounced(void) -{ - power_up(1); - vbus_on(1); - inject(1, NOMINAL_MV, -60000); - beat(); /* 就一圈 */ - TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); -} - -/* 没到 −50 mA 不算。手机在直供下本来就会有小幅的双向电流。 */ -static void test_a_small_negative_current_is_not_a_backfeed(void) -{ - power_up(1); - vbus_on(1); - watch_reset(); - inject(1, NOMINAL_MV, -40000); - beats(50); - TEST_ASSERT_EQUAL_INT(1, vbus_pin(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); -} - -/* VBUS 本来就断着的时候不判反灌:这条判据只在「USB 那一侧通着」时武装。 - 也正是它让「断完之后不再每 20 ms 刷一条事件」成立。 */ -static void test_backfeed_is_only_judged_while_vbus_is_on(void) -{ - power_up(1); /* VBUS 没开 */ - watch_reset(); - inject(1, NOMINAL_MV, -200000); - beats(50); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); /* 一次都没去写刀片 */ -} - -/* 断完之后不会每 20 ms 再刷一条事件、也不会每 20 ms 再发一次 I²C 写。 - 会刷的话,事件日志那块 flash 每秒被写 50 次 —— 判据「一直在响」和判据 - 「不灵」一样糟,而前者还会把真正的那条事件淹掉。 */ -static void test_a_backfeed_does_not_refire_every_cycle(void) -{ - power_up(1); - vbus_on(1); - inject(1, NOMINAL_MV, -60000); - beat(); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - - watch_reset(); - beats(100); /* 2 秒,故障一直在 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); -} - -/* 顶高:VBUS 期间电压被抬过设定值 +100 mV,同样立刻断 VBUS(设计文档 §15.2)。 - 然后 20 ms 后 SR-5 才决定要不要把这一路也关掉 —— 两条判据是分层的: - 先撤掉能量来源,再决定要不要关电。 */ -static void test_being_pushed_up_drops_vbus_first_then_sr5_cuts_the_rail(void) -{ - power_up(1); - vbus_on(1); - inject(1, NOMINAL_MV + 150, 1000000); - - beat(); - TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); /* 第一圈:VBUS 没了 */ - TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 电还开着 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_PUSHED_UP, g_ev[0].type); - - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); /* 第二圈:还是高,SR-5 关路 */ - TEST_ASSERT_EQUAL_INT(2, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[1].type); -} - -/* ⭐ 断 VBUS 之前要打断 EDL 序列 —— 三个序列里只有它会写 `VBUS_EN`, - 不打断的话下一拍它就把 VBUS 又抬回来了(`EDL_EN=1 → VBUS_EN=1 → 等枚举`)。 */ -static void test_a_running_edl_sequence_cannot_bring_vbus_back(void) -{ - power_up(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_edl(1)); - beats(2); /* 短接 10 ms 之后 VBUS_EN 被抬起来,进入等枚举 */ - TEST_ASSERT_EQUAL_INT(1, vbus_pin(1)); - TEST_ASSERT_EQUAL_INT(ACT_ST_EDL_ENUM, (int)t_actuator_state(1)); - - inject(1, NOMINAL_MV, -60000); - beat(); - TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); - TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); - - beats(200); /* 4 秒 —— 枚举窗口 3 s 早过了,VBUS 不许自己回来 */ - TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); -} - -/* 反灌不去打断跟 VBUS 无关的序列(按键只碰 P0.0~2,硬重启只碰 `EFUSE_EN`)—— - 「出了事就把这一路上所有事都停掉」比看上去危险:那会把一次正在进行的 - 救砖重启打断,而重启和反灌是两码事。 */ -static void test_a_backfeed_does_not_abort_an_unrelated_sequence(void) -{ - power_up(1); - vbus_on(1); - TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_VUP, ACT_KEY_MS_ON)); - - inject(1, NOMINAL_MV, -60000); - beat(); - TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); - TEST_ASSERT_TRUE(t_actuator_busy(1)); - TEST_ASSERT_EQUAL_INT(ACT_ST_KEY_HOLD, (int)t_actuator_state(1)); - /* 键还按着 —— 断 VBUS 那一次写不许把同一个字节里的键位顺手清掉。 */ - TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_VUP)); -} - -/* 五路各判各的:CH5 反灌不许动 CH2 的 VBUS。 */ -static void test_backfeed_is_per_rail(void) -{ - power_up(2); - power_up(5); - vbus_on(2); - vbus_on(5); - - inject(5, NOMINAL_MV, -60000); - beats(5); - TEST_ASSERT_EQUAL_INT(0, vbus_pin(5)); - TEST_ASSERT_EQUAL_INT(1, vbus_pin(2)); - TEST_ASSERT_EQUAL_INT(1, en_level(5)); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(5, g_ev[0].rail); -} - -/* ---- ⭐ R2 新增判据之一:`PRSNT#`(刀片不在位) -------------------------- */ - -/* 刀片被拔了而这一路还开着 ⇒ 立刻关路 + 记 `SAFETY_EV_SLOT_EMPTY`。 - 空槽带电 = 金手指裸露带 12V,手一碰就是短路,而电压电流读出来什么异常都没有 - (那一路的 INA226 跟着刀片一起走了,快照里留的是上一圈的旧值)。 */ -static void test_an_empty_slot_that_is_powered_gets_cut_at_once(void) -{ - power_up(2); - TEST_ASSERT_TRUE(g_snap.present[1]); - - sim_gpio_drive(rail_cfg(2)->prsnt_n, 1); /* 刀片拔走:上拉把 PRSNT# 拉高 */ - beat(); - - TEST_ASSERT_FALSE(g_snap.present[1]); - TEST_ASSERT_EQUAL_INT(0, en_level(2)); /* 不等确认窗口,这一圈就断 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_SLOT_EMPTY, g_ev[0].type); - TEST_ASSERT_EQUAL_UINT8(2, g_ev[0].rail); - - /* 别的四路一根线都没动(`PRSNT#` 是一路一根,不是一根线或)。 */ - for (int rail = 1; rail <= N; rail++) { - if (rail != 2) { - TEST_ASSERT_TRUE(g_snap.present[rail - 1]); - } - } - - /* 断完之后不再刷第二条 —— 判据只在「本来是开着的」时候动作。 */ - beats(100); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); -} - -/* ⭐ 专门的一条:**五个槽全空、五路全关**(也就是这块板子刚上电、还没插刀片的 - 样子)—— 判据一条事件都不许出。 - 少了「本来是开着的」这个前提,现象是开机就以每秒 250 条的速度刷 - `SLOT_EMPTY`,evlog 那块 flash 几分钟就写穿,而且真出事那条会被淹掉。 */ -/* ⭐ `PRSNT#` 接触不良(刀片还在、PCA9555 还应答)时,VBUS 也要断掉。 - - 这条和上面那条的区别是**刀片在不在**: - · 真拔走了 —— PCA9555 跟着走了,断 VBUS 那一步必然 NAK,无所谓; - · 金手指 B31 虚接 / `PRSNT#` 接触不良 —— 刀片还在、总线还通, - 这时只关 `EFUSE_EN` 会留下「直供断了、USB 还灌着」的半开状态: - 手机靠 VBUS 吊着命,遥测上这一路电压是 0、电流也接近 0,看着像"已经关了"。 - 所以判据要两样都断。⚠️ 顺序是**先 eFuse(GPIO,必成)再 VBUS(I²C,可能失败)**: - 断电优先,不能为了一次可能等锁的 I²C 事务把关电推后。 */ -static void test_an_empty_slot_also_drops_vbus_when_the_blade_still_answers(void) -{ - power_up(3); - vbus_on(3); - TEST_ASSERT_EQUAL_INT(1, vbus_pin(3)); - - /* 只把 `PRSNT#` 拉高 —— 刀片本身(假 PCA9555)还在总线上好好应答着。 */ - sim_gpio_drive(rail_cfg(3)->prsnt_n, 1); - beat(); - - TEST_ASSERT_FALSE(g_snap.present[2]); - TEST_ASSERT_EQUAL_INT(0, en_level(3)); /* 直供断了 */ - TEST_ASSERT_EQUAL_INT(0, vbus_pin(3)); /* ⭐ VBUS 也断了,不留半开状态 */ - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_SLOT_EMPTY, g_ev[0].type); - - /* 别的四路的 VBUS 一个都没被顺手带下去。 */ - for (int rail = 1; rail <= N; rail++) { - if (rail != 3) { - TEST_ASSERT_EQUAL_INT(0, vbus_pin(rail)); /* 本来就没开,确认没被乱开 */ - } - } - - /* 断完不再刷第二条。 */ - beats(50); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); -} - -static void test_empty_slots_that_are_off_never_fire(void) -{ - for (int rail = 1; rail <= N; rail++) { - sim_gpio_drive(rail_cfg(rail)->prsnt_n, 1); - } - watch_reset(); - beats(200); /* 4 秒 */ - - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_FALSE(g_snap.present[rail - 1]); - TEST_ASSERT_EQUAL_UINT32(1, en_writes(rail)); /* 只有 rail_en_init() 那一次 */ - TEST_ASSERT_EQUAL_UINT32(0, g_w[rail - 1].n); - } -} - -/* 插回去之后判据自己解除 —— 空槽判据读的是**这一圈**的引脚,不是一个一去不返的闩。 - 不重读的现象是「热插拔之后这一路再也开不起来」,而 `PRSNT#` 上量出来是好的。 */ -static void test_a_slot_that_comes_back_can_be_powered_again(void) -{ - power_up(1); - sim_gpio_drive(rail_cfg(1)->prsnt_n, 1); - beat(); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - - sim_gpio_drive(rail_cfg(1)->prsnt_n, 0); /* 刀片插回来了 */ - g_n_ev = 0; - power_up(1); - beats(20); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); -} - -/* ---- ⭐ R2 新增判据之二:mux 失联(两段式) ------------------------------ */ - -/* TCA9548A 是单点:它一挂,五路的电压电流**全部看不见**,SR-5 的整条监控链断了。 - 这时「继续供电但看不见」比「停机」危险 —— 所以判据是五路全断。 - - ⭐ 但**必须先脉冲一次 `MUX_RST#` 再判**:一次总线毛刺就停五台手机,代价太大。 - 这条用例把两段拆开看:第一圈只复位、电一路都不许断;第二圈才全断。 - 少了复位那一步的实现,第一圈就会把五台机器全停掉 —— 而现象只是「偶尔全体掉电」。 */ -static void test_a_lost_mux_resets_once_before_it_cuts_all_five(void) -{ - for (int rail = 1; rail <= N; rail++) { - power_up(rail); - } - const uint32_t rst0 = sim_i2c_mux_resets(); - - sim_i2c_set_present(TCA9548A_ADDR, false); /* mux 失联 */ - - /* 第一圈:连续选不中够数了,**先复位**。五路电一路都不许断。 */ - beat(); - TEST_ASSERT_TRUE(i2cbus_select_fail_streak() >= (uint32_t)T_SAFETY_MUX_FAIL_N); - TEST_ASSERT_EQUAL_UINT32(rst0 + 1u, sim_i2c_mux_resets()); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_recovers); - TEST_ASSERT_EQUAL_UINT32(0, g_snap.mux_trips); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT(1, en_level(rail)); - } - - /* 第二圈:复位过了还是选不中 ⇒ 认定失联,五路全断,记一条整板级事件。 */ - beat(); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_MUX_LOST, g_ev[0].type); - TEST_ASSERT_EQUAL_UINT8(0, g_ev[0].rail); /* ⚠️ 整板级事件,rail 是 0 */ - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT(0, en_level(rail)); - TEST_ASSERT_FALSE(rail_efuse_is_on(rail)); - } - - /* 第三圈往后:既不再复位,也不再刷事件。 - 两个数字里任何一个每 20 ms 涨一次,都是「判据一直在响」—— 五路已经断了, - 再断一次没有任何用处,而 evlog 那块 flash 一分钟就被写 3000 次。 */ - beats(100); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_recovers); - TEST_ASSERT_EQUAL_UINT32(rst0 + 1u, sim_i2c_mux_resets()); -} - -/* mux 回来之后判据重新武装:下一次失联还要老老实实走「先复位、再全断」两段。 - 不重新武装的现象是「第二次失联直接全断,没有那一次挽救性的复位」。 */ -static void test_the_mux_rule_rearms_after_the_bus_comes_back(void) -{ - power_up(1); - sim_i2c_set_present(TCA9548A_ADDR, false); - beats(2); /* 复位一次 → 全断一次 */ - TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); - - sim_i2c_set_present(TCA9548A_ADDR, true); /* 总线回来了 */ - beats(3); - TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); - TEST_ASSERT_EQUAL_INT(1, g_n_ev); /* 回来这件事本身不是事件 */ - - /* 再挂一次:必须还是「先复位,下一圈才全断」。 */ - sim_i2c_set_present(TCA9548A_ADDR, false); - beat(); - TEST_ASSERT_EQUAL_UINT32(2, g_snap.mux_recovers); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); /* 这一圈还不许断 */ - beat(); - TEST_ASSERT_EQUAL_UINT32(2, g_snap.mux_trips); - TEST_ASSERT_EQUAL_INT(2, g_n_ev); -} - -/* 一次毛刺不许停五台机器:连续失败 N−1 次之后又选中了,判据一动不动。 - 判据要是拿「累计失败次数」而不是「连续」去判,跑上几个小时的板子一定会踩到 —— - 而现象是「半夜五台机器一起掉电,早上看日志只有一条 mux 失联」。 */ -static void test_a_short_glitch_does_not_stop_five_phones(void) -{ - for (int rail = 1; rail <= N; rail++) { - power_up(rail); - } - const uint32_t rst0 = sim_i2c_mux_resets(); - - for (int i = 0; i < 20; i++) { - /* 每一圈都毛刺 N−1 次 —— 加起来远超 N,但一次也没有「连续 N 次」。 */ - sim_i2c_inject_nak(TCA9548A_ADDR, T_SAFETY_MUX_FAIL_N - 1); - beat(); - TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); - } - TEST_ASSERT_TRUE(i2cbus_select_fails() >= 20u * (uint32_t)(T_SAFETY_MUX_FAIL_N - 1)); - TEST_ASSERT_EQUAL_UINT32(rst0, sim_i2c_mux_resets()); - TEST_ASSERT_EQUAL_UINT32(0, g_snap.mux_trips); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - for (int rail = 1; rail <= N; rail++) { - TEST_ASSERT_EQUAL_INT(1, en_level(rail)); - } -} - -/* ---- 判据不许改变巡检的形状 ---------------------------------------------- */ - -/* 判据本身一条 I²C 事务也不许发:它要的三个量(设定值、`EFUSE_EN` 的状态、 - 正在跑的序列)全部有影子可查。多读一次就把计划 §5.5 的 12.4 % 顶穿。 - - ⚠️ R2 里**断 VBUS 本身是一次 I²C 写**(`VBUS_EN` 在 PCA9555 上),这是设计, - 不是「判据多读了东西」。所以这里分开数:起表那一圈严格等于一圈的名义事务数, - 断电那一圈恰好多**一次**(就那一次写),不许多出第二次。 */ -static void test_the_rules_do_not_add_any_i2c_traffic(void) -{ - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); - - beat(); /* 起表那一圈:判据干了活(起表),但一个字节也没多发 */ - TEST_ASSERT_EQUAL_INT(1, en_level(1)); - TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); - TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us); - - beat(); /* 断电那一圈:只多出「断 VBUS」那一次写(2 字节) */ - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns() + 1u, g_snap.txns); - TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us() + - (uint32_t)hal_i2c_bits_to_us(hal_i2c_txn_bits(2, 0)), - g_snap.bus_us); - - /* 关掉之后回到名义值 —— 判据不会在后面的圈里继续加事务。 */ - beat(); - TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); -} - -/* 加锁纪律:整圈(含判据、含断 VBUS 那一次写、含锁外关 eFuse)违规为 0。 */ -static void test_the_whole_cycle_keeps_the_lock_discipline(void) -{ - power_up(1); - vbus_on(1); - sim_lock_require(HAL_LOCK_I2C, true); - - TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON)); - inject(1, NOMINAL_MV + 150, 1000000); - beats(5); - - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); - TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); - TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); /* 出门放锁 */ -} - -/* 上一条不是句空话:不拿锁直接读一片 INA226,违规必须 +1。 - 检查本身失灵的话,上一条会永远绿 —— 而「加锁纪律测试永远绿」正是最坏的情形。 */ -static void test_the_lock_check_itself_can_fail(void) -{ - sim_lock_require(HAL_LOCK_I2C, true); - const uint32_t before = sim_lock_violations(HAL_LOCK_I2C); - ina226_vi_t vi; - (void)ina226_read_vi(rail_cfg(1)->ina_addr, &vi); - TEST_ASSERT_EQUAL_UINT32(2, sim_lock_violations(HAL_LOCK_I2C) - before); /* 两次事务 */ -} - -/* 拿不到总线锁的那一圈整圈作废,判据一条都不许跑 —— 那一圈的值是上一圈的。 - 跑的话,「总线忙」就会被当成「电压没变」,而实际上根本没读。 */ -static void test_a_skipped_cycle_runs_no_rules(void) -{ - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); - beat(); /* 起表 */ - - sim_lock_set_task(1); /* 假装 t_actuator 占着总线 */ - TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); - sim_lock_set_task(0); - - TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&g_snap)); - TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 满 20 ms 了,但这一圈没数 */ - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped); - - sim_lock_set_task(1); - hal_lock_give(HAL_LOCK_I2C); - sim_lock_set_task(0); - beat(); /* 总线回来了,这一圈才断电 */ - TEST_ASSERT_EQUAL_INT(0, en_level(1)); -} - -/* 没接事件出口也照样断电(A11 还没来,日志接不上不是不断电的理由)。 */ -static void test_the_cut_happens_even_with_no_event_sink(void) -{ - t_safety_set_event_sink(NULL, NULL); - power_up(1); - inject(1, NOMINAL_MV + 150, 1000000); - beats(2); - TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_INT(0, g_n_ev); - TEST_ASSERT_EQUAL_UINT32(1, g_snap.events); /* 快照里仍然记着 */ - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_snap.last.type); -} - -int main(void) -{ - UNITY_BEGIN(); - RUN_TEST(test_thresholds_match_the_documents); - - RUN_TEST(test_overvoltage_cuts_the_rail_within_one_period_and_logs_it); - RUN_TEST(test_the_cut_lands_exactly_one_hold_window_after_first_sight); - RUN_TEST(test_the_cut_is_never_late_because_of_sub_millisecond_jitter); - RUN_TEST(test_undervoltage_cuts_the_rail_too); - RUN_TEST(test_a_one_cycle_glitch_does_not_cut_anything); - RUN_TEST(test_exactly_one_hundred_millivolts_is_still_in_range); - RUN_TEST(test_boot_current_droop_does_not_cut_the_rail); - RUN_TEST(test_the_reference_is_the_setpoint_not_a_constant); - - RUN_TEST(test_a_rail_that_is_off_is_never_judged); - RUN_TEST(test_the_soft_start_window_is_not_judged); - RUN_TEST(test_a_blind_rail_keeps_its_power); - RUN_TEST(test_a_mute_blade_is_cut_after_ten_cycles); - RUN_TEST(test_a_blade_that_answers_again_restarts_the_count); - RUN_TEST(test_one_mute_chip_on_the_blade_still_keeps_power); - RUN_TEST(test_a_mute_blade_that_is_off_is_left_alone); - - RUN_TEST(test_the_cut_drops_vbus_inside_the_lock_on_this_rails_channel); - RUN_TEST(test_only_the_offending_rail_is_touched); - RUN_TEST(test_a_cut_rail_does_not_keep_firing); - RUN_TEST(test_a_rail_that_comes_back_starts_its_hold_window_over); - RUN_TEST(test_a_running_hard_reset_cannot_bring_the_rail_back); - RUN_TEST(test_a_cut_releases_whatever_key_was_held); - - RUN_TEST(test_reverse_current_drops_vbus); - RUN_TEST(test_reverse_current_is_not_debounced); - RUN_TEST(test_a_small_negative_current_is_not_a_backfeed); - RUN_TEST(test_backfeed_is_only_judged_while_vbus_is_on); - RUN_TEST(test_a_backfeed_does_not_refire_every_cycle); - RUN_TEST(test_being_pushed_up_drops_vbus_first_then_sr5_cuts_the_rail); - RUN_TEST(test_a_running_edl_sequence_cannot_bring_vbus_back); - RUN_TEST(test_a_backfeed_does_not_abort_an_unrelated_sequence); - RUN_TEST(test_backfeed_is_per_rail); - - RUN_TEST(test_an_empty_slot_that_is_powered_gets_cut_at_once); - RUN_TEST(test_an_empty_slot_also_drops_vbus_when_the_blade_still_answers); - RUN_TEST(test_empty_slots_that_are_off_never_fire); - RUN_TEST(test_a_slot_that_comes_back_can_be_powered_again); - - RUN_TEST(test_a_lost_mux_resets_once_before_it_cuts_all_five); - RUN_TEST(test_the_mux_rule_rearms_after_the_bus_comes_back); - RUN_TEST(test_a_short_glitch_does_not_stop_five_phones); - - RUN_TEST(test_the_rules_do_not_add_any_i2c_traffic); - RUN_TEST(test_the_whole_cycle_keeps_the_lock_discipline); - RUN_TEST(test_the_lock_check_itself_can_fail); - RUN_TEST(test_a_skipped_cycle_runs_no_rules); - RUN_TEST(test_the_cut_happens_even_with_no_event_sink); - return UNITY_END(); -} +/* A10-R2 之二:安全巡检的**判据**(SR-5 / SW-8 + R2 新增的两条)。 + 一圈的形状与总线账在 `test_t_safety.c` 里,这里只管「看见什么、做什么、按什么顺序」。 + + 计划里这一包的验收原话是「注入过压 → 20 ms 内关路并记事件;注入 −60 mA → 断 VBUS」, + R2 又加了两条:「刀片不在位 ⇒ 不许带电」「mux 失联 ⇒ 五路全断」(计划 §5.8)。 + + ---- R2 把这一层最要紧的一件事换掉了 ------------------------------------ + + R1:`EFUSE_EN` 与 `VBUS_EN` 是一对 GPIO,断哪个都是几百纳秒,顺序靠 + `sim_gpio_write_seq()` 两个序号一比就证明了。 + + R2:**`VBUS_EN` 搬到了刀片 PCA9555 的 P0.4 上**(计划 §5.4)。于是 + · 断 VBUS 变成一次跨 mux 的 I²C 事务,**必须在锁内、在本路的 `xact` 里做** + —— 放锁之后再拿一次锁,中间隔着一次锁争用,而且那时通道可能已经被别人 + 切走了,那一写就落到**另一片刀片**上:另一台手机的 USB 被拔了, + 出事那一台的 VBUS 还通着。两边都不报错。 + · `EFUSE_EN` 相反,它仍是 GPIO 直连,**放锁之后**关才对。 + ⭐ 带星的三条用例就是这件事的验收:不光要证明「顺序对」,还要证明 + **断 VBUS 那一写发生时,mux 正选在这一路上**。 + + ⚠️ 断言一律打在假器件的可观测面上:`EFUSE_EN` 看 `sim_gpio_level()` 与全局写 + 序号,`VBUS_EN` 看 `sim_pca9555_pin()`(栅极上的实际电平)与一个挂在假 PCA9555 + 上的钩子,电压电流从**假 INA226 的寄存器**注入。对着驱动自己的影子断言等于 + 自己证明自己 —— 影子是写成功才更新的,「写到了另一片上」它一个字都不会说。 + + 本文件里的 `beat()` 就是真板上两个任务合起来干的事:推一步执行器、跑一圈巡检、 + 睡到下一个 20 ms 格子。判据的「持续 20 ms」只有在真的按 20 ms 打拍子时才有意义。 */ +#include + +#include "board_pins.h" +#include "chgmode.h" +#include "hal_gpio.h" +#include "hal_i2c.h" +#include "hal_i2c_acct.h" +#include "hal_lock.h" +#include "hal_time.h" +#include "i2cbus.h" +#include "ina226.h" +#include "pca9555.h" +#include "phonerail.h" +#include "rail.h" +#include "rail_cfg.h" +#include "sched.h" +#include "sim_hal.h" +#include "slot.h" +#include "t_actuator.h" +#include "t_safety.h" +#include "tasks.h" +#include "tca9548a.h" +#include "unity.h" + +#define N PHONERAIL_RAIL_COUNT + +/* 稳态:五路都设在 3900 mV(`RAIL_MV_SAFE`),电流 1 A 正向。 */ +#define NOMINAL_MV 3900 + +#define DAC_ADDR_A 0x4A +#define DAC_ADDR_B 0x4B + +static safety_snap_t g_snap; +static sched_periodic_t g_tick; + +static int ch_of(int rail) { return (int)rail_cfg(rail)->mux_ch; } + +/* ---- ⭐ 盯住每一片刀片的 Port0 是「什么时候、在哪个通道上」被写的 -------- + + 这是 R2 规矩 3 唯一的直接凭据。只看终值的话,下面三种实现长得一模一样: + ① 锁内、通道选在本路上写(对的); + ② 放锁之后重新拿锁再写(此刻通道可能已经被 `t_actuator` 切走了); + ③ 干脆写到了另一片上(那台手机的 USB 被拔,出事这台的 VBUS 还通着)。 + 所以在假器件的写钩子里把「这一刻 mux 选在几号通道、锁在不在手上、本路的 + `EFUSE_EN` 写到第几号了」一起记下来。 */ +typedef struct { + uint32_t n; /* 这一片的 Port0 被写过几次 */ + int mux_ch; /* 最后一次写发生时,假 mux 实际选通的是哪一路 */ + bool lock_held; /* 最后一次写发生时,I²C 锁在不在自己手上 */ + uint32_t efuse_seq; /* 那一刻本路 `EFUSE_EN` 的全局写序号(0 = 还没被写过) */ + /* ⭐ 那一刻 I²C 锁**总共被拿过几次**。这个数才是「锁内还是锁外」的硬凭据: + `hal_lock_held()` 在「放锁之后重新拿一次锁再写」的实现里**同样是 true** + (那时锁确实在自己手上),通道也同样是选对的 —— 两条断言都拦不住它。 + 而「重新拿一次锁」必然让本圈的拿锁次数多出一次,这个数骗不了人。 */ + uint32_t lock_takes; + uint8_t value; /* 落进输出锁存器的字节 */ +} out0_watch_t; + +static out0_watch_t g_w[N]; +static sim_i2c_hook_t g_inner_hook; /* 假 PCA9555 自己那一份,必须接着跑 */ + +static void watch_hook(sim_i2c_dev_t *d, sim_i2c_ev_t ev) +{ + if (g_inner_hook != NULL) { + g_inner_hook(d, ev); /* 先让假器件把输入寄存器合成好 / 查危险方向 */ + } + if (ev != SIM_I2C_EV_POST_WRITE) { + return; + } + /* 只关心「写输出锁存器」那一种写;读输入时线上也有一个字节的寄存器指针。 */ + if (d->last_write_len < 2 || d->last_write[0] != PCA9555_REG_OUTPUT0) { + return; + } + for (int rail = 1; rail <= N; rail++) { + if (ch_of(rail) != d->bus) { + continue; + } + out0_watch_t *w = &g_w[rail - 1]; + w->n++; + w->mux_ch = sim_i2c_mux_ch(); + w->lock_held = hal_lock_held(HAL_LOCK_I2C); + w->efuse_seq = sim_gpio_write_seq(rail_cfg(rail)->efuse_en); + w->lock_takes = sim_lock_takes(HAL_LOCK_I2C); + w->value = d->last_write[1]; + } +} + +static void watch_reset(void) { memset(g_w, 0, sizeof(g_w)); } + +/* ---- 事件出口:把 A11 的环形缓冲换成一个能断言的小盒子 ------------------- */ + +static safety_event_t g_ev[8]; +static int g_n_ev; +/* 回调被调的那一刻锁都不该被持着(t_safety.h:回调是放锁之后才调的)。 */ +static bool g_ev_held_lock; +/* 事件被记下来的那一刻,动作**必须已经做完了**(先保命,再记账)。 + 顺序反过来在功能上看不出任何区别 —— 除非像这里一样,在记录的那一刻回头看线。 */ +static bool g_ev_before_action; + +static void catch_event(void *ctx, const safety_event_t *ev) +{ + (void)ctx; + if (hal_lock_held(HAL_LOCK_I2C)) { + g_ev_held_lock = true; + } + switch ((safety_ev_t)ev->type) { + case SAFETY_EV_OVER_VOLT: + case SAFETY_EV_UNDER_VOLT: + case SAFETY_EV_SLOT_EMPTY: + case SAFETY_EV_BLADE_MUTE: + if (rail_efuse_is_on(ev->rail)) { + g_ev_before_action = true; /* 记这条时电早该断了 */ + } + break; + case SAFETY_EV_REVERSE: + case SAFETY_EV_PUSHED_UP: + if (slot_vbus_is_on(ev->rail)) { + g_ev_before_action = true; /* SW-8:记这条时 VBUS 早该断了 */ + } + break; + case SAFETY_EV_MUX_LOST: + for (int rail = 1; rail <= N; rail++) { + if (rail_efuse_is_on(rail)) { + g_ev_before_action = true; /* 五路早该全断了 */ + } + } + break; + default: + break; + } + if (g_n_ev < (int)(sizeof(g_ev) / sizeof(g_ev[0]))) { + g_ev[g_n_ev] = *ev; + } + g_n_ev++; +} + +/* ---- 注入与观测 ---------------------------------------------------------- */ + +/* 从**那一路通道上**那片假 INA226 的寄存器注入。直接改快照等于跳过驱动和 mux, + 那样连「读的是哪一片」都证明不了。 */ +static void inject(int rail, int mv, int ua) +{ + TEST_ASSERT_EQUAL_INT(0, mv % 5); + sim_i2c_set_reg_ch(ch_of(rail), rail_cfg(rail)->ina_addr, INA226_REG_BUS_V, + (uint16_t)(mv * 4 / 5)); + sim_i2c_set_reg_ch(ch_of(rail), rail_cfg(rail)->ina_addr, INA226_REG_CURRENT, + (uint16_t)(int16_t)(ua / INA226_CURRENT_LSB_UA)); +} + +static int en_level(int rail) { return sim_gpio_level(rail_cfg(rail)->efuse_en); } +static uint32_t en_writes(int rail) { return sim_gpio_writes(rail_cfg(rail)->efuse_en); } + +/* `VBUS_EN` 在刀片 PCA9555 的 P0.4 上。看的是**栅极上的实际电平**, + 不是 `slot_vbus_is_on()`(那是驱动自己的影子)。 */ +static int vbus_pin(int rail) +{ + return sim_pca9555_pin(rail - 1, 0, PCA9555_BIT_VBUS_EN); +} + +/* ---- 假板子 -------------------------------------------------------------- */ + +static void board_up(void) +{ + const hal_i2c_cfg_t cfg = { .sda_gpio = BOARD_PIN_I2C_SDA, + .scl_gpio = BOARD_PIN_I2C_SCL, + .freq_hz = 400000 }; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); + + sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N); + for (int rail = 1; rail <= N; rail++) { + const int ch = ch_of(rail); + sim_i2c_add_ch(ch, rail_cfg(rail)->ina_addr, 2); + inject(rail, NOMINAL_MV, 1000000); /* 3.900 V / 1 A,稳稳当当 */ + sim_pca9555_attach_ch(rail - 1, ch, PCA9555_ADDR); + sim_pca9555_drive(rail - 1, 1, 0x0F); /* 四个开漏信号静息全高 = 没有故障 */ + } + sim_i2c_add(INA226_ADDR_INPUT, 2); + sim_i2c_add(DAC_ADDR_A, 2); + sim_i2c_add(DAC_ADDR_B, 2); + + /* 钩子要挂在假 PCA9555 自己那一份**之后**(链上去,不是顶掉 —— + 顶掉的话输入寄存器就没人合成,五路的 PG/FLT 会全读成 0)。 */ + g_inner_hook = sim_i2c_dev_ch(ch_of(1), PCA9555_ADDR)->hook; + TEST_ASSERT_NOT_NULL(g_inner_hook); + for (int rail = 1; rail <= N; rail++) { + sim_i2c_dev_ch(ch_of(rail), PCA9555_ADDR)->hook = watch_hook; + } + + TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); /* 五根 EN 线,全部拉低 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_prsnt_init()); + + /* ⚠️ `PRSNT#` 是上拉输入,没人驱动就是高 = 刀片不在位。不驱低的话每一圈都会 + 触发「刀片不在位」判据 —— 现象是所有用例都莫名多出五条事件。 */ + for (int rail = 1; rail <= N; rail++) { + sim_gpio_drive(rail_cfg(rail)->prsnt_n, 0); + } + slot_prsnt_poll(); + + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail)); + } + rail_setpoint_reset(); /* 五路设定值回到 NVM 默认 */ + t_actuator_init(NULL, NULL); /* PG「永远读不到」—— 硬重启会走完并记 NO_PG */ + + g_n_ev = 0; + g_ev_held_lock = false; + g_ev_before_action = false; + memset(g_ev, 0, sizeof(g_ev)); + watch_reset(); /* `slot_init()` 写过一次 OUTPUT0,不算在用例头上 */ + t_safety_set_event_sink(catch_event, NULL); + + t_safety_init(&g_snap); + chgmode_reset(); /* 窗口表是静态的,用例之间不许串 */ + sched_periodic_init(&g_tick, task_desc(TASK_SAFETY)->period_ms); +} + +void setUp(void) +{ + sim_hal_reset(); + board_up(); +} + +/* 每条用例收尾都查三件事:没有两路通道同时开着(那会读到两片的线与)、 + 没有把接地脚配成输出、事件回调没在持锁时被调。 */ +void tearDown(void) +{ + TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); + TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven()); + TEST_ASSERT_FALSE(g_ev_held_lock); + TEST_ASSERT_FALSE(g_ev_before_action); +} + +/* 一拍 = 真板上两个任务在这 20 ms 里干的事。执行器要先推,因为判据打断的正是它。 */ +static void beat(void) +{ + (void)chgmode_tick(hal_time_now_ms()); /* app_main:执行器任务每转一圈清一次过期窗口 */ + (void)t_actuator_step(); + (void)t_safety_step(&g_snap); + sched_periodic_wait(&g_tick); +} + +static void beats(int n) +{ + for (int i = 0; i < n; i++) { + beat(); + } +} + +/* 开一路电并熬过缓启动窗口。判据在这个窗口里是**不武装**的(斜坡还没走完, + 这时候比电压等于每次上电都自己把自己关掉)。 */ +static void power_up(int rail) +{ + const int before = g_n_ev; + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(rail, true)); + beats(1 + (T_SAFETY_SETTLE_MS / (int)task_desc(TASK_SAFETY)->period_ms) + 1); + TEST_ASSERT_TRUE(rail_efuse_is_on(rail)); + TEST_ASSERT_EQUAL_INT(1, en_level(rail)); + TEST_ASSERT_EQUAL_INT(before, g_n_ev); /* 上电加稳态几拍,不该冒出新事件 */ +} + +/* 把这一路的 VBUS 通上(执行器路径:自带一次 `xact`)。 */ +static void vbus_on(int rail) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(rail, true)); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(rail)); +} + +/* ---- 常量与文档对账(改了任何一个就是改了需求) -------------------------- */ + +static void test_thresholds_match_the_documents(void) +{ + /* SR-5 / SW-2 的两个数,逐字来自需求 §9 那两行。 */ + TEST_ASSERT_EQUAL_INT(100, T_SAFETY_DEV_MV); + TEST_ASSERT_EQUAL_INT(250, T_SAFETY_UNDER_MV); /* 欠压一侧,2026-09-28 放宽 */ + TEST_ASSERT_EQUAL_INT(20, T_SAFETY_DEV_HOLD_MS); + + /* 判据数的是**圈**,SR-5 写的是**毫秒** —— 这一条把两者钉在一起。 + 巡检周期哪天改了,它会当场对不上,而不是悄悄把「持续 20 ms」变成 40 ms。 */ + TEST_ASSERT_EQUAL_INT(2, T_SAFETY_DEV_HOLD_CYCLES); + TEST_ASSERT_EQUAL_UINT32((T_SAFETY_DEV_HOLD_CYCLES - 1) * task_desc(TASK_SAFETY)->period_ms, + (uint32_t)T_SAFETY_DEV_HOLD_MS); + + /* SW-8 的两个数:设计文档 §15.2 对策 2 的 −50 mA(2026-09-28 放宽到 −300 mA),需求 SW-8 的 200 ms 预算。 */ + TEST_ASSERT_EQUAL_INT32(-300000, T_SAFETY_REV_UA); + TEST_ASSERT_EQUAL_INT(200, T_SAFETY_REV_BUDGET_MS); + /* R2 里断 VBUS 多了一次 I²C 事务(≈0.16 ms),判据延迟变成「一圈 + 一次事务」。 + 它必须离 200 ms 的预算远得看得出来 —— 计划 §5.4 说的「仍有 10 倍余量」。 */ + TEST_ASSERT_TRUE((int)task_desc(TASK_SAFETY)->period_ms * 2 + + (int)(t_safety_budget_us() / 1000u) < + T_SAFETY_REV_BUDGET_MS / 2); + + /* 缓启动窗口必须盖得住 tON + 斜坡那 17 ms(§7.4),而且要是周期的整数倍以上 + —— 比一个周期还短的窗口等于没有。 */ + TEST_ASSERT_TRUE(T_SAFETY_SETTLE_MS > (int)ACT_HR_RAMP_MS); + TEST_ASSERT_TRUE(T_SAFETY_SETTLE_MS >= 2 * (int)task_desc(TASK_SAFETY)->period_ms); + + /* §12.1 注入的是 ±150 mV / −60 mA,两个都必须落在阈值**外侧**, + 否则验收测的是「阈值刚好卡在注入值上」。 */ + TEST_ASSERT_TRUE(150 > T_SAFETY_DEV_MV); + TEST_ASSERT_TRUE(300 > T_SAFETY_UNDER_MV); /* 欠压注入 −300 mV */ + TEST_ASSERT_TRUE(-310000 < T_SAFETY_REV_UA); + + /* ⭐ R2 新增:mux 连续选不中多少次才算失联。 + 1 的话一次总线毛刺就停五台手机;太大的话失联之后还有一大截时间在拿旧数据 + 跑判据。这个数是「先按 3 写、上板实测误码率之后再定」的待定项(计划 §11), + 改它必须连同这条注释一起改。 */ + TEST_ASSERT_EQUAL_INT(3, T_SAFETY_MUX_FAIL_N); + TEST_ASSERT_TRUE(T_SAFETY_MUX_FAIL_N > 1); + + /* 两条新事件必须排在枚举**末尾**:evlog 里存的是这几个数字,插在中间会让 + 已经落盘的旧记录改变含义(`t_safety.h` 那句「flash 格式」)。 */ + TEST_ASSERT_EQUAL_INT(SAFETY_EV_PUSHED_UP + 1, SAFETY_EV_MUX_LOST); + TEST_ASSERT_EQUAL_INT(SAFETY_EV_MUX_LOST + 1, SAFETY_EV_SLOT_EMPTY); + TEST_ASSERT_EQUAL_INT(SAFETY_EV_SLOT_EMPTY + 1, SAFETY_EV_BLADE_MUTE); + + /* 0.1.4 刀片不应答:连续 10 圈 = 200 ms。必须 > 1(一圈毛刺不许关一台手机)。 */ + TEST_ASSERT_EQUAL_INT(10, T_SAFETY_BLADE_MUTE_N); + TEST_ASSERT_EQUAL_UINT32(200u, T_SAFETY_BLADE_MUTE_N * task_desc(TASK_SAFETY)->period_ms); +} + +/* ---- 验收主项一:注入过压 → 20 ms 内关路并记事件(SR-5) ----------------- */ + +static void test_overvoltage_cuts_the_rail_within_one_period_and_logs_it(void) +{ + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); /* §12.1 的 +150 mV */ + + beat(); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 第一次看见:只起表,不动电 */ + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); /* 满 20 ms:这一拍断电 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type); + TEST_ASSERT_EQUAL_UINT8(1, g_ev[0].rail); + TEST_ASSERT_EQUAL_INT32(NOMINAL_MV + 150, g_ev[0].bus_mv); /* 记的是实测值 */ + TEST_ASSERT_EQUAL_INT32(1000000, g_ev[0].current_ua); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.events); +} + +/* 从**第一次看见**到断电正好一个周期。 + ⚠️ 这一条把假总线的耗时关掉(sim_hal.h 允许的那个正当理由:拿虚拟时钟当纯 + 计数器)—— 开着的话每一拍还要加上巡检自己占的 2.34 ms,量出来是 18/22 ms + 之间跳,那几毫秒是巡检花的,不是判据晚了。 */ +static void test_the_cut_lands_exactly_one_hold_window_after_first_sight(void) +{ + sim_i2c_set_bus_time(false); + power_up(2); + inject(2, NOMINAL_MV - 300, 1000000); + + const uint32_t t_first_sight = hal_time_now_ms(); + beat(); + beat(); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(T_SAFETY_DEV_HOLD_MS, g_ev[0].t_ms - t_first_sight); +} + +/* ⭐ 同一件事,把总线耗时打开(也就是真板上的样子):判据必须**照样在第二圈** + 断电。R2 一圈占 2.34 ms,比 R1 的 1.56 ms 更长,相位残差更大。 + + 判据一旦改回按墙钟算 `>= 20 ms`,落在 19 ms 那一侧的情况就要等到第三圈 —— + 40 ms,超了 §12.1 的验收,而且是**间歇性**的(同一个故障注两次,一次过一次不过)。 + 跑 20 轮,各种亚毫秒相位都会踩到。 */ +static void test_the_cut_is_never_late_because_of_sub_millisecond_jitter(void) +{ + power_up(2); + for (int i = 0; i < 20; i++) { + /* 每一轮都从「刚开电」重来,让第一次看见落在不同的亚毫秒相位上。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(2, true)); + inject(2, NOMINAL_MV, 1000000); + beats(6); /* 熬过缓启动窗口 */ + g_n_ev = 0; + + inject(2, NOMINAL_MV + 150, 1000000); + const uint32_t t0 = hal_time_now_ms(); + beat(); + TEST_ASSERT_EQUAL_INT(1, en_level(2)); /* 第一圈只起表 */ + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(2)); /* 第二圈必须断,一次都不许拖 */ + + /* 墙钟量出来在 20 ms 上下几毫秒:一圈自己占 2.34 ms 总线,加上毫秒截断。 + 那几毫秒是巡检花的,不是判据晚的 —— 上面两条断言才是主判据。 */ + const uint32_t late = g_ev[0].t_ms - t0; + TEST_ASSERT_TRUE(late >= T_SAFETY_DEV_HOLD_MS - 3 && late <= T_SAFETY_DEV_HOLD_MS + 3); + } +} + +/* 偏低同样关路(SR-5 是 ±,不是「过压」)。掉压的现因多半是排线接触不良, + 而那正是这块板子最常见的故障。 */ +static void test_undervoltage_cuts_the_rail_too(void) +{ + power_up(3); + inject(3, NOMINAL_MV - 300, 1000000); + beats(2); + + TEST_ASSERT_EQUAL_INT(0, en_level(3)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); +} + +/* 「持续」的意思是连续。只闪了一圈就回到范围内的偏差不许关路 —— + 不然一次负载阶跃(0→4 A)就能把五台手机全关掉。 */ +static void test_a_one_cycle_glitch_does_not_cut_anything(void) +{ + power_up(1); + for (int i = 0; i < 20; i++) { + inject(1, NOMINAL_MV + 150, 1000000); + beat(); + inject(1, NOMINAL_MV, 1000000); + beat(); + } + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + +/* 阈值是「**超过** 100 mV」:正好 100 mV 不算。 + 写成 >= 的话,SW-4 允许运维把某一路微调 100 mV 之后那一路就再也开不起来。 */ +static void test_exactly_one_hundred_millivolts_is_still_in_range(void) +{ + power_up(1); + inject(1, NOMINAL_MV + T_SAFETY_DEV_MV, 1000000); + beats(10); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + + inject(1, NOMINAL_MV - T_SAFETY_UNDER_MV, 1000000); + beats(10); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + +/* ⭐ 上板实测那一幕:手机开机峰值 2.1 A 时读回 −115 mV。放宽之前这里会关路, + 把正在开机的手机直接断电。过压那一侧同样的偏差照旧要关。 */ +static void test_boot_current_droop_does_not_cut_the_rail(void) +{ + power_up(1); + inject(1, NOMINAL_MV - 115, 2136000); + beats(10); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + + inject(1, NOMINAL_MV + 115, 1000000); + beats(3); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type); +} + +/* 比的是**该路 DAC 的设定值**,不是 3900 这个常数。SW-4 允许单路微调, + 拿常数比的话,一台被调到 4.010 V 的手机会被当成过压关掉 —— 而且只有那一路。 */ +static void test_the_reference_is_the_setpoint_not_a_constant(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_set_mv(1, RAIL_MV_MAX)); + TEST_ASSERT_EQUAL_INT(RAIL_MV_MAX, rail_setpoint_mv(1)); + + inject(1, RAIL_MV_MAX, 1000000); /* 实测跟着设定走 */ + power_up(1); + beats(10); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + + /* 调到最低档,实测仍是 3900:拿设定值比才是「偏高」。 + (原来是调到最高档再回 3900 判偏低 110 mV —— 欠压放宽到 −250 mV 之后那已经不越界了。) */ + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_set_mv(1, RAIL_MV_MIN)); + inject(1, NOMINAL_MV, 1000000); + beats(2); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type); +} + +/* ---- 判据什么时候**不**武装(三道闸,每一道都是一次真事故的预防) -------- */ + +/* 关着的路不判据。不设这一条的现象是:巡检每 20 ms 就「发现」五路各偏低 + 3900 mV,然后没完没了地去关已经关着的路、刷满事件日志。 */ +static void test_a_rail_that_is_off_is_never_judged(void) +{ + inject(1, 0, 0); /* 关着的路就是 0 V —— 离设定值差着 3900 mV */ + beats(50); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(1, en_writes(1)); /* 只有 rail_en_init() 那一次 */ + TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); /* 也没去写刀片的输出锁存器 */ +} + +/* 刚开电的那几十毫秒不判据:tON 5.1 ms + 斜坡 11.5 ms 期间电压本来就在爬。 + 没有这个窗口的现象是**每次上电都自己把自己关掉**,而且五路一起。 */ +static void test_the_soft_start_window_is_not_judged(void) +{ + inject(1, 1200, 100000); /* 斜坡爬到一半 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(1, true)); + + const int in_window = T_SAFETY_SETTLE_MS / (int)task_desc(TASK_SAFETY)->period_ms; + beats(in_window); /* 窗口之内:不管电压多离谱都不判 */ + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + + /* 窗口一过,判据武装,还是这么低就该关了。 */ + beats(3); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); +} + +/* 器件失联的那一路不判据(§10.10:「保持供电,标记为盲跑,**禁用该路的 + SR-5 / SW-8 判据**」)。快照里留着的是上一圈的值,拿它去关电是最难查的那种错。 */ +static void test_a_blind_rail_keeps_its_power(void) +{ + power_up(4); + + /* ⚠️ 顺序是有讲究的:**先让坏值真的读进快照一次**,再让器件失联。 + 反过来写(先失联再改寄存器)的话快照里躺着的是个正常值, + 「盲跑那一路照样判据」这个错就一点痕迹都不会留下 —— 测试永远绿。 */ + inject(4, NOMINAL_MV - 300, 1000000); + beat(); + TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* 第一个样本只起表 */ + sim_i2c_set_present_ch(ch_of(4), rail_cfg(4)->ina_addr, false); + + beats(50); /* 1 秒,远超两条判据各自的窗口 */ + TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* §10.10:盲跑 = 保持供电 */ + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_FALSE(g_snap.rail_ok[3]); + /* 快照里躺着的确实是那个越界的旧值 —— 这是上面那句「顺序有讲究」的凭据。 */ + TEST_ASSERT_EQUAL_INT32(NOMINAL_MV - 300, g_snap.rail[3].bus_mv); + + /* 器件回来了,值还是坏的 —— 这时才该关,而且要重新数满 20 ms。 */ + sim_i2c_set_present_ch(ch_of(4), rail_cfg(4)->ina_addr, true); + beat(); + TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* 第一个新鲜样本只起表 */ + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(4)); +} + +/* ---- 0.1.4:整片刀片不应答 ⇒ 关路(§10.10 新增那一行) ------------------ */ + +static void mute_blade(int rail, bool mute) +{ + sim_i2c_set_present_ch(ch_of(rail), rail_cfg(rail)->ina_addr, !mute); + sim_i2c_set_present_ch(ch_of(rail), PCA9555_ADDR, !mute); +} + +/* 2026-09-28 板 B 第 2 槽的现场:`PRSNT#` 卡低(假在位)、刀片已经拔走、eFuse 还开着。 + 两颗都不答应满 10 圈 ⇒ 关这一路、记一条 BLADE_MUTE;别的路一根线都不动。 */ +static void test_a_mute_blade_is_cut_after_ten_cycles(void) +{ + power_up(2); + power_up(3); + const uint32_t other = en_writes(3); + const uint32_t out0 = g_w[1].n; + mute_blade(2, true); /* PRSNT2# 仍是低:slot_present(2) 照旧为真 */ + + beats(T_SAFETY_BLADE_MUTE_N - 1); + TEST_ASSERT_EQUAL_INT(1, en_level(2)); /* 9 圈:还没到 */ + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(2)); /* 第 10 圈:关 */ + TEST_ASSERT_FALSE(rail_efuse_is_on(2)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_INT(SAFETY_EV_BLADE_MUTE, g_ev[0].type); + TEST_ASSERT_EQUAL_INT(2, g_ev[0].rail); + /* 没有去写那片刚 NAK 过的 PCA9555(断 VBUS 那一写只会白等超时) */ + TEST_ASSERT_EQUAL_UINT32(out0, g_w[1].n); + /* 第 3 路一根线都没动 */ + TEST_ASSERT_EQUAL_INT(1, en_level(3)); + TEST_ASSERT_EQUAL_UINT32(other, en_writes(3)); + + beats(50); /* 关掉之后不再刷事件 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +/* 只差一圈就恢复了:从头数,不关。 */ +static void test_a_blade_that_answers_again_restarts_the_count(void) +{ + power_up(2); + mute_blade(2, true); + beats(T_SAFETY_BLADE_MUTE_N - 1); + mute_blade(2, false); + beat(); + mute_blade(2, true); + beats(T_SAFETY_BLADE_MUTE_N - 1); + TEST_ASSERT_EQUAL_INT(1, en_level(2)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(2)); +} + +/* §10.10 原有的两行不变:只有一颗不答应 ⇒ 盲跑、保持供电。 */ +static void test_one_mute_chip_on_the_blade_still_keeps_power(void) +{ + power_up(2); + sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, false); /* 只有 PCA9555 不答应 */ + beats(50); + TEST_ASSERT_EQUAL_INT(1, en_level(2)); + sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, true); + sim_i2c_set_present_ch(ch_of(2), rail_cfg(2)->ina_addr, false); /* 只有 INA226 不答应 */ + beats(50); + TEST_ASSERT_EQUAL_INT(1, en_level(2)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + +/* 关着的路不数:空槽假在位、没人开它,不该每 200 ms 刷一条事件。 */ +static void test_a_mute_blade_that_is_off_is_left_alone(void) +{ + mute_blade(2, true); + beats(50); + TEST_ASSERT_EQUAL_INT(0, en_level(2)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + +/* ---- ⭐ 关路这件事本身:R2 的顺序与「写给了哪一片」 ---------------------- */ + +/* ⭐⭐ 本包最要紧的一条。关路 = **先断 VBUS(锁内、本路通道上)再关 eFuse(锁外)**。 + 三件事一起证: + ① 那一写发生时,假 mux 实际选通的就是这一路 —— 不是别人的通道,也不是全关; + ② 那一刻 I²C 锁在自己手上(放锁之后再拿一次锁去写,中间隔着一次锁争用); + ③ 那一刻本路 `EFUSE_EN` 还没被写 —— 也就是 eFuse 是**之后**才关的。 + 只看两个终值的话,「先关 eFuse 再断 VBUS」和这条长得一模一样,而那个顺序会 + 留下一小段「手机只靠 USB 供着电」的窗口:既不是开,也不是关。 */ +static void test_the_cut_drops_vbus_inside_the_lock_on_this_rails_channel(void) +{ + const int rail = 3; /* CH3 —— DAC 在**另一片**上、mux 通道 2,最容易算错的那一路 */ + power_up(rail); + vbus_on(rail); + watch_reset(); + + const uint32_t en_seq_before = sim_gpio_write_seq(rail_cfg(rail)->efuse_en); + + inject(rail, NOMINAL_MV - 300, 1000000); /* 欠压,不是反灌 */ + beat(); /* 第一圈只起表 */ + TEST_ASSERT_EQUAL_UINT32(0, g_w[rail - 1].n); + + /* 断电那一圈从这里开始数拿锁次数:一圈是五路 + 主干 = 六次 `xact`, + 断 VBUS 那一写必须落在**第 rail 次**那一次里(也就是读这一路的同一次持锁)。 */ + const uint32_t takes_before = sim_lock_takes(HAL_LOCK_I2C); + beat(); + TEST_ASSERT_EQUAL_UINT32((uint32_t)N + 1u, sim_lock_takes(HAL_LOCK_I2C) - takes_before); + + /* 终值:两边都断了。 */ + TEST_ASSERT_EQUAL_INT(0, vbus_pin(rail)); + TEST_ASSERT_EQUAL_INT(0, en_level(rail)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); + + /* ① 断 VBUS 那一写,落在**这一路自己的通道**上。 */ + TEST_ASSERT_EQUAL_UINT32(1, g_w[rail - 1].n); + TEST_ASSERT_EQUAL_INT(ch_of(rail), g_w[rail - 1].mux_ch); + /* ② 而且是**读这一路的那一次持锁里**写下去的,不是放锁之后重新拿一次锁。 + 重新拿一次锁的代价不是慢:中间隔着一次锁争用,`t_actuator` 完全可能在那 + 一瞬间把通道切到别的刀片上,那一写就落到另一台手机的 PCA9555 上 —— + 别人的 USB 被拔了,出事这一台的 VBUS 还通着,两边都不报错。 */ + TEST_ASSERT_TRUE(g_w[rail - 1].lock_held); + TEST_ASSERT_EQUAL_UINT32((uint32_t)rail, g_w[rail - 1].lock_takes - takes_before); + /* ③ 那一刻 `EFUSE_EN` 还停在断电前的写序号上 ⇒ eFuse 是后关的。 */ + TEST_ASSERT_EQUAL_UINT32(en_seq_before, g_w[rail - 1].efuse_seq); + TEST_ASSERT_TRUE(sim_gpio_write_seq(rail_cfg(rail)->efuse_en) > en_seq_before); + /* 写下去的字节里 VBUS 位是 0(顺带确认没把三个键按下去)。 */ + TEST_ASSERT_EQUAL_UINT8(0, g_w[rail - 1].value); + + /* 另外四片刀片一个字节都没被写过 —— 「写到了另一片上」的直接拦网。 */ + for (int r = 1; r <= N; r++) { + if (r == rail) { + continue; + } + TEST_ASSERT_EQUAL_UINT32(0, g_w[r - 1].n); + TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(r - 1, 0)); + } +} + +/* 只关出事的那一路,另外四路一根线、一个字节都不许动。 */ +static void test_only_the_offending_rail_is_touched(void) +{ + for (int rail = 1; rail <= N; rail++) { + power_up(rail); + vbus_on(rail); + } + watch_reset(); + uint32_t before[N]; + for (int rail = 1; rail <= N; rail++) { + before[rail - 1] = en_writes(rail); + } + + inject(3, NOMINAL_MV + 150, 1000000); + beats(2); + + TEST_ASSERT_EQUAL_INT(0, en_level(3)); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(3)); + for (int rail = 1; rail <= N; rail++) { + if (rail == 3) { + continue; + } + TEST_ASSERT_EQUAL_INT(1, en_level(rail)); + TEST_ASSERT_EQUAL_UINT32(before[rail - 1], en_writes(rail)); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(rail)); /* 别人的 USB 不许被拔 */ + TEST_ASSERT_EQUAL_UINT32(0, g_w[rail - 1].n); + } + /* CH3 的 VBUS 也通着,所以它出的是两条:第一圈 SW-8「被顶高」断 VBUS, + 第二圈 SR-5 关路(两条判据分层,见后面那条专门的用例)。 + 要紧的是这两条**都只指向 CH3**。 */ + TEST_ASSERT_EQUAL_INT(2, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(3, g_ev[0].rail); + TEST_ASSERT_EQUAL_UINT8(3, g_ev[1].rail); +} + +/* 关掉之后判据自己解除武装:不再刷第二条事件,也不再去写那两个东西。 */ +static void test_a_cut_rail_does_not_keep_firing(void) +{ + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); + beats(2); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + + const uint32_t writes = en_writes(1); + watch_reset(); + beats(100); /* 2 秒 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(writes, en_writes(1)); + TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); +} + +/* 被关掉的那一路重新上电之后,确认窗口要**从头数**。 + `dev_n` 关路时不清零的话,这一路下次开电只要看见一个越界样本就立刻跳闸 —— + 少了整个确认窗口,而现象只是「这一路特别容易掉」,查不到根上。 */ +static void test_a_rail_that_comes_back_starts_its_hold_window_over(void) +{ + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); + beats(2); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + + inject(1, NOMINAL_MV, 1000000); /* 修好了,重新上电 */ + power_up(1); + + inject(1, NOMINAL_MV + 150, 1000000); /* 又坏了 */ + beat(); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 第一圈还是只起表,不是立刻跳 */ + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(2, g_n_ev); +} + +/* ⭐ 关路之前必须先 `t_actuator_abort()`。 + 不 abort 的现象最阴:硬重启的 `HR_OFF` 会在 4 s 之后自己把 `EFUSE_EN` 抬回去, + 于是「过压关掉的那一路」几秒钟后又带电了,而事件日志里只有一条「已关路」。 */ +static void test_a_running_hard_reset_cannot_bring_the_rail_back(void) +{ + power_up(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(1)); + + /* 断电窗口 4 s 之后 `EFUSE_EN` 被抬回来,序列进入等 PG(PG 永远读不到)。 */ + beats((ACT_HR_OFF_MS + T_SAFETY_SETTLE_MS) / 20 + 2); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(ACT_ST_HR_PG, (int)t_actuator_state(1)); + + inject(1, NOMINAL_MV + 150, 1000000); + beats(2); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); + + /* 再跑 10 秒 —— 电不许自己回来。 */ + beats(500); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +/* abort 顺带把按下去的键松开了。按键序列跑到一半被过压打断, + 键要是留在按下状态,手机再也起不来,而遥测上五路电压电流全都正常。 + ⚠️ R2 里「键」在刀片 PCA9555 的 P0.0 上,所以这一条顺带也证明了收尾那一次 + I²C 写落到了正确的那一片上。 */ +static void test_a_cut_releases_whatever_key_was_held(void) +{ + power_up(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON)); + beat(); + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_PWR)); + + inject(1, NOMINAL_MV + 150, 1000000); + beats(3); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_PWR)); + TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); + /* 别的四片的键也没被顺手按下(影子记在另一片上的经典后果)。 */ + for (int r = 2; r <= N; r++) { + TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(r - 1, 0)); + } +} + +/* ---- 验收主项二:注入 −60 mA → 断 VBUS(SW-8) --------------------------- */ + +static void test_reverse_current_drops_vbus(void) +{ + power_up(1); + vbus_on(1); + watch_reset(); + const uint32_t en_w = en_writes(1); + inject(1, NOMINAL_MV, -310000); /* §12.1 的 −60 mA,阈值放宽后改 −310 mA */ + + const uint32_t t0 = hal_time_now_ms(); + const uint32_t takes_before = sim_lock_takes(HAL_LOCK_I2C); + beat(); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* **只断 VBUS**,直供不动 */ + TEST_ASSERT_EQUAL_UINT32(en_w, en_writes(1)); /* eFuse 那根线一次都没被碰 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_REVERSE, g_ev[0].type); + TEST_ASSERT_EQUAL_INT32(-310000, g_ev[0].current_ua); + + /* ⭐ 这一写同样是锁内、本路通道上的(SW-8 走的是和 SR-5 同一条路)。 */ + TEST_ASSERT_EQUAL_UINT32(1, g_w[0].n); + TEST_ASSERT_EQUAL_INT(ch_of(1), g_w[0].mux_ch); + TEST_ASSERT_TRUE(g_w[0].lock_held); + /* CH1 是本圈第一次 `xact`:断 VBUS 那一写就在那一次持锁里。 */ + TEST_ASSERT_EQUAL_UINT32(1, g_w[0].lock_takes - takes_before); + TEST_ASSERT_EQUAL_UINT32((uint32_t)N + 1u, sim_lock_takes(HAL_LOCK_I2C) - takes_before); + + /* SW-8 的预算是 200 ms,实际是一圈 + 一次事务。 */ + TEST_ASSERT_TRUE(hal_time_elapsed_ms(t0) < (uint32_t)T_SAFETY_REV_BUDGET_MS); +} + +/* 反灌**不等确认窗口**(与过压判据有意不对称):一圈看见就断。 + 误断 VBUS 只丢一次 USB 数据,手机还由直供电着;漏判会把能量泵回 12V 母线, + **连累另外四台**。 */ +static void test_reverse_current_is_not_debounced(void) +{ + power_up(1); + vbus_on(1); + inject(1, NOMINAL_MV, -310000); + beat(); /* 就一圈 */ + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +/* 没到 −300 mA 不算(−250 mA 也不算)。手机在直供下本来就会有小幅的双向电流。 */ +static void test_a_small_negative_current_is_not_a_backfeed(void) +{ + power_up(1); + vbus_on(1); + watch_reset(); + inject(1, NOMINAL_MV, -250000); + beats(50); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); +} + +/* VBUS 本来就断着的时候不判反灌:这条判据只在「USB 那一侧通着」时武装。 + 也正是它让「断完之后不再每 20 ms 刷一条事件」成立。 */ +static void test_backfeed_is_only_judged_while_vbus_is_on(void) +{ + power_up(1); /* VBUS 没开 */ + watch_reset(); + inject(1, NOMINAL_MV, -200000); + beats(50); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); /* 一次都没去写刀片 */ +} + +/* 断完之后不会每 20 ms 再刷一条事件、也不会每 20 ms 再发一次 I²C 写。 + 会刷的话,事件日志那块 flash 每秒被写 50 次 —— 判据「一直在响」和判据 + 「不灵」一样糟,而前者还会把真正的那条事件淹掉。 */ +static void test_a_backfeed_does_not_refire_every_cycle(void) +{ + power_up(1); + vbus_on(1); + inject(1, NOMINAL_MV, -310000); + beat(); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + + watch_reset(); + beats(100); /* 2 秒,故障一直在 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(0, g_w[0].n); +} + +/* 顶高:VBUS 期间电压被抬过设定值 +100 mV,同样立刻断 VBUS(设计文档 §15.2)。 + 然后 20 ms 后 SR-5 才决定要不要把这一路也关掉 —— 两条判据是分层的: + 先撤掉能量来源,再决定要不要关电。 */ +static void test_being_pushed_up_drops_vbus_first_then_sr5_cuts_the_rail(void) +{ + power_up(1); + vbus_on(1); + inject(1, NOMINAL_MV + 150, 1000000); + + beat(); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); /* 第一圈:VBUS 没了 */ + TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 电还开着 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_PUSHED_UP, g_ev[0].type); + + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); /* 第二圈:还是高,SR-5 关路 */ + TEST_ASSERT_EQUAL_INT(2, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[1].type); +} + +/* ⭐ 断 VBUS 之前要打断 EDL 序列 —— 三个序列里只有它会写 `VBUS_EN`, + 不打断的话下一拍它就把 VBUS 又抬回来了(`EDL_EN=1 → VBUS_EN=1 → 等枚举`)。 */ +static void test_a_running_edl_sequence_cannot_bring_vbus_back(void) +{ + power_up(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_edl(1)); + beats(2); /* 短接 10 ms 之后 VBUS_EN 被抬起来,进入等枚举 */ + TEST_ASSERT_EQUAL_INT(1, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(ACT_ST_EDL_ENUM, (int)t_actuator_state(1)); + + inject(1, NOMINAL_MV, -310000); + beat(); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, (int)t_actuator_last_result(1)); + + beats(200); /* 4 秒 —— 枚举窗口 3 s 早过了,VBUS 不许自己回来 */ + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +/* 反灌不去打断跟 VBUS 无关的序列(按键只碰 P0.0~2,硬重启只碰 `EFUSE_EN`)—— + 「出了事就把这一路上所有事都停掉」比看上去危险:那会把一次正在进行的 + 救砖重启打断,而重启和反灌是两码事。 */ +static void test_a_backfeed_does_not_abort_an_unrelated_sequence(void) +{ + power_up(1); + vbus_on(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_VUP, ACT_KEY_MS_ON)); + + inject(1, NOMINAL_MV, -310000); + beat(); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_TRUE(t_actuator_busy(1)); + TEST_ASSERT_EQUAL_INT(ACT_ST_KEY_HOLD, (int)t_actuator_state(1)); + /* 键还按着 —— 断 VBUS 那一次写不许把同一个字节里的键位顺手清掉。 */ + TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_VUP)); +} + +/* 五路各判各的:CH5 反灌不许动 CH2 的 VBUS。 */ +static void test_backfeed_is_per_rail(void) +{ + power_up(2); + power_up(5); + vbus_on(2); + vbus_on(5); + + inject(5, NOMINAL_MV, -310000); + beats(5); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(5)); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(2)); + TEST_ASSERT_EQUAL_INT(1, en_level(5)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(5, g_ev[0].rail); +} + +/* ---- ⭐ R2 新增判据之一:`PRSNT#`(刀片不在位) -------------------------- */ + +/* 刀片被拔了而这一路还开着 ⇒ 立刻关路 + 记 `SAFETY_EV_SLOT_EMPTY`。 + 空槽带电 = 金手指裸露带 12V,手一碰就是短路,而电压电流读出来什么异常都没有 + (那一路的 INA226 跟着刀片一起走了,快照里留的是上一圈的旧值)。 */ +static void test_an_empty_slot_that_is_powered_gets_cut_at_once(void) +{ + power_up(2); + TEST_ASSERT_TRUE(g_snap.present[1]); + + sim_gpio_drive(rail_cfg(2)->prsnt_n, 1); /* 刀片拔走:上拉把 PRSNT# 拉高 */ + beat(); + + TEST_ASSERT_FALSE(g_snap.present[1]); + TEST_ASSERT_EQUAL_INT(0, en_level(2)); /* 不等确认窗口,这一圈就断 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_SLOT_EMPTY, g_ev[0].type); + TEST_ASSERT_EQUAL_UINT8(2, g_ev[0].rail); + + /* 别的四路一根线都没动(`PRSNT#` 是一路一根,不是一根线或)。 */ + for (int rail = 1; rail <= N; rail++) { + if (rail != 2) { + TEST_ASSERT_TRUE(g_snap.present[rail - 1]); + } + } + + /* 断完之后不再刷第二条 —— 判据只在「本来是开着的」时候动作。 */ + beats(100); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +/* ⭐ 专门的一条:**五个槽全空、五路全关**(也就是这块板子刚上电、还没插刀片的 + 样子)—— 判据一条事件都不许出。 + 少了「本来是开着的」这个前提,现象是开机就以每秒 250 条的速度刷 + `SLOT_EMPTY`,evlog 那块 flash 几分钟就写穿,而且真出事那条会被淹掉。 */ +/* ⭐ `PRSNT#` 接触不良(刀片还在、PCA9555 还应答)时,VBUS 也要断掉。 + + 这条和上面那条的区别是**刀片在不在**: + · 真拔走了 —— PCA9555 跟着走了,断 VBUS 那一步必然 NAK,无所谓; + · 金手指 B31 虚接 / `PRSNT#` 接触不良 —— 刀片还在、总线还通, + 这时只关 `EFUSE_EN` 会留下「直供断了、USB 还灌着」的半开状态: + 手机靠 VBUS 吊着命,遥测上这一路电压是 0、电流也接近 0,看着像"已经关了"。 + 所以判据要两样都断。⚠️ 顺序是**先 eFuse(GPIO,必成)再 VBUS(I²C,可能失败)**: + 断电优先,不能为了一次可能等锁的 I²C 事务把关电推后。 */ +static void test_an_empty_slot_also_drops_vbus_when_the_blade_still_answers(void) +{ + power_up(3); + vbus_on(3); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(3)); + + /* 只把 `PRSNT#` 拉高 —— 刀片本身(假 PCA9555)还在总线上好好应答着。 */ + sim_gpio_drive(rail_cfg(3)->prsnt_n, 1); + beat(); + + TEST_ASSERT_FALSE(g_snap.present[2]); + TEST_ASSERT_EQUAL_INT(0, en_level(3)); /* 直供断了 */ + TEST_ASSERT_EQUAL_INT(0, vbus_pin(3)); /* ⭐ VBUS 也断了,不留半开状态 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_SLOT_EMPTY, g_ev[0].type); + + /* 别的四路的 VBUS 一个都没被顺手带下去。 */ + for (int rail = 1; rail <= N; rail++) { + if (rail != 3) { + TEST_ASSERT_EQUAL_INT(0, vbus_pin(rail)); /* 本来就没开,确认没被乱开 */ + } + } + + /* 断完不再刷第二条。 */ + beats(50); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +static void test_empty_slots_that_are_off_never_fire(void) +{ + for (int rail = 1; rail <= N; rail++) { + sim_gpio_drive(rail_cfg(rail)->prsnt_n, 1); + } + watch_reset(); + beats(200); /* 4 秒 */ + + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_FALSE(g_snap.present[rail - 1]); + TEST_ASSERT_EQUAL_UINT32(1, en_writes(rail)); /* 只有 rail_en_init() 那一次 */ + TEST_ASSERT_EQUAL_UINT32(0, g_w[rail - 1].n); + } +} + +/* 插回去之后判据自己解除 —— 空槽判据读的是**这一圈**的引脚,不是一个一去不返的闩。 + 不重读的现象是「热插拔之后这一路再也开不起来」,而 `PRSNT#` 上量出来是好的。 */ +static void test_a_slot_that_comes_back_can_be_powered_again(void) +{ + power_up(1); + sim_gpio_drive(rail_cfg(1)->prsnt_n, 1); + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + + sim_gpio_drive(rail_cfg(1)->prsnt_n, 0); /* 刀片插回来了 */ + g_n_ev = 0; + power_up(1); + beats(20); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + +/* ---- ⭐ R2 新增判据之二:mux 失联(两段式) ------------------------------ */ + +/* TCA9548A 是单点:它一挂,五路的电压电流**全部看不见**,SR-5 的整条监控链断了。 + 这时「继续供电但看不见」比「停机」危险 —— 所以判据是五路全断。 + + ⭐ 但**必须先脉冲一次 `MUX_RST#` 再判**:一次总线毛刺就停五台手机,代价太大。 + 这条用例把两段拆开看:第一圈只复位、电一路都不许断;第二圈才全断。 + 少了复位那一步的实现,第一圈就会把五台机器全停掉 —— 而现象只是「偶尔全体掉电」。 */ +static void test_a_lost_mux_resets_once_before_it_cuts_all_five(void) +{ + for (int rail = 1; rail <= N; rail++) { + power_up(rail); + } + const uint32_t rst0 = sim_i2c_mux_resets(); + + sim_i2c_set_present(TCA9548A_ADDR, false); /* mux 失联 */ + + /* 第一圈:连续选不中够数了,**先复位**。五路电一路都不许断。 */ + beat(); + TEST_ASSERT_TRUE(i2cbus_select_fail_streak() >= (uint32_t)T_SAFETY_MUX_FAIL_N); + TEST_ASSERT_EQUAL_UINT32(rst0 + 1u, sim_i2c_mux_resets()); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_recovers); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.mux_trips); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(1, en_level(rail)); + } + + /* 第二圈:复位过了还是选不中 ⇒ 认定失联,五路全断,记一条整板级事件。 */ + beat(); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_MUX_LOST, g_ev[0].type); + TEST_ASSERT_EQUAL_UINT8(0, g_ev[0].rail); /* ⚠️ 整板级事件,rail 是 0 */ + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(0, en_level(rail)); + TEST_ASSERT_FALSE(rail_efuse_is_on(rail)); + } + + /* 第三圈往后:既不再复位,也不再刷事件。 + 两个数字里任何一个每 20 ms 涨一次,都是「判据一直在响」—— 五路已经断了, + 再断一次没有任何用处,而 evlog 那块 flash 一分钟就被写 3000 次。 */ + beats(100); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_recovers); + TEST_ASSERT_EQUAL_UINT32(rst0 + 1u, sim_i2c_mux_resets()); +} + +/* mux 回来之后判据重新武装:下一次失联还要老老实实走「先复位、再全断」两段。 + 不重新武装的现象是「第二次失联直接全断,没有那一次挽救性的复位」。 */ +static void test_the_mux_rule_rearms_after_the_bus_comes_back(void) +{ + power_up(1); + sim_i2c_set_present(TCA9548A_ADDR, false); + beats(2); /* 复位一次 → 全断一次 */ + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + + sim_i2c_set_present(TCA9548A_ADDR, true); /* 总线回来了 */ + beats(3); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); /* 回来这件事本身不是事件 */ + + /* 再挂一次:必须还是「先复位,下一圈才全断」。 */ + sim_i2c_set_present(TCA9548A_ADDR, false); + beat(); + TEST_ASSERT_EQUAL_UINT32(2, g_snap.mux_recovers); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.mux_trips); /* 这一圈还不许断 */ + beat(); + TEST_ASSERT_EQUAL_UINT32(2, g_snap.mux_trips); + TEST_ASSERT_EQUAL_INT(2, g_n_ev); +} + +/* 一次毛刺不许停五台机器:连续失败 N−1 次之后又选中了,判据一动不动。 + 判据要是拿「累计失败次数」而不是「连续」去判,跑上几个小时的板子一定会踩到 —— + 而现象是「半夜五台机器一起掉电,早上看日志只有一条 mux 失联」。 */ +static void test_a_short_glitch_does_not_stop_five_phones(void) +{ + for (int rail = 1; rail <= N; rail++) { + power_up(rail); + } + const uint32_t rst0 = sim_i2c_mux_resets(); + + for (int i = 0; i < 20; i++) { + /* 每一圈都毛刺 N−1 次 —— 加起来远超 N,但一次也没有「连续 N 次」。 */ + sim_i2c_inject_nak(TCA9548A_ADDR, T_SAFETY_MUX_FAIL_N - 1); + beat(); + TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); + } + TEST_ASSERT_TRUE(i2cbus_select_fails() >= 20u * (uint32_t)(T_SAFETY_MUX_FAIL_N - 1)); + TEST_ASSERT_EQUAL_UINT32(rst0, sim_i2c_mux_resets()); + TEST_ASSERT_EQUAL_UINT32(0, g_snap.mux_trips); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_INT(1, en_level(rail)); + } +} + +/* ---- 判据不许改变巡检的形状 ---------------------------------------------- */ + +/* 判据本身一条 I²C 事务也不许发:它要的三个量(设定值、`EFUSE_EN` 的状态、 + 正在跑的序列)全部有影子可查。多读一次就把计划 §5.5 的 12.4 % 顶穿。 + + ⚠️ R2 里**断 VBUS 本身是一次 I²C 写**(`VBUS_EN` 在 PCA9555 上),这是设计, + 不是「判据多读了东西」。所以这里分开数:起表那一圈严格等于一圈的名义事务数, + 断电那一圈恰好多**一次**(就那一次写),不许多出第二次。 */ +static void test_the_rules_do_not_add_any_i2c_traffic(void) +{ + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); + + beat(); /* 起表那一圈:判据干了活(起表),但一个字节也没多发 */ + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); + TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us); + + beat(); /* 断电那一圈:只多出「断 VBUS」那一次写(2 字节) */ + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns() + 1u, g_snap.txns); + TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us() + + (uint32_t)hal_i2c_bits_to_us(hal_i2c_txn_bits(2, 0)), + g_snap.bus_us); + + /* 关掉之后回到名义值 —— 判据不会在后面的圈里继续加事务。 */ + beat(); + TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns); +} + +/* 加锁纪律:整圈(含判据、含断 VBUS 那一次写、含锁外关 eFuse)违规为 0。 */ +static void test_the_whole_cycle_keeps_the_lock_discipline(void) +{ + power_up(1); + vbus_on(1); + sim_lock_require(HAL_LOCK_I2C, true); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(1, ACT_KEYS_PWR, ACT_KEY_MS_ON)); + inject(1, NOMINAL_MV + 150, 1000000); + beats(5); + + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); /* 出门放锁 */ +} + +/* 上一条不是句空话:不拿锁直接读一片 INA226,违规必须 +1。 + 检查本身失灵的话,上一条会永远绿 —— 而「加锁纪律测试永远绿」正是最坏的情形。 */ +static void test_the_lock_check_itself_can_fail(void) +{ + sim_lock_require(HAL_LOCK_I2C, true); + const uint32_t before = sim_lock_violations(HAL_LOCK_I2C); + ina226_vi_t vi; + (void)ina226_read_vi(rail_cfg(1)->ina_addr, &vi); + TEST_ASSERT_EQUAL_UINT32(2, sim_lock_violations(HAL_LOCK_I2C) - before); /* 两次事务 */ +} + +/* 拿不到总线锁的那一圈整圈作废,判据一条都不许跑 —— 那一圈的值是上一圈的。 + 跑的话,「总线忙」就会被当成「电压没变」,而实际上根本没读。 */ +static void test_a_skipped_cycle_runs_no_rules(void) +{ + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); + beat(); /* 起表 */ + + sim_lock_set_task(1); /* 假装 t_actuator 占着总线 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + sim_lock_set_task(0); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&g_snap)); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 满 20 ms 了,但这一圈没数 */ + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped); + + sim_lock_set_task(1); + hal_lock_give(HAL_LOCK_I2C); + sim_lock_set_task(0); + beat(); /* 总线回来了,这一圈才断电 */ + TEST_ASSERT_EQUAL_INT(0, en_level(1)); +} + +/* 没接事件出口也照样断电(A11 还没来,日志接不上不是不断电的理由)。 */ +static void test_the_cut_happens_even_with_no_event_sink(void) +{ + t_safety_set_event_sink(NULL, NULL); + power_up(1); + inject(1, NOMINAL_MV + 150, 1000000); + beats(2); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + TEST_ASSERT_EQUAL_UINT32(1, g_snap.events); /* 快照里仍然记着 */ + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_snap.last.type); +} + +/* ==== 0.1.5 刷机模式(chgmode.h)======================================== + 刷机时手机充电器会把节点顶到浮充 ~4.4 V(2026-09-28 板 B 实测 4395 mV)。 + 模式窗口里:不因为这个断 VBUS、也不关路;超过 CHG_MODE_MAX_MV 才算充电器异常。 */ + +#define CHARGER_FLOAT_MV 4395 /* 板 B 实测 PUSHED_UP 时的读数 */ + +static void flash_mode_on(int rail, uint32_t min) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, chgmode_set(rail, min, hal_time_now_ms())); +} + +static void test_flash_mode_lets_the_charger_push_the_rail_up(void) +{ + power_up(1); + flash_mode_on(1, 30); + vbus_on(1); + inject(1, CHARGER_FLOAT_MV, 0); /* eFuse 反向阻断:电流 0 */ + beats(500); /* 10 s */ + TEST_ASSERT_EQUAL_INT(1, vbus_pin(1)); /* USB 没断 */ + TEST_ASSERT_EQUAL_INT(1, en_level(1)); /* 直供也没关 */ + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + +/* 超过绝对上限 = 充电器本身不对:和平时一样分两层 —— 当圈断 VBUS,20 ms 后还高就关路。 */ +static void test_flash_mode_still_cuts_above_the_absolute_ceiling(void) +{ + power_up(1); + flash_mode_on(1, 30); + vbus_on(1); + inject(1, CHG_MODE_MAX_MV + 10, 0); + beat(); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_PUSHED_UP, g_ev[0].type); + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[1].type); +} + +/* 正好等于上限不算越界(与「正好 +100 mV 还在范围内」同一个口径)。 */ +static void test_flash_mode_ceiling_itself_is_still_in_range(void) +{ + power_up(1); + flash_mode_on(1, 30); + vbus_on(1); + inject(1, CHG_MODE_MAX_MV, 0); + beats(50); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + +/* 反灌判据不受刷机模式影响(eFuse 反向阻断之外的第二道)。 */ +static void test_flash_mode_keeps_the_backfeed_rule(void) +{ + power_up(1); + flash_mode_on(1, 30); + vbus_on(1); + inject(1, CHARGER_FLOAT_MV, -310000); + beat(); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_REVERSE, g_ev[0].type); +} + +/* 欠压判据也不受影响。 */ +static void test_flash_mode_keeps_the_undervoltage_rule(void) +{ + power_up(1); + flash_mode_on(1, 30); + inject(1, NOMINAL_MV - T_SAFETY_UNDER_MV - 50, 1000000); + beats(2); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); +} + +/* 模式只管自己那一路:CH2 没开模式,照样一顶高就断 VBUS。 */ +static void test_flash_mode_is_per_rail(void) +{ + power_up(1); + power_up(2); + flash_mode_on(1, 30); + vbus_on(1); + vbus_on(2); + inject(1, CHARGER_FLOAT_MV, 0); + inject(2, CHARGER_FLOAT_MV, 0); + beat(); + TEST_ASSERT_EQUAL_INT(1, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(0, vbus_pin(2)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(2, g_ev[0].rail); +} + +/* ⭐ 窗口到期那一刻手机还在充、节点在 4.4 V:SW-8 立刻断 VBUS(回到平时), + 但宽限期里 SR-5 仍按绝对值判 —— 节点往回落的那几毫秒不许把手机关掉。 */ +static void test_flash_mode_expiry_drops_vbus_but_not_the_rail(void) +{ + power_up(1); + flash_mode_on(1, 1); + vbus_on(1); + inject(1, CHARGER_FLOAT_MV, 0); + beats(60000 / 20 - 5); /* 窗口里 */ + TEST_ASSERT_EQUAL_INT(1, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + + beats(10); /* 跨过到期 */ + TEST_ASSERT_EQUAL_INT(0, vbus_pin(1)); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_PUSHED_UP, g_ev[0].type); + + inject(1, NOMINAL_MV, 200000); /* USB 断了,节点回到直供 */ + beats(200); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_FALSE(chgmode_relaxed(1, hal_time_now_ms())); /* 宽限期过了,标志也被清掉 */ +} + +/* 宽限期过了节点还高(比如 VBUS 根本没断掉)⇒ 回到平时的 SR-5,关路。 */ +static void test_after_the_grace_period_the_normal_rule_is_back(void) +{ + power_up(1); + flash_mode_on(1, 1); + inject(1, CHARGER_FLOAT_MV, 0); /* VBUS 没开:模拟「断掉了但节点还被别的东西撑着」 */ + beats(60000 / 20 - 5); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + beats((CHG_MODE_GRACE_MS / 20) + 20); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type); +} + +/* 最多同时 CHG_MODE_MAX_RAILS 路;本路续期不占新名额;关掉一路后腾出名额。 */ +static void test_flash_mode_allows_at_most_two_rails(void) +{ + const uint32_t now = hal_time_now_ms(); + TEST_ASSERT_EQUAL_INT(2, CHG_MODE_MAX_RAILS); + TEST_ASSERT_EQUAL_INT(HAL_OK, chgmode_set(1, 30, now)); + TEST_ASSERT_EQUAL_INT(HAL_OK, chgmode_set(3, 30, now)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, chgmode_set(5, 30, now)); + TEST_ASSERT_EQUAL_INT(HAL_OK, chgmode_set(3, 60, now)); /* 续期 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, chgmode_set(1, 0, now)); + TEST_ASSERT_EQUAL_INT(HAL_OK, chgmode_set(5, 30, now)); + TEST_ASSERT_EQUAL_INT(2, chgmode_count(now)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, chgmode_set(1, CHG_MODE_MAX_MIN + 1, now)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, chgmode_set(0, 30, now)); +} + +/* 过期的窗口不许「复活」:32 位毫秒时钟 ~24.8 天后 (int32_t)(until - now) 会翻正, + 所以 tick 必须把标志清掉。这里直接把时钟拨到 25 天后验证。 */ +static void test_an_expired_window_does_not_come_back_after_the_clock_wraps(void) +{ + const uint32_t now = hal_time_now_ms(); + TEST_ASSERT_EQUAL_INT(HAL_OK, chgmode_set(1, 1, now)); + TEST_ASSERT_EQUAL_UINT32(1u, chgmode_tick(now + 60000u + CHG_MODE_GRACE_MS)); /* 自然到期 */ + const uint32_t later = now + 25u * 24u * 3600u * 1000u; + TEST_ASSERT_FALSE(chgmode_active(1, later)); + TEST_ASSERT_FALSE(chgmode_relaxed(1, later)); + TEST_ASSERT_EQUAL_UINT32(0, chgmode_tick(later)); + + /* 命令关掉的,tick 不再报到期(关的时候命令层已经记过事件)。 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, chgmode_set(2, 5, later)); + TEST_ASSERT_EQUAL_INT(HAL_OK, chgmode_set(2, 0, later)); + TEST_ASSERT_EQUAL_UINT32(0, chgmode_tick(later + CHG_MODE_GRACE_MS)); +} + +/* 0.1.6 加钟:剩余时间截断在 CHG_MODE_MAX_MIN;宽限期里 / 过期后不能加(要重新开)。 */ +static void test_flash_mode_extend_caps_and_needs_an_open_window(void) +{ + const uint32_t now = hal_time_now_ms(); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, chgmode_extend(1, 10, now)); + TEST_ASSERT_EQUAL_INT(HAL_OK, chgmode_set(1, 100, now)); + TEST_ASSERT_EQUAL_INT(HAL_OK, chgmode_extend(1, 60, now)); + TEST_ASSERT_EQUAL_UINT32(CHG_MODE_MAX_MIN * 60u, chgmode_left_s(1, now)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, chgmode_extend(1, 0, now)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, chgmode_extend(1, CHG_MODE_MAX_MIN + 1, now)); + + const uint32_t over = now + CHG_MODE_MAX_MIN * 60000u + 10u; /* 窗口刚过,宽限期里 */ + TEST_ASSERT_TRUE(chgmode_relaxed(1, over)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, chgmode_extend(1, 10, over)); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_thresholds_match_the_documents); + + RUN_TEST(test_overvoltage_cuts_the_rail_within_one_period_and_logs_it); + RUN_TEST(test_the_cut_lands_exactly_one_hold_window_after_first_sight); + RUN_TEST(test_the_cut_is_never_late_because_of_sub_millisecond_jitter); + RUN_TEST(test_undervoltage_cuts_the_rail_too); + RUN_TEST(test_a_one_cycle_glitch_does_not_cut_anything); + RUN_TEST(test_exactly_one_hundred_millivolts_is_still_in_range); + RUN_TEST(test_boot_current_droop_does_not_cut_the_rail); + RUN_TEST(test_the_reference_is_the_setpoint_not_a_constant); + + RUN_TEST(test_a_rail_that_is_off_is_never_judged); + RUN_TEST(test_the_soft_start_window_is_not_judged); + RUN_TEST(test_a_blind_rail_keeps_its_power); + RUN_TEST(test_a_mute_blade_is_cut_after_ten_cycles); + RUN_TEST(test_a_blade_that_answers_again_restarts_the_count); + RUN_TEST(test_one_mute_chip_on_the_blade_still_keeps_power); + RUN_TEST(test_a_mute_blade_that_is_off_is_left_alone); + + RUN_TEST(test_the_cut_drops_vbus_inside_the_lock_on_this_rails_channel); + RUN_TEST(test_only_the_offending_rail_is_touched); + RUN_TEST(test_a_cut_rail_does_not_keep_firing); + RUN_TEST(test_a_rail_that_comes_back_starts_its_hold_window_over); + RUN_TEST(test_a_running_hard_reset_cannot_bring_the_rail_back); + RUN_TEST(test_a_cut_releases_whatever_key_was_held); + + RUN_TEST(test_reverse_current_drops_vbus); + RUN_TEST(test_reverse_current_is_not_debounced); + RUN_TEST(test_a_small_negative_current_is_not_a_backfeed); + RUN_TEST(test_backfeed_is_only_judged_while_vbus_is_on); + RUN_TEST(test_a_backfeed_does_not_refire_every_cycle); + RUN_TEST(test_being_pushed_up_drops_vbus_first_then_sr5_cuts_the_rail); + RUN_TEST(test_a_running_edl_sequence_cannot_bring_vbus_back); + RUN_TEST(test_a_backfeed_does_not_abort_an_unrelated_sequence); + RUN_TEST(test_backfeed_is_per_rail); + + RUN_TEST(test_an_empty_slot_that_is_powered_gets_cut_at_once); + RUN_TEST(test_an_empty_slot_also_drops_vbus_when_the_blade_still_answers); + RUN_TEST(test_empty_slots_that_are_off_never_fire); + RUN_TEST(test_a_slot_that_comes_back_can_be_powered_again); + + RUN_TEST(test_a_lost_mux_resets_once_before_it_cuts_all_five); + RUN_TEST(test_the_mux_rule_rearms_after_the_bus_comes_back); + RUN_TEST(test_a_short_glitch_does_not_stop_five_phones); + + RUN_TEST(test_the_rules_do_not_add_any_i2c_traffic); + RUN_TEST(test_the_whole_cycle_keeps_the_lock_discipline); + RUN_TEST(test_the_lock_check_itself_can_fail); + RUN_TEST(test_a_skipped_cycle_runs_no_rules); + RUN_TEST(test_the_cut_happens_even_with_no_event_sink); + RUN_TEST(test_flash_mode_lets_the_charger_push_the_rail_up); + RUN_TEST(test_flash_mode_still_cuts_above_the_absolute_ceiling); + RUN_TEST(test_flash_mode_ceiling_itself_is_still_in_range); + RUN_TEST(test_flash_mode_keeps_the_backfeed_rule); + RUN_TEST(test_flash_mode_keeps_the_undervoltage_rule); + RUN_TEST(test_flash_mode_is_per_rail); + RUN_TEST(test_flash_mode_expiry_drops_vbus_but_not_the_rail); + RUN_TEST(test_after_the_grace_period_the_normal_rule_is_back); + RUN_TEST(test_flash_mode_allows_at_most_two_rails); + RUN_TEST(test_an_expired_window_does_not_come_back_after_the_clock_wraps); + RUN_TEST(test_flash_mode_extend_caps_and_needs_an_open_window); + + return UNITY_END(); +} diff --git a/五路直供电源板设计.md b/五路直供电源板设计.md index f591083..56a9e41 100644 --- a/五路直供电源板设计.md +++ b/五路直供电源板设计.md @@ -69,13 +69,14 @@ Rinj 107k→150k,输出上限 4.22V→**4.01V**(§4.3 / §5)。P2-CHANNEL | 9 | **侧键接 3 个**(PWR / VOL+ / VOL−)×5 台 = 15 路 MOS | §13.3 | | 10 | **尾插装原厂小板**(不自制 BM24 转接板) | §13.5 方案 A | | 11 | **USB 仅接电脑 / Hub(恒 5V),不接快充充电器** | §15.1 | -| 12 | USB 三条对策全做:软件关充电 + VBUS 负载开关 + 12V 母线泄放 | §15.2 | +| 12 | USB 三条对策全做:软件关充电 + VBUS 负载开关 + 12V 母线泄放。**例外:刷机模式**(固件 0.1.5,2026-09-28)—— 按路限时、最多 2 路,允许充电不断 USB;靠 eFuse 反向阻断兜住反灌,见需求 SR-10 | §15.2 | | 13 | 15 路按键经 **74HC595 ×3**(24 位链,含 5 路 EDL_EN + HUB_RST)驱动,EN / VBUS_EN 保持 GPIO 直连。**2026-09-06 起三片 595 + 15 颗按键 MOS 整窝在载板**(§26.23) | §8.1 / §26.23 | | 14 | **侧键接触改中框式焊盘,顶针取消**(2026-09-06):载板顶层 4ר1.5 裸铜盘(ENIG、钢网不开口),螺丝把主板压在载板上、间隙 **1.0 mm**,主板自带弹片直压铜盘。定位孔 **H5 不打**(每台 6 颗螺钉)。 | §22.3e | | 15 | **走线方向规则(2026-09-07):顶层水平、底层纵向**。两块板共用的栅格布线器按此计价(软规则,逆向走法 2 倍代价) | §10.1b | -| 16 | **风扇改 FAN-R2(2026-09-07)**:五个 3 针风扇 → **两只 4 线 PWM 风扇**(山洋 9G0612P4S001,一进一出),高边 P-MOS 默认关电 + 独立 PWM 开漏 + **两路独立测速**。连带把 12V 预算从 8.3 A 顶到 **≈10 A**,`IO5=FAN_EN` / `IO4=FAN_TACH_OUT` | §22.6 | +| 16 | **风扇改 FAN-R2(2026-09-07)**:五个 3 针风扇 → **两只 4 线 PWM 风扇**(~~山洋 9G0612P4S001~~ ⇒ **2026-09-13 改 60×60×10 薄风扇**(广诚散热科技、小 4PIN = PC 标准 4 线口、孔距 50.2,配机箱自攻螺丝;**MPN / 电压 / 电流 / PWM·TACH 规格待补**),一进一出;为缩短 R2 机箱,见 R2 §3.3),高边 P-MOS 默认关电 + 独立 PWM 开漏 + **两路独立测速**。连带把 12V 预算从 8.3 A 顶到 **≈10 A**,`IO5=FAN_EN` / `IO4=FAN_TACH_OUT` | §22.6 | | 18 | ~~**U32 的 `PRT_SWP1~5` 各 100k 下拉**:`R900~R904`~~ **已被 #19 取代(同日)** —— 五颗里有三颗(CH1/CH2/CH3)在载板上**布不出线**,见 §21.6a 末尾 | §21.6a | | 19 | **U32 改 `CFG_SEL[2:0]=110`(2026-09-07,取代 #18)**:Table 5-2 的「Internal Default Configuration • **Strap Options are Disabled**」——十个 strap 脚根本不采样,`R900~R904` 一颗不装。`CFG_SEL2`(13) 是纯输入(`I`),由硬接 GND 改**硬接 `+3V3_HUB`**(不加电阻);`CFG_SEL1` 的 `R712` 由下拉改上拉;`CFG_SEL0` 的 `R711` 不动。**必须连带删掉 `R714`/`R715`**(否则口 6/7 变幽灵口)。BOM 净 −7 颗 | §21.6b | +| 19a | **R2 底板(刀片式)2026-09-10 改回 `CFG_SEL=000`**:`PRT_SWP1~3` 各 100k 上拉 = 口 1~3 D+/D− 片内对调(底板上北去三对不再 U 形掉头),`PRT_SWP4~6` 各 100k 下拉;`CFG_SEL2` 接 GND、`R712` 改下拉。**只改底板,R1 载板仍按 #19**。理由与接法见 `.claude/r2/a4_hub.py` 文件头 | R2 | | 17 | **C737 换固态钽(2026-09-07)**:AMS1117-3.3 的 22 µF 输出电容按手册 §Stability 用 **KEMET T491A226K010AT**(C116575),不再用陶瓷。**1 脚是正极** | §26.25 | | 20 | **12V 输入座改圆形 DC 电源座(2026-09-08)**:`J1` 由 XT30PW-M 换成 **`DC-005-5.5*2.5`**(C9900017043,封装 `DC-IN-TH_DC-005-2`)。理由是成品 12V/10A 适配器出厂就是 5.5×2.5 圆头,装配上顺手。⚠️ **代价是插座超额定**:家族额定 2~5 A、本板最坏 9.99 A,发热在**插头内芯与插座弹片的接触面**,板上加铜救不了 —— **用户已明确接受、不降载**。板端唯一缓解:`pin2`/`pin3` 两个负极触点**都接地并联**,接触电阻减半。引脚:1=中心正极 / 2=负极静触点 / 3=负极动触点(2-3 无插头时短接)。⚠️ 装配前用万用表核:无插头时 2-3 通、1 对 2/3 断 | §1 / §22.6 | @@ -1163,7 +1164,7 @@ GND 靠地平面 + 散热盘阵列一个就够。出线宽度 1.0 mm —— **不阻塞打样,但要定** - [ ] 主板机械固定方式(建议连中框,见 §13.6)。 -- [ ] `input_suspend` 节点在目标 ROM 上的实际路径(§15.2 对策 1)。 +- [x] ~~`input_suspend` 节点在目标 ROM 上的实际路径(§15.2 对策 1)~~ → **2026-09-28 小米8(MIUI / Android 10)实测:`/sys/class/power_supply/battery/input_suspend`**,写 1 后 `status=Discharging`、`usb/online=0`,VBUS 常开不再顶高母线。由 Magisk 模块 `phone/magisk-phonerail/`(service.sh,日志 `/data/local/tmp/phonerail_chg.log`)开机自动写入 = 需求 SW-7。 --- @@ -2593,6 +2594,7 @@ Table 7.1:06h Config Data Byte 1 内部默认 ROM = 9Bh = 1001_1011b | **R708** | **12.0 kΩ ±1%**(C25752) | `RBIAS`(63) → GND | Table 5.1:「A 12.0kΩ (+/-1%) resistor…to set the transceiver's internal bias」。**这个值不能动** | | **Y1** | **24 MHz ±10ppm, CL=9pF**(C2762192) | `XTAL1`(61) / `XTAL2`(60) | §9.1:「Parallel Resonant, Fundamental Mode, **24 MHz ±350ppm**」。选 ±10ppm 余量很大 | | **C740 / C741** | **12 pF C0G**(C1547)×2 | 晶振两端 → GND | 按 Figure 9.2:`CL = (C1+CS1)(C2+CS2)/(C1+CS1+C2+CS2)`,取杂散 ~3pF、CL=9pF → C1=C2≈12pF | +| **1 MΩ 跨接**(⚠ 2026-09-10 补,R1 起就漏了)| **1 MΩ 0402**(`0402WGF1004TCE` / C26083)| `XTAL1`(61) ↔ `XTAL2`(60) | USB2517 Hardware Design Checklist p9 原文:「A 1 MΩ resistor connected across XTAL1 and XTAL2 is **required**. Failure to place this resistor will result in unstable crystal operation.」数据手册 p52 Figure 9-1 也画着 1Meg。R2 底板 = `R723`;**R1 载板待补**(用户 2026-09-10 定:两块都补) | | **C742 / C743** | **1 µF 0402**(C52923)×2 | `VDD18`(25)、`VDD18PLL`(62) → GND | Table 5.1 两处都写「must have a **1.0µF (or greater) ±20% (ESR <0.1Ω)** capacitor to VSS」 | | **R709 / R710** | 100 kΩ / 100 kΩ | 上游 VBUS → 分压 → `VBUS_DET`(44) | §7.1.2 + Table 5.1:「When designing a **detachable** hub, this pin must be connected to the VBUS power pin of the USB port that is upstream」。我们的上游是可插拔的 USB-C,**属于 detachable**,所以要真接、不能拉到 3.3V。该脚是 3.3V 逻辑,5V 必须分压 | | **R711~R713** | 10 kΩ ×3 | `CFG_SEL[2:0]` → GND | §21.3 | @@ -3343,7 +3345,7 @@ U33: QA=EDL_EN1 QB=EDL_EN2 QC=EDL_EN3 QD=EDL_EN4 QE=EDL_EN5 QF/QG/QH 备用 | 项 | 现行 | |---|---| -| 风扇 | **山洋 SANYO DENKI 9G0612P4S001 ×2**(`C2920592`),60×60×25,12V 四线 PWM | +| 风扇 | ~~山洋 SANYO DENKI 9G0612P4S001 ×2(`C2920592`),60×60×25~~ ⇒ **2026-09-13 改:60×60×10 薄风扇 ×2**(广诚散热科技,小 4PIN / PC 4 线:1 GND、2 +12V、3 TACH、4 PWM —— 与 J26/J27 针序一致;孔距 50.2 / 孔 Ø4.4,配机箱自攻螺丝)。⚠ **额定电压、电流、风量、静压、PWM/TACH 规格均未取得**,12 V 预算里的风扇电流沿用旧值、待核 | | 布置 | **一进一出**:J26 = 进风、J27 = 排风,分列左右两侧;原顶部排风孔取消 | | 额定 | 100% PWM 时每只 0.67 A / 8.04 W、11000 RPM;**0% PWM 仍转 3300 RPM**、每只 0.07 A | | 供电 | 高边 P-MOS **默认关电**:`+12V --Q7(AO3401A)--> FAN_12V_SW` | diff --git a/需求文档.md b/需求文档.md index da768f7..6d5354a 100644 --- a/需求文档.md +++ b/需求文档.md @@ -179,7 +179,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU | **SR-7** | 放电 MOS 与 eFuse EN **硬件反相联锁**,物理上不可能同时导通 | §7.3 | | **SR-8** | **输入保护**:保险丝 + TVS + P-MOS 反接保护 | §2 | | **SR-9** | **12 V 母线泄放**:母线 >13.2 V 时导通泄放,防 USB 反灌或突卸能量抬高母线 | §15.2 | -| **SR-10** | **USB 反灌三条对策全做,一条都不能省**:① 软件关充电 ② VBUS 负载开关 ③ 母线泄放 | 决策 12 / §13.5 | +| **SR-10** | **USB 反灌三条对策全做,一条都不能省**:① 软件关充电 ② VBUS 负载开关 ③ 母线泄放。**唯一的有意例外:刷机模式**(固件 0.1.5,2026-09-28 用户定)——按路、带时限(默认 60 分钟,最长 120)、最多同时 2 路,窗口内允许手机充电、「顶高断 VBUS」与过压改按绝对值 4.45 V 判,反灌与欠压判据不变;依据是每路 eFuse(TPS25940A)自带反向阻断,充电器顶高节点时能量进不了 Buck / 12V 母线(板 B 五次顶高实测电流 0 ~ −0.5 mA) | 决策 12 / §13.5 / firmware `chgmode.h` | | **SR-11** | 上游 USB 不做 PD 协商(CC 双 5.1 kΩ 下拉,纯 UFP),杜绝 VBUS 被抬到 9 / 12 V | §15.1 | | **SR-12** | 五台手机的地**不得**在载板上并成一根:定位孔做 **NPTH**,立柱用**尼龙** | §25.2 | | **SR-13** | 固件**不得**提供任何能让输出超过 4.2 V 的接口 | §8.3 | @@ -283,7 +283,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU | **SW-5** | 侧键序列:短按 100 ms 唤醒 / 长按 5 s 开机(小米8 实测 2 s 不够,2026-09-28)/ 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot / VOL++PWR 进 Recovery。硬重启末尾的开机键**按完看电流**:松键后 3~5 s 内 < 100 mA 就重按,最多 3 次,仍不开记事件 19「开机重按」并保持供电(固件 0.1.2,起因是电源键弹片接触不良) | | **SW-6** | EDL 时序**固定为** `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0`(先短接再插 USB) | | **SW-7** | 开机脚本执行「关充电」(写 `input_suspend` 类节点),路径需在首台机上确认后写死 | -| **SW-8** | USB 反灌保护:检测到该路电流转负或母线上升,**200 ms 内**断开该路 VBUS | +| **SW-8** | USB 反灌保护:检测到该路电流转负或母线上升,**200 ms 内**断开该路 VBUS。刷机模式窗口内「顶高」按绝对值 4.45 V 判(SR-10 的例外),电流转负照断 | | **SW-9** | 风扇 PWM 闭环(以载板 LM75BD 为反馈)+ TACH 停转告警 | | **SW-10** | 整板功率预算:读输入侧 INA226,超适配器能力时按优先级降载 | | **SW-11** | 上电默认全关:五路输出关、VBUS 断、按键不按、EDL 不触发 |