From 1f6862ac3838a7be7e8e8fabc81993348b18a75b Mon Sep 17 00:00:00 2001 From: zhou yang Date: Mon, 28 Sep 2026 01:13:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E4=BB=B6=200.1.1-c5=EF=BC=9A=E7=A1=AC?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E5=90=8E=E8=87=AA=E5=8A=A8=E5=BC=80=E6=9C=BA?= =?UTF-8?q?=E3=80=81MQTT=20=E5=8F=91=E5=B8=83=E6=AD=BB=E9=94=81=E3=80=81?= =?UTF-8?q?=E6=AC=A0=E5=8E=8B=E6=94=BE=E5=AE=BD=E5=88=B0=20=E2=88=92250=20?= =?UTF-8?q?mV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 硬重启等到 PG 后长按电源键 5 s(新状态 hr-boot):电池端上电的小米8 停在关机态,原来的 power_cycle 等于关机。ACT_KEY_MS_ON 2000 → 5000 (实测 2 s 松手即掉电)。NO_PG 时不按。 - 修死锁:执行器持 s_mtx 等 esp-mqtt 的 api_lock,esp-mqtt 任务持 api_lock 分发 DISCONNECTED 时等 s_mtx。热点一断 USB/WiFi 全失联、按键可能松不开。 执行器现在只往 s_outbox 拷贝,由 t_link 交给 esp-mqtt。 - SR-5 欠压一侧 −100 → −250 mV(T_SAFETY_UNDER_MV),过压仍 +100 mV: 带屏开机峰值 2.1 A 时实测 −115 mV 被误关。需求 SR-5 / SW-2 / SW-5 / 验收表同步。 板 B 上板:power_cycle → hr-boot → 手机开机,最低 3778 mV / 2.12 A 不再跳闸。 Co-Authored-By: Claude Opus 5.5 --- firmware/HANDOFF.md | 16 +++++- firmware/main/mlink_esp.c | 79 +++++++++++++++++++++++------ firmware/main/phonerail.h | 2 +- firmware/main/t_actuator.c | 18 +++++-- firmware/main/t_actuator.h | 17 +++++-- firmware/main/t_safety.c | 4 +- firmware/main/t_safety.h | 6 +++ firmware/test/test_t_actuator.c | 71 ++++++++++++++++++++++---- firmware/test/test_t_safety_rules.c | 37 +++++++++++--- 需求文档.md | 8 +-- 10 files changed, 211 insertions(+), 47 deletions(-) diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index 9c905ae..cab6044 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -1,5 +1,19 @@ # 交接 -更新:2026-09-27 深夜 固件 **0.1.1-c3**(I²C 不应答不再报成 STATE;重启不断电)+ rack 界面改版 / 推固件页 / 底板温度曲线 +更新:2026-09-28 固件 **0.1.1-c5**(硬重启末尾自动开机 + MQTT 发布死锁 + 欠压放宽到 −250 mV);板 B 经 USB 整片烧上,WiFi / MQTT 已通 + +## c4:两处修复(2026-09-28,第一片刀片 + 小米8 上板时发现) +- **硬重启后手机不开机**:电池端上电的小米8 停在关机态(只漏 ~2 mA)。现在 `power_cycle` 等到 PG 后 + **长按电源键 5 s**(新状态 `hr-boot`,`ACT_HR_BOOT_KEY_MS`);NO_PG 时不按。`ACT_KEY_MS_ON` 2000 → **5000**: + 实测按 2 s 时 PMIC 起来了(415 mA),一松手就掉回 1 mA。需求 SW-5 已同步。rack 的二级恢复(power_cycle)因此真的能拉起手机了。 + 上板:power_cycle → 4.1 s 上电 → hr-boot 5 s → res ok,手机进入开机电流曲线。 +- **热点一断整板失联(死锁)**:执行器「持 `s_mtx` → `esp_mqtt_client_enqueue` 等 `api_lock`」, + esp-mqtt 任务「持 `api_lock` 分发 DISCONNECTED → `on_mqtt` 的 `ST_EDIT` 等 `s_mtx`」。 + 现象:USB 命令无应答、WiFi 不重连、遥测停,只有 t_safety / t_thermal 的日志还在 —— **按到一半的键也会松不开**。 + 修:执行器只往 `s_outbox`(深度 4)拷一份,`t_link` 里 `outbox_drain()` 交给 esp-mqtt;持 `s_mtx` 时不调 esp-mqtt。 + ⚠️ 死锁状态下 OTA 进不去,只能 USB 烧(esptool `--before usb_reset`,第一次常报 ClearCommError,再跑一次就过)。 +- **c5:带载欠压误关**。小米8 插屏幕后开机峰值 ~2.1 A,轨 1 读回 3785 mV(设定 3900,−115 mV),SR-5 的 −100 mV 直接关路。 + 用户拍板:**欠压一侧放宽到 −250 mV**(`T_SAFETY_UNDER_MV`),过压仍 +100 mV;需求 SR-5 / SW-2 / 验收表同步。 + 压降 ≈ 55 mΩ × 2 A 的来源(Buck 负载调整 / eFuse / 采样电阻 / 走线)还没拆开查。 ## 一句话现状 OTA 换固件 / 超时回滚现在走 `warm_restart()`(`main/warm_esp.c`):目标固件认接管 ⇒ 手机不断电; diff --git a/firmware/main/mlink_esp.c b/firmware/main/mlink_esp.c index 16d6a55..dccf375 100644 --- a/firmware/main/mlink_esp.c +++ b/firmware/main/mlink_esp.c @@ -7,10 +7,14 @@ | WiFi / IP 事件(掉线、拿到地址) | 系统事件循环 | | MQTT 事件(连上、收到命令) | esp-mqtt 自己的任务,优先级压到与 `t_link` 相同(§10.3:链路最低) | | **MQTT 客户端的创建 / 销毁、重配置、遥测节拍** | `t_link`(`mlink_esp_poll()`)—— 生命周期只在一个任务里管 | - | 回应 / 遥测的发布 | 执行器任务(cmd_queue 的回调)—— 只用 `esp_mqtt_client_enqueue()`,**不阻塞** | + | 回应 / 遥测的发布 | 执行器任务只把一份拷贝塞进 `s_outbox`(不等);`t_link` 取出来 `esp_mqtt_client_enqueue()` | - `esp_mqtt_client_publish()` 在网络慢的时候会阻塞到超时,放在执行器任务里就是让 - 「按键该松了」等 TCP。enqueue 只进发件箱,由 esp-mqtt 自己的任务发。 + ⚠️ **执行器任务一律不碰 esp-mqtt 的任何 API**,连 `esp_mqtt_client_enqueue()` 也不行: + 它要拿客户端的 `api_lock`,而 esp-mqtt 任务在连接 / 写超时 / 分发事件时一直持着这把锁。 + 0.1.1-c3 之前执行器是「持 `s_mtx` → 等 `api_lock`」,esp-mqtt 任务分发 DISCONNECTED 时是 + 「持 `api_lock` → `on_mqtt` 里 `ST_EDIT` 等 `s_mtx`」—— 2026-09-28 热点一断就死锁: + USB 命令没人执行、WiFi 不重连、**按到一半的键松不开**,只有 t_safety / t_thermal 还活着。 + 现在的规矩:**持 `s_mtx` 时不调 esp-mqtt;执行器根本不调 esp-mqtt**。 ---- 失联不动作(§10.2 / §10.8) ----------------------------------------- WiFi 断、broker 断,本文件只做一件事:**重连**(WiFi 指数退避,MQTT 由 esp-mqtt 每 10 s 重试)。 @@ -25,6 +29,7 @@ #include "esp_timer.h" #include "esp_wifi.h" #include "freertos/FreeRTOS.h" +#include "freertos/queue.h" #include "freertos/semphr.h" #include "hal_time.h" #include "mlink.h" @@ -65,6 +70,20 @@ static mlink_tele_t s_tele; static volatile bool s_tele_inflight; static bool s_up; +/* 发件队列(见文件头):执行器 / esp-mqtt 任务往里拷,t_link 取出来交给 esp-mqtt。 + 深度 4 × 1.5 KB:遥测 1 s 一条、回应偶尔一条,t_link 至少每 100 ms 来取一次; + 满了说明 t_link 被 esp-mqtt 堵住了 —— 丢掉这一条,遥测记 skipped,回应靠上位机重传。 */ +typedef struct { + uint8_t topic; /* mlink_topic_t */ + uint8_t qos; + uint16_t len; + char data[CMD_OUT_MAX]; +} out_msg_t; + +#define OUTBOX_DEPTH 4 +static QueueHandle_t s_outbox; +static SemaphoreHandle_t s_out_mtx; /* 只护 enqueue() 的暂存缓冲 */ + /* ---- 状态:改完立刻推给 netcfg,命令处理器随时读得到一份完整的 ------------ */ /* 可变参数:语句里带逗号(snprintf 的实参)也能整个传进来。 */ @@ -119,39 +138,66 @@ static void on_cfg_changed(void *ctx) s_dirty = true; /* 执行器任务里只置标志;断开重连在 t_link 里做(esp_mqtt_client_stop 会阻塞) */ } -/* ---- 发布(执行器任务里调,只进发件箱) ----------------------------------- */ +/* ---- 发布:任何任务都只进 s_outbox,不等、不碰 esp-mqtt ------------------- */ -static void enqueue(mlink_topic_t t, const char *data, size_t len, int qos, int retain) +static bool enqueue(mlink_topic_t t, const char *data, size_t len, int qos) { - if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(10)) != pdTRUE) { - return; + /* 1.5 KB 不放栈上。执行器和 esp-mqtt 任务(link_error)都会进来 ⇒ s_out_mtx 护着。 + 这把锁持有期间只有 memcpy 和一次**不等**的入队,不会再去等任何别的东西。 */ + static out_msg_t m; + if (s_outbox == NULL || len > sizeof(m.data)) { + return false; } - if (s_mqtt != NULL && s_st.mqtt == NET_UP) { - (void)esp_mqtt_client_enqueue(s_mqtt, mlink_topic(t), data, (int)len, qos, retain, true); + /* 没连上就不攒(上线后 on_mqtt 会先补一条遥测)。读一个枚举,不拿 s_mtx。 */ + if (s_st.mqtt != NET_UP) { + return false; } - xSemaphoreGive(s_mtx); + if (xSemaphoreTake(s_out_mtx, pdMS_TO_TICKS(10)) != pdTRUE) { + return false; + } + m.topic = (uint8_t)t; + m.qos = (uint8_t)qos; + m.len = (uint16_t)len; + memcpy(m.data, data, len); + const bool ok = xQueueSend(s_outbox, &m, 0) == pdTRUE; + xSemaphoreGive(s_out_mtx); + return ok; } static void reply_mqtt(void *ctx, const char *line, size_t len) { (void)ctx; - enqueue(MLINK_T_RSP, line, len, 1, 0); + (void)enqueue(MLINK_T_RSP, line, len, 1); } static void reply_tele(void *ctx, const char *line, size_t len) { (void)ctx; - enqueue(MLINK_T_TELE, line, len, 0, 0); - ST_EDIT(s_st.tele_sent++); + const bool ok = enqueue(MLINK_T_TELE, line, len, 0); + ST_EDIT(if (ok) { s_st.tele_sent++; } else { s_st.tele_skipped++; }); s_tele_inflight = false; } +/* t_link 里调:把发件队列交给 esp-mqtt。s_mqtt 只在 t_link 里改,这里读它不用锁; + esp_mqtt_client_enqueue 可能等 api_lock 等到 esp-mqtt 的网络超时 —— 堵的是 t_link, + 而 t_link 此刻不持任何我们的锁,esp-mqtt 的事件回调照样拿得到 s_mtx。 */ +static void outbox_drain(void) +{ + static out_msg_t m; + while (s_outbox != NULL && xQueueReceive(s_outbox, &m, 0) == pdTRUE) { + if (s_mqtt != NULL) { + (void)esp_mqtt_client_enqueue(s_mqtt, mlink_topic((mlink_topic_t)m.topic), m.data, + (int)m.len, m.qos, 0, true); + } + } +} + static void link_error(const char *payload, const char *err) { char out[96]; const size_t n = ulink_link_error(payload, err, out, sizeof(out)); if (n > 0) { - enqueue(MLINK_T_RSP, out, n, 1, 0); + (void)enqueue(MLINK_T_RSP, out, n, 1); } } @@ -291,12 +337,14 @@ static void wifi_apply(void) hal_err_t mlink_esp_init(void) { s_mtx = xSemaphoreCreateMutex(); + s_out_mtx = xSemaphoreCreateMutex(); + s_outbox = xQueueCreate(OUTBOX_DEPTH, sizeof(out_msg_t)); esp_err_t e = nvs_flash_init(); if (e == ESP_ERR_NVS_NO_FREE_PAGES || e == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); /* NVS 分区格式不认:清掉重来(里面只有本模块的配置与 WiFi 校准) */ e = nvs_flash_init(); } - if (s_mtx == NULL || e != ESP_OK) { + if (s_mtx == NULL || s_out_mtx == NULL || s_outbox == NULL || e != ESP_OK) { ESP_LOGE(TAG, "NVS 起不来:%s —— WiFi 链路不可用", esp_err_to_name(e)); netcfg_init(NULL, NULL, NULL, NULL); return HAL_ERR_STATE; @@ -352,6 +400,7 @@ void mlink_esp_poll(void) if (s_mqtt == NULL && s_st.wifi == NET_UP && s_cur.uri[0] != '\0') { mqtt_start(); } + outbox_drain(); if (s_st.mqtt != NET_UP || !mlink_tele_due(&s_tele, hal_time_now_ms(), s_cur.tele_ms)) { return; } diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h index 0362238..ecd2a94 100644 --- a/firmware/main/phonerail.h +++ b/firmware/main/phonerail.h @@ -3,7 +3,7 @@ #define PHONERAIL_H /* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ -#define PHONERAIL_FW_VERSION "0.1.1-c3" +#define PHONERAIL_FW_VERSION "0.1.1-c5" /* 通道数。五路直供,见设计文档 §2。 */ #define PHONERAIL_RAIL_COUNT 5 diff --git a/firmware/main/t_actuator.c b/firmware/main/t_actuator.c index f6fd67b..92b92dd 100644 --- a/firmware/main/t_actuator.c +++ b/firmware/main/t_actuator.c @@ -122,8 +122,18 @@ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now) case ACT_ST_HR_PG: { bool pg = false; if (pg_now(rail, &pg) && pg) { - finish(r, ACT_RES_OK, now); - return ACT_IDLE_WAIT_MS; + /* 电好了 ⇒ 长按电源键开机(ACT_HR_BOOT_KEY_MS 的注释)。 + 和 KEY 序列一样:先记 release_mask 再看写没写成 —— 写失败时器件里那个字节 + 是未知的,照样走 CLEANUP 把它显式清一遍。 */ + const uint8_t mask = key_out_mask(ACT_KEYS_PWR); + r->release_mask = mask; + r->hold_ms = ACT_HR_BOOT_KEY_MS; + if (out_write(rail, mask, mask) != HAL_OK) { + finish(r, ACT_RES_IO, now); /* 电是开着的,只是没按上键 */ + return ACT_CLEANUP_RETRY_MS; + } + enter(r, ACT_ST_HR_BOOT, now); + return ACT_HR_BOOT_KEY_MS; } if (el >= ACT_HR_PG_TIMEOUT_MS) { /* ⚠️ 电**继续开着**。见 ACT_RES_NO_PG 的注释:把「看不见」当成 @@ -135,6 +145,7 @@ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now) return left < ACT_HR_PG_POLL_MS ? left : ACT_HR_PG_POLL_MS; } + case ACT_ST_HR_BOOT: case ACT_ST_KEY_HOLD: if (el < r->hold_ms) { return r->hold_ms - el; @@ -232,7 +243,7 @@ hal_err_t t_actuator_hard_reset(int rail) return err; /* 序列没开始 */ } r->seq = ACT_SEQ_HARD_RESET; - r->release_mask = 0; /* 硬重启在链上没按下任何东西 */ + r->release_mask = 0; /* 断电这一段没按任何东西;开机那一下在 HR_PG 里才记 */ r->result = ACT_RES_NONE; enter(r, ACT_ST_HR_OFF, hal_time_now_ms()); return HAL_OK; @@ -356,6 +367,7 @@ const char *t_actuator_state_str(act_state_t st) case ACT_ST_HR_OFF: return "hr-off"; case ACT_ST_HR_RAMP: return "hr-ramp"; case ACT_ST_HR_PG: return "hr-pg"; + case ACT_ST_HR_BOOT: return "hr-boot"; case ACT_ST_KEY_HOLD: return "key-hold"; case ACT_ST_EDL_SHORT: return "edl-short"; case ACT_ST_EDL_ENUM: return "edl-enum"; diff --git a/firmware/main/t_actuator.h b/firmware/main/t_actuator.h index 4e402fb..1bb61ba 100644 --- a/firmware/main/t_actuator.h +++ b/firmware/main/t_actuator.h @@ -4,7 +4,7 @@ | 序列 | 时序 | 出处 | |---|---|---| - | 硬重启 | `EFUSE_EN↓` → 等 3~5 s → `EFUSE_EN↑` → 等 **17 ms** → 等 PG 置位 | 设计文档 §7.4 | + | 硬重启 | `EFUSE_EN↓` → 等 3~5 s → `EFUSE_EN↑` → 等 **17 ms** → 等 PG 置位 → 长按电源键 5 s 开机 | 设计文档 §7.4;开机那一步见 `ACT_HR_BOOT_KEY_MS` | | 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | SW-5 / §13.3 | | EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | SW-6 / §22.4 | @@ -80,9 +80,19 @@ extern "C" { 会退化成「一律等满 500 ms」,而测试与遥测都看不出区别。 */ #define ACT_HR_PG_POLL_MS 5 -/* SW-5 的三档按键时长。 */ +/* 硬重启的最后一步:等到 PG 之后长按电源键开机。电池端上电的手机**停在关机态** + (小米8 实测:重新上电后只漏 ~2 mA,不按键就一直关着),不按这一下的硬重启 + 等于「关机」。时长就是 SW-5 的开机档。 + ⚠️ 只在**等到 PG** 时按:NO_PG 多半是这一片 PCA9555 读不到,键也在同一片上, + 去按只会让这一路卡在 CLEANUP 里重试。 */ +#define ACT_HR_BOOT_KEY_MS ACT_KEY_MS_ON + +/* SW-5 的三档按键时长。 + ⚠️ 开机那一档 2026-09-28 小米8 实测:按 2 s 时 PMIC 起来了(415 mA),一松手 3 s 内 + 掉回 ~1 mA —— SoC 还没接住 PS_HOLD。按 5 s 松手后稳在 ~210 mA、约 45 s 进系统。 + 上限是 ACT_KEY_MS_FORCE:按到 10 s 就成了强制重启。 */ #define ACT_KEY_MS_TAP 100 /* 短按:唤醒 / 息屏 */ -#define ACT_KEY_MS_ON 2000 /* 长按:开机 */ +#define ACT_KEY_MS_ON 5000 /* 长按:开机 */ #define ACT_KEY_MS_FORCE 10000 /* 长按:强制重启(两级重启的「一级(软)」) */ /* 按键时长上限。上层传进来一个 10 分钟,等于把电源键按死 —— @@ -138,6 +148,7 @@ typedef enum { ACT_ST_HR_OFF, /* `EFUSE_EN` 已拉低,等 3~5 s(放电由硬件自己做) */ ACT_ST_HR_RAMP, /* `EFUSE_EN` 已拉高,等 17 ms 走完 tON + 斜坡 */ ACT_ST_HR_PG, /* 等 PG 置位 */ + ACT_ST_HR_BOOT, /* PG 到了,电源键按着,保持 ACT_HR_BOOT_KEY_MS 开机 */ ACT_ST_KEY_HOLD, /* 键已按下,保持 T */ ACT_ST_EDL_SHORT, /* `EDL_EN=1`,等短接建立 */ ACT_ST_EDL_ENUM, /* `VBUS_EN=1`,等枚举 */ diff --git a/firmware/main/t_safety.c b/firmware/main/t_safety.c index d2b5ff6..699ee30 100644 --- a/firmware/main/t_safety.c +++ b/firmware/main/t_safety.c @@ -135,8 +135,8 @@ static void judge_locked(safety_snap_t *s, int rail, uint32_t now) } } - /* ---- SR-5:±100 mV 连着 T_SAFETY_DEV_HOLD_CYCLES 圈(= 持续 20 ms)---- */ - if (dev > T_SAFETY_DEV_MV || dev < -T_SAFETY_DEV_MV) { + /* ---- 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++; } diff --git a/firmware/main/t_safety.h b/firmware/main/t_safety.h index 5d10e0c..8cdb138 100644 --- a/firmware/main/t_safety.h +++ b/firmware/main/t_safety.h @@ -73,6 +73,12 @@ extern "C" { 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」——「持续」在这里数的是**圈**,不是墙钟毫秒: 连着两圈新鲜样本都越界才算,而成立的那一刻就是断电的那一刻。 diff --git a/firmware/test/test_t_actuator.c b/firmware/test/test_t_actuator.c index 3ed1474..328b77f 100644 --- a/firmware/test/test_t_actuator.c +++ b/firmware/test/test_t_actuator.c @@ -278,15 +278,30 @@ static void test_hard_reset_walks_the_full_power_up_sequence(void) run_ms(1); TEST_ASSERT_EQUAL_UINT32(ACT_HR_RAMP_MS, hal_time_now_ms() - t_on); + + /* ⭐ PG 到了 ⇒ 当场按下电源键开机。电池端上电的手机停在关机态, + 不按这一下的硬重启等于「关机」(小米8 实测,t_actuator.h)。 */ + TEST_ASSERT_EQUAL_INT(ACT_ST_HR_BOOT, t_actuator_state(rail)); + TEST_ASSERT_EQUAL_INT(1, key_pin(rail, PCA9555_KEY_PWR)); + TEST_ASSERT_EQUAL_INT(0, key_pin(rail, PCA9555_KEY_VUP)); + TEST_ASSERT_EQUAL_INT(0, key_pin(rail, PCA9555_KEY_VDN)); + + run_ms(ACT_HR_BOOT_KEY_MS - 1); + TEST_ASSERT_EQUAL_INT(1, key_pin(rail, PCA9555_KEY_PWR)); + TEST_ASSERT_TRUE(t_actuator_busy(rail)); + + run_ms(1); + TEST_ASSERT_EQUAL_INT(0, key_pin(rail, PCA9555_KEY_PWR)); /* 松开 */ TEST_ASSERT_FALSE(t_actuator_busy(rail)); TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail)); TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(en)); TEST_ASSERT_EQUAL_UINT32(1, t_actuator_done(rail)); - /* ⭐ 硬重启在总线上什么都不该发:键本来就没按着 ⇒ 影子不脏 ⇒ 一个事务都不发。 - R2 里这条比 R1 更要紧 —— 每一次多余的事务都是从巡检嘴里抢锁。 */ - TEST_ASSERT_EQUAL_UINT32(takes0, sim_lock_takes(HAL_LOCK_I2C)); - assert_only_this_blade_moved(0, l0, w0); /* 0 = 五片一个都不许动 */ + /* ⭐ 总线上只有按下、松开这两次事务,而且只动这一片。断电那一段一个都不发: + 键本来就没按着 ⇒ 影子不脏。R2 里每一次多余的事务都是从巡检嘴里抢锁。 */ + TEST_ASSERT_EQUAL_UINT32(takes0 + 2, sim_lock_takes(HAL_LOCK_I2C)); + assert_only_this_blade_moved(rail, l0, w0); + TEST_ASSERT_EQUAL_HEX8(l0[rail - 1], latch(rail)); /* 收工后这一片回到原样 */ TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); } @@ -301,7 +316,7 @@ static void test_hard_reset_touches_only_its_own_en_pin(void) } s_pg_val[rail - 1] = true; TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); - run_ms(ACT_HR_OFF_MS + ACT_HR_RAMP_MS); + run_ms(ACT_HR_OFF_MS + ACT_HR_RAMP_MS + ACT_HR_BOOT_KEY_MS); TEST_ASSERT_FALSE(t_actuator_busy(rail)); for (int r = 1; r <= N; r++) { @@ -332,6 +347,8 @@ static void test_no_pg_leaves_the_rail_powered(void) TEST_ASSERT_FALSE(t_actuator_busy(rail)); TEST_ASSERT_EQUAL_INT(ACT_RES_NO_PG, t_actuator_last_result(rail)); TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(en)); /* ⭐ 不许再关掉 */ + /* 开机键也不按:键和 PG 在同一片 PCA9555 上,读不到 PG 时去按只会卡在 CLEANUP。 */ + TEST_ASSERT_EQUAL_INT(0, key_pin(rail, PCA9555_KEY_PWR)); } /* 读得到、但一直是低(电源确实没好)走的是同一条路:照样不自作主张断电。 */ @@ -360,6 +377,8 @@ static void test_late_pg_is_still_accepted(void) s_pg_val[rail - 1] = true; run_ms(ACT_HR_PG_POLL_MS); + TEST_ASSERT_EQUAL_INT(ACT_ST_HR_BOOT, t_actuator_state(rail)); + run_ms(ACT_HR_BOOT_KEY_MS); TEST_ASSERT_FALSE(t_actuator_busy(rail)); TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail)); } @@ -385,8 +404,8 @@ static void test_the_task_loop_only_sleeps_what_step_asks_for(void) for (int i = 0; i < 10000; i++) { const uint32_t w = t_actuator_step(); /* 任务体:它说睡多久就睡多久 */ - if (!t_actuator_busy(rail)) { - total = hal_time_now_ms() - t0; /* 收工的**那一刻**,不是下一次醒来 */ + if (t_actuator_state(rail) == ACT_ST_HR_BOOT) { + total = hal_time_now_ms() - t0; /* 等到 PG 的**那一刻**,不是下一次醒来 */ break; } sim_time_advance_ms(w == 0 ? 1 : w); @@ -396,9 +415,18 @@ static void test_the_task_loop_only_sleeps_what_step_asks_for(void) } } TEST_ASSERT_TRUE(pg_raised); + TEST_ASSERT_TRUE(total > 0 && total <= pg_at + 2 * ACT_HR_PG_POLL_MS); + + /* 按键保持期也照样听 step 的:睡到点就松键收工,不多睡一整段。 */ + for (int i = 0; i < 10000 && t_actuator_busy(rail); i++) { + const uint32_t w = t_actuator_step(); + if (t_actuator_busy(rail)) { + sim_time_advance_ms(w == 0 ? 1 : w); + } + } TEST_ASSERT_FALSE(t_actuator_busy(rail)); TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail)); - TEST_ASSERT_TRUE(total <= pg_at + 2 * ACT_HR_PG_POLL_MS); + TEST_ASSERT_TRUE(hal_time_now_ms() - t0 <= total + ACT_HR_BOOT_KEY_MS + ACT_CLEANUP_RETRY_MS); } /* 硬重启之前先把这一路的三个键松开:断电重启时有键按着,手机会开进 @@ -544,7 +572,7 @@ static void test_the_key_hold_never_holds_the_i2c_lock(void) TEST_ASSERT_EQUAL_INT(-1, sim_lock_owner(HAL_LOCK_I2C)); /* 写完当场就放掉 */ TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); - for (int i = 0; i < 120; i++) { + for (int i = 0; i < (int)(ACT_KEY_MS_ON / 20) + 20; i++) { (void)t_actuator_step(); TEST_ASSERT_EQUAL_INT(-1, sim_lock_owner(HAL_LOCK_I2C)); TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); @@ -836,6 +864,26 @@ static void test_abort_during_hard_reset_does_not_restore_power(void) TEST_ASSERT_EQUAL_INT(0, sim_gpio_level(en)); /* 还是关着的,等上层拿主意 */ } +/* 开机键按着的时候被打断(过压关路走的就是这条):键当场松开,电不动。 + 键留在按下状态的现象是手机再也起不来,而遥测上一切正常。 */ +static void test_abort_during_boot_press_releases_the_key(void) +{ + const int rail = 2; + const int en = rail_cfg(rail)->efuse_en; + + s_pg_val[rail - 1] = true; + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_ms(ACT_HR_OFF_MS + ACT_HR_RAMP_MS + 1000); + TEST_ASSERT_EQUAL_INT(ACT_ST_HR_BOOT, t_actuator_state(rail)); + TEST_ASSERT_EQUAL_INT(1, key_pin(rail, PCA9555_KEY_PWR)); + + t_actuator_abort(rail); + TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, t_actuator_last_result(rail)); + TEST_ASSERT_EQUAL_INT(0, key_pin(rail, PCA9555_KEY_PWR)); + TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(en)); +} + /* EDL 跑到「已插 USB」那一段被打断:`EDL_EN` 立刻松(那是本序列按下去的), **`VBUS_EN` 一动不动** —— R2 里它和按键在同一个字节里,一不小心就会被 收尾那次读改写顺手清掉,而现象是「手机莫名其妙从 9008 掉线」。 */ @@ -972,7 +1020,9 @@ static void test_timings_match_the_documents(void) TEST_ASSERT_TRUE(ACT_HR_PG_POLL_MS > 0 && ACT_HR_PG_POLL_MS * 10 <= ACT_HR_PG_TIMEOUT_MS); TEST_ASSERT_EQUAL_UINT32(100, ACT_KEY_MS_TAP); /* SW-5 三档 */ - TEST_ASSERT_EQUAL_UINT32(2000, ACT_KEY_MS_ON); + TEST_ASSERT_EQUAL_UINT32(5000, ACT_KEY_MS_ON); /* 小米8 实测 2 s 不够(t_actuator.h) */ + TEST_ASSERT_TRUE(ACT_KEY_MS_ON < ACT_KEY_MS_FORCE); /* 开机那一下不能按成强制重启 */ + TEST_ASSERT_EQUAL_UINT32(ACT_KEY_MS_ON, ACT_HR_BOOT_KEY_MS); TEST_ASSERT_EQUAL_UINT32(10000, ACT_KEY_MS_FORCE); TEST_ASSERT_TRUE(ACT_KEY_MS_FORCE <= ACT_KEY_MAX_MS); /* 最长那一档不能被自己的上限拦掉 */ @@ -1075,6 +1125,7 @@ int main(void) RUN_TEST(test_a_failed_press_still_goes_through_cleanup); RUN_TEST(test_abort_releases_the_key_and_leaves_the_pins_alone); RUN_TEST(test_abort_during_hard_reset_does_not_restore_power); + RUN_TEST(test_abort_during_boot_press_releases_the_key); RUN_TEST(test_abort_during_edl_releases_the_short_but_keeps_vbus); RUN_TEST(test_actuator_and_safety_share_one_lock_without_starving); diff --git a/firmware/test/test_t_safety_rules.c b/firmware/test/test_t_safety_rules.c index 274b778..6768eda 100644 --- a/firmware/test/test_t_safety_rules.c +++ b/firmware/test/test_t_safety_rules.c @@ -296,6 +296,7 @@ static void test_thresholds_match_the_documents(void) { /* SR-5 / SW-2 的两个数,逐字来自需求 §9 那两行。 */ TEST_ASSERT_EQUAL_INT(100, T_SAFETY_DEV_MV); + TEST_ASSERT_EQUAL_INT(250, T_SAFETY_UNDER_MV); /* 欠压一侧,2026-09-28 放宽 */ TEST_ASSERT_EQUAL_INT(20, T_SAFETY_DEV_HOLD_MS); /* 判据数的是**圈**,SR-5 写的是**毫秒** —— 这一条把两者钉在一起。 @@ -321,6 +322,7 @@ static void test_thresholds_match_the_documents(void) /* §12.1 注入的是 ±150 mV / −60 mA,两个都必须落在阈值**外侧**, 否则验收测的是「阈值刚好卡在注入值上」。 */ TEST_ASSERT_TRUE(150 > T_SAFETY_DEV_MV); + TEST_ASSERT_TRUE(300 > T_SAFETY_UNDER_MV); /* 欠压注入 −300 mV */ TEST_ASSERT_TRUE(-60000 < T_SAFETY_REV_UA); /* ⭐ R2 新增:mux 连续选不中多少次才算失联。 @@ -365,7 +367,7 @@ static void test_the_cut_lands_exactly_one_hold_window_after_first_sight(void) { sim_i2c_set_bus_time(false); power_up(2); - inject(2, NOMINAL_MV - 150, 1000000); + inject(2, NOMINAL_MV - 300, 1000000); const uint32_t t_first_sight = hal_time_now_ms(); beat(); @@ -409,7 +411,7 @@ static void test_the_cut_is_never_late_because_of_sub_millisecond_jitter(void) static void test_undervoltage_cuts_the_rail_too(void) { power_up(3); - inject(3, NOMINAL_MV - 150, 1000000); + inject(3, NOMINAL_MV - 300, 1000000); beats(2); TEST_ASSERT_EQUAL_INT(0, en_level(3)); @@ -441,12 +443,28 @@ static void test_exactly_one_hundred_millivolts_is_still_in_range(void) beats(10); TEST_ASSERT_EQUAL_INT(1, en_level(1)); - inject(1, NOMINAL_MV - T_SAFETY_DEV_MV, 1000000); + inject(1, NOMINAL_MV - T_SAFETY_UNDER_MV, 1000000); beats(10); TEST_ASSERT_EQUAL_INT(1, en_level(1)); TEST_ASSERT_EQUAL_INT(0, g_n_ev); } +/* ⭐ 上板实测那一幕:手机开机峰值 2.1 A 时读回 −115 mV。放宽之前这里会关路, + 把正在开机的手机直接断电。过压那一侧同样的偏差照旧要关。 */ +static void test_boot_current_droop_does_not_cut_the_rail(void) +{ + power_up(1); + inject(1, NOMINAL_MV - 115, 2136000); + beats(10); + TEST_ASSERT_EQUAL_INT(1, en_level(1)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + + inject(1, NOMINAL_MV + 115, 1000000); + beats(3); + TEST_ASSERT_EQUAL_INT(0, en_level(1)); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type); +} + /* 比的是**该路 DAC 的设定值**,不是 3900 这个常数。SW-4 允许单路微调, 拿常数比的话,一台被调到 4.010 V 的手机会被当成过压关掉 —— 而且只有那一路。 */ static void test_the_reference_is_the_setpoint_not_a_constant(void) @@ -460,11 +478,13 @@ static void test_the_reference_is_the_setpoint_not_a_constant(void) TEST_ASSERT_EQUAL_INT(1, en_level(1)); TEST_ASSERT_EQUAL_INT(0, g_n_ev); - /* 回到 3900:现在这才是「偏低 110 mV」。 */ + /* 调到最低档,实测仍是 3900:拿设定值比才是「偏高」。 + (原来是调到最高档再回 3900 判偏低 110 mV —— 欠压放宽到 −250 mV 之后那已经不越界了。) */ + TEST_ASSERT_EQUAL_INT(HAL_OK, rail_set_mv(1, RAIL_MV_MIN)); inject(1, NOMINAL_MV, 1000000); beats(2); TEST_ASSERT_EQUAL_INT(0, en_level(1)); - TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_UNDER_VOLT, g_ev[0].type); + TEST_ASSERT_EQUAL_UINT8(SAFETY_EV_OVER_VOLT, g_ev[0].type); } /* ---- 判据什么时候**不**武装(三道闸,每一道都是一次真事故的预防) -------- */ @@ -507,7 +527,7 @@ static void test_a_blind_rail_keeps_its_power(void) /* ⚠️ 顺序是有讲究的:**先让坏值真的读进快照一次**,再让器件失联。 反过来写(先失联再改寄存器)的话快照里躺着的是个正常值, 「盲跑那一路照样判据」这个错就一点痕迹都不会留下 —— 测试永远绿。 */ - inject(4, NOMINAL_MV - 150, 1000000); + inject(4, NOMINAL_MV - 300, 1000000); beat(); TEST_ASSERT_EQUAL_INT(1, en_level(4)); /* 第一个样本只起表 */ sim_i2c_set_present_ch(ch_of(4), rail_cfg(4)->ina_addr, false); @@ -517,7 +537,7 @@ static void test_a_blind_rail_keeps_its_power(void) TEST_ASSERT_EQUAL_INT(0, g_n_ev); TEST_ASSERT_FALSE(g_snap.rail_ok[3]); /* 快照里躺着的确实是那个越界的旧值 —— 这是上面那句「顺序有讲究」的凭据。 */ - TEST_ASSERT_EQUAL_INT32(NOMINAL_MV - 150, g_snap.rail[3].bus_mv); + TEST_ASSERT_EQUAL_INT32(NOMINAL_MV - 300, g_snap.rail[3].bus_mv); /* 器件回来了,值还是坏的 —— 这时才该关,而且要重新数满 20 ms。 */ sim_i2c_set_present_ch(ch_of(4), rail_cfg(4)->ina_addr, true); @@ -545,7 +565,7 @@ static void test_the_cut_drops_vbus_inside_the_lock_on_this_rails_channel(void) const uint32_t en_seq_before = sim_gpio_write_seq(rail_cfg(rail)->efuse_en); - inject(rail, NOMINAL_MV - 150, 1000000); /* 欠压,不是反灌 */ + inject(rail, NOMINAL_MV - 300, 1000000); /* 欠压,不是反灌 */ beat(); /* 第一圈只起表 */ TEST_ASSERT_EQUAL_UINT32(0, g_w[rail - 1].n); @@ -1177,6 +1197,7 @@ int main(void) RUN_TEST(test_undervoltage_cuts_the_rail_too); RUN_TEST(test_a_one_cycle_glitch_does_not_cut_anything); RUN_TEST(test_exactly_one_hundred_millivolts_is_still_in_range); + RUN_TEST(test_boot_current_droop_does_not_cut_the_rail); RUN_TEST(test_the_reference_is_the_setpoint_not_a_constant); RUN_TEST(test_a_rail_that_is_off_is_never_judged); diff --git a/需求文档.md b/需求文档.md index 200adff..27612c6 100644 --- a/需求文档.md +++ b/需求文档.md @@ -174,7 +174,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU | **SR-2** | **第一层 · 拓扑限幅**:分压网络与 DAC 量程在物理上把输出限死在 3.42 ~ 4.12 V(含最坏容差),全部落在正常锂电电压内。防固件 bug / I²C 误码 / MCU 跑飞 | §4 | | **SR-3** | **第二层 · eFuse 独立过压断开**(每路一颗,**最后一道硬件防线**):OVP 4.36 V 标称、**断开而非钳位**、响应 2 µs;限流 4.45 A 恒流、不依赖 MCU | §5 | | **SR-4** | eFuse 的过压响应**必须来得及**:按最坏值(Buck 高侧限流 14 A、负载 2200 µF)核算,余量 ≥ 10 倍 | §5 | -| **SR-5** | **第三层 · 软件监控**:电压偏离设定 >±100 mV 且持续 20 ms → 拉低该路 EN 并上报。**仅承担记录与告警,不承担实时保护** | §5 | +| **SR-5** | **第三层 · 软件监控**:电压高于设定 >100 mV 或低于设定 >250 mV(欠压一侧 2026-09-28 放宽:小米8 带屏开机峰值 2.1 A 时实测 −115 mV),且持续 20 ms → 拉低该路 EN 并上报。**仅承担记录与告警,不承担实时保护** | §5 | | **SR-6** | **复位安全**:ESP32 **非计划**复位(上电、崩溃、看门狗、按 EN、esptool)期间,五路输出全关、五路 VBUS 全断、15 路按键全不按、5 路 EDL 全不触发、风扇不转。靠**上下拉电阻**保证,不靠固件。
**例外(2026-09-27 用户定)**:固件自己发起的**计划内重启**(OTA 换固件、自证超时回滚)锁住 `EFUSE_EN`、新固件接管,手机不断电;按键 / EDL 仍全松,VBUS 保持。见 §10.9「重启不断电」 | §8.1 / §22.4 | | **SR-7** | 放电 MOS 与 eFuse EN **硬件反相联锁**,物理上不可能同时导通 | §7.3 | | **SR-8** | **输入保护**:保险丝 + TVS + P-MOS 反接保护 | §2 | @@ -277,10 +277,10 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU | ID | 需求 | |---|---| | **SW-1** | 崩溃检测 + 两级自动重启(§4.1)—— **整块板子最核心的价值** | -| **SW-2** | 20 ms 周期巡检六路 INA226,电压偏离 >±100 mV 持续 20 ms 即关路告警(SR-5) | +| **SW-2** | 20 ms 周期巡检六路 INA226,电压偏离 >+100 / −250 mV 持续 20 ms 即关路告警(SR-5) | | **SW-3** | 过流 / 故障事件记录:路号、时刻、电流值 —— 排查排线接触不良的唯一线索 | | **SW-4** | 单路电压微调接口(跑不稳的手机提到 4.0 V,或反向降压降温),**上限受 SR-13 约束** | -| **SW-5** | 侧键序列:短按 100 ms 唤醒 / 长按 2 s 开机 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot / VOL++PWR 进 Recovery | +| **SW-5** | 侧键序列:短按 100 ms 唤醒 / 长按 5 s 开机(小米8 实测 2 s 不够,2026-09-28)/ 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot / VOL++PWR 进 Recovery | | **SW-6** | EDL 时序**固定为** `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0`(先短接再插 USB) | | **SW-7** | 开机脚本执行「关充电」(写 `input_suspend` 类节点),路径需在首台机上确认后写死 | | **SW-8** | USB 反灌保护:检测到该路电流转负或母线上升,**200 ms 内**断开该路 VBUS | @@ -1010,7 +1010,7 @@ FR-12 是「中(可选)」,建议**首版先不做**,把接口留出来 | PR-4 / PR-5 | 扫 DAC 全量程 | 输出扫过 3.52 ~ 4.01 V,与公式误差 <1%;**任何码值都到不了 4.2 V** | | SR-2 | 固件写越界值 | 输出被拓扑限死,不越过上限 | | SR-3 / SR-4 | 人为断开反馈下电阻 | eFuse 断开,输出侧尖峰不超过 4.7 V | -| SR-5 | 注入 ±150 mV 偏差 | 20 ms 内关路并产生告警记录 | +| SR-5 | 注入 +150 / −300 mV 偏差 | 20 ms 内关路并产生告警记录 | | SR-6 | 手动复位 ESP32 | 示波器确认五路立即关断、15 路按键无一误触发、EDL 无触发 | | SR-7 | 固件同时置位 EN 与放电 | 硬件互斥,10 Ω 上无持续功耗 | | SR-9 | 可调电源把母线升到 13.5 V | 泄放导通并拉回 13.2 V 以下 |