固件 0.1.5~0.1.13-c5:刷机模式、多组 WiFi、热插拔初始化、总线锁误判、硬重启断 VBUS

- 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) <[email protected]>
这个提交包含在:
zhou yang
2026-09-29 08:16:21 +08:00
共同撰写人 Claude Opus 5.5
父节点 385d2f9c47
当前提交 9fb5bba367
共修改 35 个文件,包含 5120 行新增和 3675 行删除
+89 -1
查看文件
@@ -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)
+1
查看文件
@@ -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
+1
查看文件
@@ -12,6 +12,7 @@ idf_component_register(
"slot.c"
"sched.c"
"tasks.c"
"chgmode.c"
"t_safety.c"
"t_actuator.c"
"t_thermal.c"
+1
查看文件
@@ -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」只是查一眼就回来 —— 那会让这个中优先级任务
+110
查看文件
@@ -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;
}
}
+87
查看文件
@@ -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 <stdbool.h>
#include <stdint.h>
#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 */
+2 -2
查看文件
@@ -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)
+5
查看文件
@@ -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
+107
查看文件
@@ -5,8 +5,10 @@
判重、名字表、回应拼装在 cmd.c;status / events 的正文在 cmd_report.c。 */
#include <string.h>
#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;
+41 -7
查看文件
@@ -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));
+1
查看文件
@@ -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);
+6 -2
查看文件
@@ -7,6 +7,7 @@
#include <stdio.h>
#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;
+583 -582
查看文件
文件差异内容过多而无法显示 加载差异
+196 -192
查看文件
@@ -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 <stdbool.h>
#include <stdint.h>
#include "hal_err.h"
#include "t_safety.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- 事件类型 ------------------------------------------------------------
⚠️⚠️ **这些数字是 flash 里的格式,不是一个内部枚举。** 板子上跑了半年的
日志是按数字存的,改一个值就等于把历史记录全部重新解释了一遍,
而且**读出来仍然是合法的记录**,没有任何一处会报错。
只许在末尾追加,不许插入、不许重排、不许删除。
`test_flash_format_is_frozen()` 把每一个数字都钉死了。 */
typedef enum {
LOG_EV_NONE = 0,
LOG_EV_BOOT = 1, /* 每次上电的第一条。§10.7「上电从 0 计」⇒ 时间戳会跳回去,
没有这一条的话,重启前后的记录混在一起没法读 */
LOG_EV_LOST = 2, /* RAM 缓冲溢出,丢了 bus_mv 条(见文件头) */
/* A10 的四条判据事件。⚠️ 这里的数字与 `safety_ev_t` 的**不一样**,
两边靠 `evlog_from_safety()` 里那个显式 switch 对应 —— 不许写成加个偏移。 */
LOG_EV_OVER_VOLT = 3, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */
LOG_EV_UNDER_VOLT = 4, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */
LOG_EV_REVERSE = 5, /* SW-8:电流转负 → 断这一路 VBUS */
LOG_EV_PUSHED_UP = 6, /* SW-8:VBUS 期间电压被顶高 → 断这一路 VBUS */
/* §10.7 必记事件里**还没人产生**的那几类。号先占住(见上面那条警告),
产生它们的代码各归各的包:括号里是包号。 */
LOG_EV_EFUSE_FAULT = 7, /* eFuse FLT# 拉低(A15) */
LOG_EV_PG_CHANGE = 8, /* PGOOD 变化,arg = 新电平(A15) */
LOG_EV_I2C_LOST = 9, /* I²C 器件失联,arg = 地址(A15) */
LOG_EV_THERMAL_DERATE = 10,/* 过温降载,bus_mv = 温度 m°C(A16) */
LOG_EV_FAN_STALL = 11, /* 风扇停转(A16) */
LOG_EV_REBOOT = 12, /* 每次重启序列,arg = 软/硬 + 谁下的命令(A9/A12) */
LOG_EV_CMD_REJECT = 13, /* 越界命令被拒,arg = 命令号(A12) */
/* ⭐ R2 新增(计划 §5.8 / §5.9)。**加在末尾**,前面 13 个的数字一个都没动 ——
flash 里跑过的历史记录因此仍然读得对。 */
LOG_EV_MUX_LOST = 14, /* TCA9548A 连续选不中 ⇒ 五路全断(rail = 0,整板级) */
LOG_EV_SLOT_EMPTY = 15,/* `PRSNT#` 为高:刀片不在位,这一路不许带电 */
LOG_EV_PD_LIMIT = 16, /* PD 档位换了 / 触发限功率降载,bus_mv = 上限 mW/100(R-6) */
/* 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 <stdbool.h>
#include <stdint.h>
#include "hal_err.h"
#include "t_safety.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- 事件类型 ------------------------------------------------------------
⚠️⚠️ **这些数字是 flash 里的格式,不是一个内部枚举。** 板子上跑了半年的
日志是按数字存的,改一个值就等于把历史记录全部重新解释了一遍,
而且**读出来仍然是合法的记录**,没有任何一处会报错。
只许在末尾追加,不许插入、不许重排、不许删除。
`test_flash_format_is_frozen()` 把每一个数字都钉死了。 */
typedef enum {
LOG_EV_NONE = 0,
LOG_EV_BOOT = 1, /* 每次上电的第一条。§10.7「上电从 0 计」⇒ 时间戳会跳回去,
没有这一条的话,重启前后的记录混在一起没法读 */
LOG_EV_LOST = 2, /* RAM 缓冲溢出,丢了 bus_mv 条(见文件头) */
/* A10 的四条判据事件。⚠️ 这里的数字与 `safety_ev_t` 的**不一样**,
两边靠 `evlog_from_safety()` 里那个显式 switch 对应 —— 不许写成加个偏移。 */
LOG_EV_OVER_VOLT = 3, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */
LOG_EV_UNDER_VOLT = 4, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */
LOG_EV_REVERSE = 5, /* SW-8:电流转负 → 断这一路 VBUS */
LOG_EV_PUSHED_UP = 6, /* SW-8:VBUS 期间电压被顶高 → 断这一路 VBUS */
/* §10.7 必记事件里**还没人产生**的那几类。号先占住(见上面那条警告),
产生它们的代码各归各的包:括号里是包号。 */
LOG_EV_EFUSE_FAULT = 7, /* eFuse FLT# 拉低(A15) */
LOG_EV_PG_CHANGE = 8, /* PGOOD 变化,arg = 新电平(A15) */
LOG_EV_I2C_LOST = 9, /* I²C 器件失联,arg = 地址(A15) */
LOG_EV_THERMAL_DERATE = 10,/* 过温降载,bus_mv = 温度 m°C(A16) */
LOG_EV_FAN_STALL = 11, /* 风扇停转(A16) */
LOG_EV_REBOOT = 12, /* 每次重启序列,arg = 软/硬 + 谁下的命令(A9/A12) */
LOG_EV_CMD_REJECT = 13, /* 越界命令被拒,arg = 命令号(A12) */
/* ⭐ R2 新增(计划 §5.8 / §5.9)。**加在末尾**,前面 13 个的数字一个都没动 ——
flash 里跑过的历史记录因此仍然读得对。 */
LOG_EV_MUX_LOST = 14, /* TCA9548A 连续选不中 ⇒ 五路全断(rail = 0,整板级) */
LOG_EV_SLOT_EMPTY = 15,/* `PRSNT#` 为高:刀片不在位,这一路不许带电 */
LOG_EV_PD_LIMIT = 16, /* PD 档位换了 / 触发限功率降载,bus_mv = 上限 mW/100(R-6) */
/* 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 */
+152 -152
查看文件
@@ -1,152 +1,152 @@
/* 带 mux 的 I²C 门面。契约与三条规矩见 i2cbus.h。 */
#include "i2cbus.h"
#include <stddef.h>
#include "board_pins.h"
#include "hal_gpio.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "tca9548a.h"
/* `MUX_RST#` 的低电平宽度。datasheet 只要求 6 ns,这里给的是「至少 1 ms」。
⚠️ **实际会比 1 ms 长**:ESP 侧 `hal_time_delay_ms()` 是 `vTaskDelay()`,
在 `CONFIG_FREERTOS_HZ=100` 下一个 tick 就是 10 ms(而且它按「至少睡一个
tick」写死)。对这颗器件绰绰有余,但要知道**调用者会被挡住这么久** ——
`i2cbus_recover()` 是 `t_safety` 在放锁之后调的,所以触发恢复的那一圈
巡检会晚一拍。这是可以接受的:走到这条路径时 mux 已经连续失联了,
比起「五路电压电流全看不见还在继续跑」,晚一圈不算什么。 */
#define I2CBUS_RST_MS 1
static bool s_inited;
static bool s_in_xact; /* 嵌套检测。单核上够用,双核上外面还有锁挡着 */
static int s_ch = I2CBUS_CH_UNKNOWN;
static uint32_t s_selects;
static uint32_t s_select_fails;
static uint32_t s_fail_streak;
static uint32_t s_lock_timeouts;
static uint32_t s_recoveries;
int i2cbus_channel(void) { return s_ch; }
uint32_t i2cbus_selects(void) { return s_selects; }
uint32_t i2cbus_select_fails(void) { return s_select_fails; }
uint32_t i2cbus_select_fail_streak(void) { return s_fail_streak; }
uint32_t i2cbus_lock_timeouts(void) { return s_lock_timeouts; }
uint32_t i2cbus_recoveries(void) { return s_recoveries; }
void i2cbus_forget_channel(void) { s_ch = I2CBUS_CH_UNKNOWN; }
/* 选通道。**成功才更新影子**;失败一律把影子作废(规矩 3)。 */
static hal_err_t select_ch(int ch)
{
if (ch == s_ch) {
return HAL_OK; /* 影子命中:一个字节都不发(计划 §5.5 的账就靠这个) */
}
s_selects++;
const hal_err_t err = tca9548a_select(TCA9548A_ADDR, ch);
if (err != HAL_OK) {
s_ch = I2CBUS_CH_UNKNOWN;
s_select_fails++;
if (s_fail_streak < 0xFFFFFFFFu) {
s_fail_streak++;
}
return err;
}
s_ch = ch;
s_fail_streak = 0;
return HAL_OK;
}
hal_err_t i2cbus_init(void)
{
s_inited = false;
s_in_xact = false;
s_ch = I2CBUS_CH_UNKNOWN;
s_selects = 0;
s_select_fails = 0;
s_fail_streak = 0;
s_lock_timeouts = 0;
s_recoveries = 0;
/* `MUX_RST#` 低有效、板上 R611 上拉。先写 1 再配输出会有一个「配成输出时
默认输出 0」的窗口,那就是一次意外复位 —— 所以顺序是:配输出(HAL 两侧
都保证配完是 0)→ 立刻写 1。窗口仍在,但只有几个指令,且此刻还没人用总线。 */
hal_err_t err = hal_gpio_config(BOARD_PIN_MUX_RST_N, HAL_GPIO_OUTPUT);
if (err != HAL_OK) {
return err;
}
err = hal_gpio_write(BOARD_PIN_MUX_RST_N, 1);
if (err != HAL_OK) {
return err;
}
s_inited = true;
/* 认件顺便把通道全关。失败不 abort —— 见头文件。 */
err = hal_lock_take(HAL_LOCK_I2C, HAL_LOCK_FOREVER);
if (err != HAL_OK) {
return err;
}
const hal_err_t probe = tca9548a_probe(TCA9548A_ADDR);
if (probe == HAL_OK) {
s_ch = I2CBUS_CH_TRUNK; /* probe 末尾就是「全关」 */
} else {
s_ch = I2CBUS_CH_UNKNOWN;
}
hal_lock_give(HAL_LOCK_I2C);
return probe;
}
hal_err_t i2cbus_xact(int ch, uint32_t timeout_ms, i2cbus_fn_t fn, void *arg)
{
if (!s_inited || fn == NULL) {
return HAL_ERR_STATE;
}
if (s_in_xact) {
return HAL_ERR_STATE; /* 嵌套:回调里又调了一次(头文件末尾那条警告) */
}
if (ch < I2CBUS_CH_TRUNK || ch >= TCA9548A_CH_COUNT) {
return HAL_ERR_ARG;
}
hal_err_t err = hal_lock_take(HAL_LOCK_I2C, timeout_ms);
if (err != HAL_OK) {
s_lock_timeouts++;
return HAL_ERR_TIMEOUT; /* 这一次什么也没做 —— 调用方决定重试还是跳过 */
}
s_in_xact = true;
err = select_ch(ch);
if (err == HAL_OK) {
err = fn(arg);
}
s_in_xact = false;
hal_lock_give(HAL_LOCK_I2C);
return err;
}
hal_err_t i2cbus_recover(void)
{
if (!s_inited) {
return HAL_ERR_STATE;
}
if (s_in_xact) {
return HAL_ERR_STATE; /* 持着锁去睡 1 ms,会把巡检钉住 */
}
s_recoveries++;
hal_err_t err = hal_gpio_write(BOARD_PIN_MUX_RST_N, 0);
if (err != HAL_OK) {
i2cbus_forget_channel();
return err;
}
hal_time_delay_ms(I2CBUS_RST_MS);
err = hal_gpio_write(BOARD_PIN_MUX_RST_N, 1);
/* ⚠️ **无论成功与否都作废影子**:复位之后器件里的通道是全关的,
而影子还记着上一次选的那一路。不作废 = 下一次事务跳过选通、
发到一个断开的支路上,现象是「所有刀片一起失联」。 */
i2cbus_forget_channel();
return err;
}
/* 带 mux 的 I²C 门面。契约与三条规矩见 i2cbus.h。 */
#include "i2cbus.h"
#include <stddef.h>
#include "board_pins.h"
#include "hal_gpio.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "tca9548a.h"
/* `MUX_RST#` 的低电平宽度。datasheet 只要求 6 ns,这里给的是「至少 1 ms」。
⚠️ **实际会比 1 ms 长**:ESP 侧 `hal_time_delay_ms()` 是 `vTaskDelay()`,
在 `CONFIG_FREERTOS_HZ=100` 下一个 tick 就是 10 ms(而且它按「至少睡一个
tick」写死)。对这颗器件绰绰有余,但要知道**调用者会被挡住这么久** ——
`i2cbus_recover()` 是 `t_safety` 在放锁之后调的,所以触发恢复的那一圈
巡检会晚一拍。这是可以接受的:走到这条路径时 mux 已经连续失联了,
比起「五路电压电流全看不见还在继续跑」,晚一圈不算什么。 */
#define I2CBUS_RST_MS 1
static bool s_inited;
static 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;
}
+121 -27
查看文件
@@ -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)。 */
+57 -7
查看文件
@@ -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);
}
+26 -5
查看文件
@@ -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;
+1 -1
查看文件
@@ -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,
+1 -1
查看文件
@@ -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
+8
查看文件
@@ -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)) {
+4
查看文件
@@ -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);
+76 -4
查看文件
@@ -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;
}
+13
查看文件
@@ -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`,
+498 -457
查看文件
@@ -1,457 +1,498 @@
#include "t_safety.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "hal_i2c_acct.h"
#include "hal_time.h"
#include "i2cbus.h"
#include "rail.h"
#include "rail_cfg.h"
#include "slot.h"
#include "t_actuator.h"
#include "tasks.h"
/* 判据与四条规矩见 t_safety.h。这里只补一句 R2 最容易写错的:
**断 VBUS 在锁内(它是 I²C 上的 PCA9555),关 eFuse 在锁外(它是 GPIO)。**
两者以前是一对 GPIO,现在不是了。 */
static safety_ev_fn s_sink;
static void *s_sink_ctx;
void t_safety_set_event_sink(safety_ev_fn fn, void *ctx)
{
s_sink = fn;
s_sink_ctx = ctx;
}
const char *t_safety_ev_str(safety_ev_t ev)
{
switch (ev) {
case SAFETY_EV_NONE:
return "无";
case SAFETY_EV_OVER_VOLT:
return "过压关路(SR-5)";
case SAFETY_EV_UNDER_VOLT:
return "欠压关路(SR-5)";
case SAFETY_EV_REVERSE:
return "反灌断VBUS(SW-8)";
case SAFETY_EV_PUSHED_UP:
return "被顶高断VBUS(SW-8)";
case SAFETY_EV_MUX_LOST:
return "mux失联全断";
case SAFETY_EV_SLOT_EMPTY:
return "刀片不在位";
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 <assert.h>
#include <stdio.h>
#include <string.h>
#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);
}
+257 -236
查看文件
@@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "hal_err.h"
#include "ina226.h"
#include "pca9555.h"
#include "phonerail.h"
#ifdef __cplusplus
extern "C" {
#endif
/* 每一路等 I²C 锁最多等这么久。等过头这一路就废了,而**废一路一圈**
(20 ms 后重来)比**赖着不走**(把最高优先级任务钉死)好得多。 */
#define T_SAFETY_LOCK_TIMEOUT_MS 5
/* ⭐ **整圈等锁的总预算**,R2 必须有的一条。
R1 一圈只拿一次锁,所以「单次超时 5 ms」就是全部的账。**R2 一圈要拿六次**
(五路各一次 + 主干一次),单看每次 5 ms 没问题,六次加起来是 **30 ms —— 比
20 ms 的周期还长**。总线长期繁忙时的现象不是「偶尔丢一拍」,而是**巡检成片
地丢拍、周期整个失准**,而每一次 `xact` 都老老实实在 5 ms 就返回了,
哪一层都不会报错。
所以除了单次超时,还要有一条整圈的闸:本圈在等锁上花掉的时间超过这个数,
**剩下的路这一圈就不读了**(记 `skipped`)。取周期的一半,留另一半给判据、
给 `t_actuator`、给别的任务。
⚠️ 已知的不公平:预算是按顺序花的,所以长期繁忙时**后面几路会比前面几路
更常被跳过**。没有做轮转起点,因为 SR-5 判据数的是「连续两圈都越界」,
某一路偶尔漏一圈只让它晚一圈跳闸;而轮转会让「第几次拿锁」这种可断言的
不变量消失,代价比收益大。真正该看的是 `skipped` 有没有持续增长。 */
#define T_SAFETY_LOCK_BUDGET_MS 10
/* ---- A10:判据阈值。**每一个都有出处,改了就是改需求** ------------------
`test_thresholds_match_the_documents()` 会拦。 */
/* SR-5 / SW-2:「电压偏离设定 >±100 mV」。比的是 INA226 读回来的母线电压与
**该路 DAC 的设定值**(`rail_setpoint_mv()`),不是 3900 这个常数 ——
SW-4 允许运维单路微调,拿常数比会把一台调到 4.0 V 的机器当成过压。 */
#define T_SAFETY_DEV_MV 100
/* SR-5 的**欠压**一侧单独放宽到 −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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "hal_err.h"
#include "ina226.h"
#include "pca9555.h"
#include "phonerail.h"
#ifdef __cplusplus
extern "C" {
#endif
/* 每一路等 I²C 锁最多等这么久。等过头这一路就废了,而**废一路一圈**
(20 ms 后重来)比**赖着不走**(把最高优先级任务钉死)好得多。 */
#define T_SAFETY_LOCK_TIMEOUT_MS 5
/* ⭐ **整圈等锁的总预算**,R2 必须有的一条。
R1 一圈只拿一次锁,所以「单次超时 5 ms」就是全部的账。**R2 一圈要拿六次**
(五路各一次 + 主干一次),单看每次 5 ms 没问题,六次加起来是 **30 ms —— 比
20 ms 的周期还长**。总线长期繁忙时的现象不是「偶尔丢一拍」,而是**巡检成片
地丢拍、周期整个失准**,而每一次 `xact` 都老老实实在 5 ms 就返回了,
哪一层都不会报错。
所以除了单次超时,还要有一条整圈的闸:本圈在等锁上花掉的时间超过这个数,
**剩下的路这一圈就不读了**(记 `skipped`)。取周期的一半,留另一半给判据、
给 `t_actuator`、给别的任务。
⚠️ 已知的不公平:预算是按顺序花的,所以长期繁忙时**后面几路会比前面几路
更常被跳过**。没有做轮转起点,因为 SR-5 判据数的是「连续两圈都越界」,
某一路偶尔漏一圈只让它晚一圈跳闸;而轮转会让「第几次拿锁」这种可断言的
不变量消失,代价比收益大。真正该看的是 `skipped` 有没有持续增长。 */
#define T_SAFETY_LOCK_BUDGET_MS 10
/* ---- A10:判据阈值。**每一个都有出处,改了就是改需求** ------------------
`test_thresholds_match_the_documents()` 会拦。 */
/* SR-5 / SW-2:「电压偏离设定 >±100 mV」。比的是 INA226 读回来的母线电压与
**该路 DAC 的设定值**(`rail_setpoint_mv()`),不是 3900 这个常数 ——
SW-4 允许运维单路微调,拿常数比会把一台调到 4.0 V 的机器当成过压。 */
#define T_SAFETY_DEV_MV 100
/* SR-5 的**欠压**一侧单独放宽到 −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 */
+147 -1
查看文件
@@ -11,6 +11,7 @@
#include <string.h>
#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);
+2 -1
查看文件
@@ -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 只产生其中两类。剩下的号先占住,
+31
查看文件
@@ -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);
+104 -6
查看文件
@@ -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);
+110
查看文件
@@ -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);
文件差异内容过多而无法显示 加载差异
文件差异内容过多而无法显示 加载差异