From 385d2f9c4760241ff898e9eb9ec83a0354a3da25 Mon Sep 17 00:00:00 2001 From: zhou yang Date: Mon, 28 Sep 2026 09:07:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E4=BB=B6=200.1.4-c5=EF=BC=9A=E6=95=B4?= =?UTF-8?q?=E7=89=87=E5=88=80=E7=89=87=E4=B8=8D=E5=BA=94=E7=AD=94=EF=BC=88?= =?UTF-8?q?INA226=20+=20PCA9555=20=E5=90=8C=E6=97=B6=20NAK=20200=20ms?= =?UTF-8?q?=EF=BC=89=E2=87=92=20=E5=85=B3=E8=BF=99=E4=B8=80=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - t_safety:eFuse 开着、本路两颗都 NAK 连续 10 圈 ⇒ abort + 关 eFuse,新事件 20 BLADE_MUTE; 只关 eFuse,不再写刚 NAK 过的 PCA9555。只有一颗不答应仍按 §10.10 盲跑、保持供电 - 起因:板 B 第 2 槽 PRSNT2# 卡低,刀片挪走后空槽 eFuse 开了约 4 h,原判据一条没响 - 需求 §10.10 加一行 + 表后第 3 条;rack 事件 20 命名并标红 - 宿主机 26 ctest 全绿(test_t_safety_rules +4 例) Co-Authored-By: Claude Opus 5.5 --- firmware/HANDOFF.md | 9 +- firmware/main/evlog.c | 2 + firmware/main/evlog.h | 5 + firmware/main/phonerail.h | 2 +- firmware/main/t_safety.c | 24 +- firmware/main/t_safety.h | 13 + firmware/test/test_evlog.c | 10 +- firmware/test/test_t_safety_rules.c | 86 + rack/backend/prrack/protocol.py | 5 +- rack/frontend/src/pages/Overview.vue | 4 +- 需求文档.md | 2220 +++++++++++++------------- 11 files changed, 1275 insertions(+), 1105 deletions(-) diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index 0509f1d..910150a 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -1,5 +1,12 @@ # 交接 -更新:2026-09-28 固件 **0.1.3-c5**(status 加 `host`);板 B 经 OTA 推上(热重启,第 1 路没断电) +更新:2026-09-28 固件 **0.1.4-c5**(整片刀片不应答 ⇒ 关路);板 B 经 OTA 推上 + +## 0.1.4:整片刀片不应答 ⇒ 关这一路(2026-09-28,用户拍板) +- `t_safety` 每路锁内:eFuse 开着、本路 INA226 **和** PCA9555 同时 NAK,连续 `T_SAFETY_BLADE_MUTE_N` = 10 圈(200 ms) + ⇒ abort 序列 + 关 eFuse,记新事件 **20 `LOG_EV_BLADE_MUTE`**。只关 eFuse、不去写那片 PCA9555(刚 NAK 过,再写只白等 IDF 超时)。 +- §10.10 前两行(只有一颗不答应 ⇒ 盲跑 / 不关输出)**不变**,`test_a_blind_rail_keeps_its_power` 原样;需求 §10.10 新增一行 + 表后第 3 条。 +- 起因:板 B 第 2 槽 `PRSNT2#` 卡低(见 0.1.3),刀片挪走后空槽 eFuse 开了约 4 h,原有判据一条都没响。 +- 宿主机 26 ctest 全绿(`test_t_safety_rules` 39 → 43 例,`test_evlog` 事件号 20)。rack 已同步事件 20(标红)。 ## 0.1.3:上游电脑在不在进遥测(2026-09-28) - `UP_VBUS_DET`(IO6,R721/R722 分压,R722 下拉)启动时配成输入;status 新增 `"host":true|false|null`(读不到 null)。 diff --git a/firmware/main/evlog.c b/firmware/main/evlog.c index 0ea6853..04244e8 100644 --- a/firmware/main/evlog.c +++ b/firmware/main/evlog.c @@ -217,6 +217,7 @@ log_ev_t evlog_from_safety(uint8_t safety_ev) case SAFETY_EV_PUSHED_UP: return LOG_EV_PUSHED_UP; case SAFETY_EV_MUX_LOST: return LOG_EV_MUX_LOST; case SAFETY_EV_SLOT_EMPTY: return LOG_EV_SLOT_EMPTY; + case SAFETY_EV_BLADE_MUTE: return LOG_EV_BLADE_MUTE; case SAFETY_EV_NONE: break; } return LOG_EV_NONE; @@ -574,6 +575,7 @@ const char *evlog_ev_str(log_ev_t ev) case LOG_EV_PG_TIMEOUT: return "上电等不到PG"; case LOG_EV_OTA: return "固件升级"; case LOG_EV_BOOT_CHECK: return "开机重按"; + case LOG_EV_BLADE_MUTE: return "刀片不应答关路"; case LOG_EV_COUNT: break; } return "?"; diff --git a/firmware/main/evlog.h b/firmware/main/evlog.h index d8e8800..598cb53 100644 --- a/firmware/main/evlog.h +++ b/firmware/main/evlog.h @@ -88,6 +88,11 @@ typedef enum { 频繁出现 = 这一台的电源键弹片和刀片铜盘接触不良(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; diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h index 51673dd..89cdc28 100644 --- a/firmware/main/phonerail.h +++ b/firmware/main/phonerail.h @@ -3,7 +3,7 @@ #define PHONERAIL_H /* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ -#define PHONERAIL_FW_VERSION "0.1.3-c5" +#define PHONERAIL_FW_VERSION "0.1.4-c5" /* 通道数。五路直供,见设计文档 §2。 */ #define PHONERAIL_RAIL_COUNT 5 diff --git a/firmware/main/t_safety.c b/firmware/main/t_safety.c index 699ee30..945b2c8 100644 --- a/firmware/main/t_safety.c +++ b/firmware/main/t_safety.c @@ -43,6 +43,8 @@ const char *t_safety_ev_str(safety_ev_t ev) return "mux失联全断"; case SAFETY_EV_SLOT_EMPTY: return "刀片不在位"; + case SAFETY_EV_BLADE_MUTE: + return "刀片不应答关路"; default: return "?"; } @@ -183,7 +185,27 @@ static hal_err_t cycle_rail(void *arg) } } - /* 判据就在这里跑:断 VBUS 要在本路通道选好、锁还持着的此刻(规矩 3)。 */ + /* 整片刀片不应答(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; } diff --git a/firmware/main/t_safety.h b/firmware/main/t_safety.h index 8cdb138..1153138 100644 --- a/firmware/main/t_safety.h +++ b/firmware/main/t_safety.h @@ -118,6 +118,16 @@ extern "C" { 在拿旧数据跑判据。判定成立之后**先脉冲一次 `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 把这条记录塞进环形缓冲,这里只负责**产生**它。 */ @@ -130,6 +140,8 @@ typedef enum { /* ⭐ 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 { @@ -153,6 +165,7 @@ 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 = 没有 */ diff --git a/firmware/test/test_evlog.c b/firmware/test/test_evlog.c index 003cf18..cc523f0 100644 --- a/firmware/test/test_evlog.c +++ b/firmware/test/test_evlog.c @@ -87,7 +87,8 @@ static void test_flash_format_is_frozen(void) TEST_ASSERT_EQUAL_INT(17, LOG_EV_PG_TIMEOUT); /* A15 */ 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_COUNT); + TEST_ASSERT_EQUAL_INT(20, LOG_EV_BLADE_MUTE); /* 0.1.4 刀片不应答 */ + TEST_ASSERT_EQUAL_INT(21, LOG_EV_COUNT); } /* §10.7 的「必记事件」有八类,A10 只产生其中两类。剩下的号先占住, @@ -129,6 +130,7 @@ static void test_the_safety_mapping_is_explicit(void) TEST_ASSERT_EQUAL_INT(LOG_EV_UNDER_VOLT, evlog_from_safety(SAFETY_EV_UNDER_VOLT)); TEST_ASSERT_EQUAL_INT(LOG_EV_REVERSE, evlog_from_safety(SAFETY_EV_REVERSE)); TEST_ASSERT_EQUAL_INT(LOG_EV_PUSHED_UP, evlog_from_safety(SAFETY_EV_PUSHED_UP)); + TEST_ASSERT_EQUAL_INT(LOG_EV_BLADE_MUTE, evlog_from_safety(SAFETY_EV_BLADE_MUTE)); TEST_ASSERT_EQUAL_INT(LOG_EV_NONE, evlog_from_safety(SAFETY_EV_NONE)); TEST_ASSERT_EQUAL_INT(LOG_EV_NONE, evlog_from_safety(200)); /* 认不出的不许瞎猜 */ @@ -161,6 +163,7 @@ static void test_the_safety_mapping_is_a_table_not_an_offset(void) TEST_ASSERT_EQUAL_INT(4, SAFETY_EV_PUSHED_UP); TEST_ASSERT_EQUAL_INT(5, SAFETY_EV_MUX_LOST); TEST_ASSERT_EQUAL_INT(6, SAFETY_EV_SLOT_EMPTY); + TEST_ASSERT_EQUAL_INT(7, SAFETY_EV_BLADE_MUTE); /* 逐对:safety 号 → flash 号,两边都是裸数字。 */ TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(0)); /* NONE */ @@ -170,6 +173,7 @@ static void test_the_safety_mapping_is_a_table_not_an_offset(void) TEST_ASSERT_EQUAL_INT(6, (int)evlog_from_safety(4)); /* PUSHED_UP */ TEST_ASSERT_EQUAL_INT(14, (int)evlog_from_safety(5)); /* MUX_LOST */ TEST_ASSERT_EQUAL_INT(15, (int)evlog_from_safety(6)); /* SLOT_EMPTY */ + TEST_ASSERT_EQUAL_INT(20, (int)evlog_from_safety(7)); /* BLADE_MUTE(0.1.4) */ /* 而且这张表**在数学上不可能**是一个偏移:前四对差 2,后两对差 9。 写死这一条,是为了让「改成偏移」这件事在用例名上就说得清楚, @@ -178,8 +182,8 @@ static void test_the_safety_mapping_is_a_table_not_an_offset(void) TEST_ASSERT_EQUAL_INT(9, (int)evlog_from_safety(5) - (int)SAFETY_EV_MUX_LOST); /* 越过表尾的号一律 NONE。偏移写法在这里会算出一个**越界但合法**的 - `log_ev_t`(7 → 记成 eFuse 故障,200 → 记成 202 这种根本没有的号)。 */ - TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(7)); + `log_ev_t`(8 → 记成 PG 变化,200 → 记成 202 这种根本没有的号)。 */ + TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(8)); TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(200)); TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(255)); } diff --git a/firmware/test/test_t_safety_rules.c b/firmware/test/test_t_safety_rules.c index 6768eda..cfb49b9 100644 --- a/firmware/test/test_t_safety_rules.c +++ b/firmware/test/test_t_safety_rules.c @@ -133,6 +133,7 @@ static void catch_event(void *ctx, const safety_event_t *ev) case SAFETY_EV_OVER_VOLT: case SAFETY_EV_UNDER_VOLT: case SAFETY_EV_SLOT_EMPTY: + case SAFETY_EV_BLADE_MUTE: if (rail_efuse_is_on(ev->rail)) { g_ev_before_action = true; /* 记这条时电早该断了 */ } @@ -336,6 +337,11 @@ static void test_thresholds_match_the_documents(void) 已经落盘的旧记录改变含义(`t_safety.h` 那句「flash 格式」)。 */ TEST_ASSERT_EQUAL_INT(SAFETY_EV_PUSHED_UP + 1, SAFETY_EV_MUX_LOST); TEST_ASSERT_EQUAL_INT(SAFETY_EV_MUX_LOST + 1, SAFETY_EV_SLOT_EMPTY); + TEST_ASSERT_EQUAL_INT(SAFETY_EV_SLOT_EMPTY + 1, SAFETY_EV_BLADE_MUTE); + + /* 0.1.4 刀片不应答:连续 10 圈 = 200 ms。必须 > 1(一圈毛刺不许关一台手机)。 */ + TEST_ASSERT_EQUAL_INT(10, T_SAFETY_BLADE_MUTE_N); + TEST_ASSERT_EQUAL_UINT32(200u, T_SAFETY_BLADE_MUTE_N * task_desc(TASK_SAFETY)->period_ms); } /* ---- 验收主项一:注入过压 → 20 ms 内关路并记事件(SR-5) ----------------- */ @@ -547,6 +553,82 @@ static void test_a_blind_rail_keeps_its_power(void) TEST_ASSERT_EQUAL_INT(0, en_level(4)); } +/* ---- 0.1.4:整片刀片不应答 ⇒ 关路(§10.10 新增那一行) ------------------ */ + +static void mute_blade(int rail, bool mute) +{ + sim_i2c_set_present_ch(ch_of(rail), rail_cfg(rail)->ina_addr, !mute); + sim_i2c_set_present_ch(ch_of(rail), PCA9555_ADDR, !mute); +} + +/* 2026-09-28 板 B 第 2 槽的现场:`PRSNT#` 卡低(假在位)、刀片已经拔走、eFuse 还开着。 + 两颗都不答应满 10 圈 ⇒ 关这一路、记一条 BLADE_MUTE;别的路一根线都不动。 */ +static void test_a_mute_blade_is_cut_after_ten_cycles(void) +{ + power_up(2); + power_up(3); + const uint32_t other = en_writes(3); + const uint32_t out0 = g_w[1].n; + mute_blade(2, true); /* PRSNT2# 仍是低:slot_present(2) 照旧为真 */ + + beats(T_SAFETY_BLADE_MUTE_N - 1); + TEST_ASSERT_EQUAL_INT(1, en_level(2)); /* 9 圈:还没到 */ + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(2)); /* 第 10 圈:关 */ + TEST_ASSERT_FALSE(rail_efuse_is_on(2)); + TEST_ASSERT_EQUAL_INT(1, g_n_ev); + TEST_ASSERT_EQUAL_INT(SAFETY_EV_BLADE_MUTE, g_ev[0].type); + TEST_ASSERT_EQUAL_INT(2, g_ev[0].rail); + /* 没有去写那片刚 NAK 过的 PCA9555(断 VBUS 那一写只会白等超时) */ + TEST_ASSERT_EQUAL_UINT32(out0, g_w[1].n); + /* 第 3 路一根线都没动 */ + TEST_ASSERT_EQUAL_INT(1, en_level(3)); + TEST_ASSERT_EQUAL_UINT32(other, en_writes(3)); + + beats(50); /* 关掉之后不再刷事件 */ + TEST_ASSERT_EQUAL_INT(1, g_n_ev); +} + +/* 只差一圈就恢复了:从头数,不关。 */ +static void test_a_blade_that_answers_again_restarts_the_count(void) +{ + power_up(2); + mute_blade(2, true); + beats(T_SAFETY_BLADE_MUTE_N - 1); + mute_blade(2, false); + beat(); + mute_blade(2, true); + beats(T_SAFETY_BLADE_MUTE_N - 1); + TEST_ASSERT_EQUAL_INT(1, en_level(2)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); + beat(); + TEST_ASSERT_EQUAL_INT(0, en_level(2)); +} + +/* §10.10 原有的两行不变:只有一颗不答应 ⇒ 盲跑、保持供电。 */ +static void test_one_mute_chip_on_the_blade_still_keeps_power(void) +{ + power_up(2); + sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, false); /* 只有 PCA9555 不答应 */ + beats(50); + TEST_ASSERT_EQUAL_INT(1, en_level(2)); + sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, true); + sim_i2c_set_present_ch(ch_of(2), rail_cfg(2)->ina_addr, false); /* 只有 INA226 不答应 */ + beats(50); + TEST_ASSERT_EQUAL_INT(1, en_level(2)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + +/* 关着的路不数:空槽假在位、没人开它,不该每 200 ms 刷一条事件。 */ +static void test_a_mute_blade_that_is_off_is_left_alone(void) +{ + mute_blade(2, true); + beats(50); + TEST_ASSERT_EQUAL_INT(0, en_level(2)); + TEST_ASSERT_EQUAL_INT(0, g_n_ev); +} + /* ---- ⭐ 关路这件事本身:R2 的顺序与「写给了哪一片」 ---------------------- */ /* ⭐⭐ 本包最要紧的一条。关路 = **先断 VBUS(锁内、本路通道上)再关 eFuse(锁外)**。 @@ -1203,6 +1285,10 @@ int main(void) RUN_TEST(test_a_rail_that_is_off_is_never_judged); RUN_TEST(test_the_soft_start_window_is_not_judged); RUN_TEST(test_a_blind_rail_keeps_its_power); + RUN_TEST(test_a_mute_blade_is_cut_after_ten_cycles); + RUN_TEST(test_a_blade_that_answers_again_restarts_the_count); + RUN_TEST(test_one_mute_chip_on_the_blade_still_keeps_power); + RUN_TEST(test_a_mute_blade_that_is_off_is_left_alone); RUN_TEST(test_the_cut_drops_vbus_inside_the_lock_on_this_rails_channel); RUN_TEST(test_only_the_offending_rail_is_touched); diff --git a/rack/backend/prrack/protocol.py b/rack/backend/prrack/protocol.py index d586462..93a947e 100644 --- a/rack/backend/prrack/protocol.py +++ b/rack/backend/prrack/protocol.py @@ -36,7 +36,8 @@ EVENT_TYPES: Dict[int, Tuple[str, str]] = { 16: ("PD_LIMIT", "PD 限功率"), 17: ("PG_TIMEOUT", "上电等不到 PG"), # A15:arg bit0 = FLT# 在报,bit1 = 按电压判 18: ("OTA", "固件升级"), # C2:arg 1 开始 / 2 就绪 / 3 失败(mV 列=原因) / 4 自证健康 / 5 回滚 / 6 为换固件重启 - 19: ("BOOT_CHECK", "开机重按"), # 0.1.2:arg bit0~3 = 按了几次,bit7 = 最终没开起来(电源键弹片接触不良) + 19: ("BOOT_CHECK", "开机重按"), + 20: ("BLADE_MUTE", "刀片不应答关路"), # 0.1.4:eFuse 开着、本路 INA226 + PCA9555 连续 200 ms 都不应答 # 0.1.2:arg bit0~3 = 按了几次,bit7 = 最终没开起来(电源键弹片接触不良) } # LOG_EV_OTA 的 arg(firmware/main/ota.h 的 ota_ev_t)与失败原因(ota_err_t,失败那条的 mV 列)。⚠️ 冻结。 @@ -46,7 +47,7 @@ OTA_ERR_NAMES = {1: "下载失败", 2: "大小不对", 3: "哈希不对", 4: " 6: "切启动分区失败", 7: "没有备用槽 / 新固件未自证"} # 严重程度:界面上标红的那些(会断电 / 断 VBUS / 硬件故障)。 -EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14, 17} # OTA 的失败 / 回滚、开机重按的「没开起来」在 api.events 里按 arg 单独标红 +EVENT_SEVERE = {3, 4, 5, 6, 7, 9, 11, 14, 17, 20} # OTA 的失败 / 回滚、开机重按的「没开起来」在 api.events 里按 arg 单独标红 BOOT_CHECK_FAILED = 0x80 # LOG_EV_BOOT_CHECK 的 arg bit7 # cmd_id_t(firmware/main/cmd.h),CMD_REJECT 事件的 arg 就是它。 diff --git a/rack/frontend/src/pages/Overview.vue b/rack/frontend/src/pages/Overview.vue index 0048c4e..e73350a 100644 --- a/rack/frontend/src/pages/Overview.vue +++ b/rack/frontend/src/pages/Overview.vue @@ -90,8 +90,8 @@ import { mA, V, W, C, ago, fmtTime, railsMw, railHealth, LEVEL_COLOR, RECOVERY_Z // 与 backend/prrack/protocol.py 的 EVENT_TYPES / EVENT_SEVERE 同一张表(数字来自固件 evlog.h) const EV = ['', '板子上电', '事件丢失', '过压关路', '欠压关路', '反灌断 VBUS', '顶高断 VBUS', 'eFuse 故障', 'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率', - '上电等不到 PG', '固件升级', '开机重按'] -const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14, 17]) + '上电等不到 PG', '固件升级', '开机重按', '刀片不应答关路'] +const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14, 17, 20]) // 开机重按(19)只有 arg bit7「最终没开起来」才算严重 const isSevere = ev => SEVERE.has(ev[3]) || (ev[3] === 19 && (ev[4] & 0x80) !== 0) const evName = t => EV[t] || `事件 ${t}` diff --git a/需求文档.md b/需求文档.md index 27612c6..da768f7 100644 --- a/需求文档.md +++ b/需求文档.md @@ -1,1095 +1,1125 @@ -# 五路手机直供电源板 —— 项目需求文档 - -| 项 | 值 | -|---|---| -| 文档版本 | **v1.2**(2026-09-06)—— 上位机链路定稿为**方案 C(WiFi 常态 + USB CDC 兜底)**,回填 §10.8,新增 D5a / SW-12 / IF-19 与两条验收
v1.1 —— 新增 **§10 软件架构设计**,原 §10~§14 顺延为 §11~§15 | -| 项目代号 | PhoneRail | -| 对应设计文档 | `五路直供电源板设计.md` v1.6 及其后续增补(§22 起的两板架构) | -| 状态 | 需求已冻结;硬件处于 PCB 布线阶段(见 `PROGRESS.md`) | - -> **本文档与设计文档的分工** -> 本文只回答 **「要做成什么样、凭什么算做到了」**; -> **「怎么做到」** 一律在 `五路直供电源板设计.md` 里,本文只给条目号交叉引用。 -> 两者冲突时:**电气数值以设计文档为准**(它是逐项算过并对过 datasheet 的), -> **需求的取舍与验收标准以本文为准**。 -> 设计文档 §0.1 的 13 条决策已定稿,本文把它们表述成需求,不重新讨论。 -> **唯一的例外是 §10 软件架构设计** —— 那是「怎么做」,但固件在设计文档里没有落脚点, -> 暂放本文,理由写在该节开头。 - ---- - -## 1. 背景与目标 - -### 1.1 要解决的问题 - -手机农场里成排运行的小米8(dipper)主板,用原装电池供电有三个绕不开的问题: - -1. **电池会老化、会鼓包**,几十台规模下变成持续的维护负担和安全隐患; -2. **手机卡死时没法远程恢复** —— 人必须跑到机架前拔电池 / 长按电源键; -3. **充电管理系统一直在工作**,长期 100% 电量反而触发系统的异常逻辑。 - -### 1.2 项目目标 - -做一块**电池位直供**的电源板:拆掉电芯与保护板,从主板电池座直接灌一个受控的、 -稳定在正常锂电范围内的电压;同时把「断电重启、按键模拟、进 Fastboot / 9008、 -USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU 远程执行。 - -**一句话验收目标**:五台小米8 主板挂在这套板子上连续跑 24 小时无异常重启, -任何一台卡死时都能在**不到机架前**的情况下恢复到正常运行状态。 - -### 1.3 目标用户与使用场景 - -| 用户 | 场景 | -|---|---| -| 农场运维 | 日常挂机;某台无响应时下发一次重启;查某台的电流曲线定位接触不良 | -| 刷机 / 开发 | 远程进 Fastboot 或 9008(EDL) 刷包;有线 adb 抓日志 | -| 本项目自身 | 首板调试、长稳测试、失效注入验证 | - ---- - -## 2. 范围 - -### 2.1 交付范围(In Scope) - -| # | 交付物 | -|---|---| -| D1 | **下板(电源板)**:12V 输入 → 五路 4 A 直供 + eFuse + 遥测 + ESP32-S3 控制 | -| D2 | **上板(载板)**:五块主板的机械承载 + 板载 USB Hub + VBUS 开关 + 侧键顶针 + EDL 口 | -| D3 | 两板对插的板对板接口(两条 2×20 排针,共 80 针) | -| D4 | 手机侧线束与离板电容组(电池排线改造、尾插 USB 线、EDL 引线) | -| D5 | ESP32-S3 固件(遥测、两级重启、按键 / EDL 模拟、VBUS 通断、风扇闭环、告警) | -| D5a | **双链路命令接口**(2026-09-06 用户拍板):**WiFi 为常态、USB CDC 为兜底**,两条链路共用同一套命令语义。本项目交付板侧协议与实现,见 §10.8;上位机见 D7 | -| D7 | **机架管理前后端**(2026-09-27 用户拍板,取代原 N7):**架构 A 纯中心化** —— home 服务器(10.5.5.13)上跑 mosquitto + Python(FastAPI)后端 + 时序库 + Vue3 前端,走统一门户;板子经 WiFi MQTT 直连。首版功能:实时总览、手动操作、自动崩溃恢复(**服务器侧 adb over WiFi 探活 + 电流曲线确认**,先软后硬)、历史曲线与事件。**不做机架旁 agent,USB CDC 只用于上架配网与调试**(代价:WiFi 故障时没有远程救援通道,见 §10.8)。开发顺序:先补固件 A12/A13/A14,再写前后端。**2026-09-27 已上线**:代码 `rack/`(说明见 `rack/README.md`),服务 `phonerail-rack`(127.0.0.1:7764),门户 key `rack` | -| D6 | 完整原理图(11 页)+ 两块 PCB + BOM + 首板调试与验收记录 | - -### 2.2 明确不做(Out of Scope) - -| # | 不做 | 理由 | -|---|---|---| -| N1 | 电池充放电管理、模拟真实电池阻抗 | 直供不需要,电芯已移除 | -| N2 | 支持 1S 以外的机型 / 非小米8 主板 | 全部机械与引脚定义都是 dipper 专用(§13) | -| N3 | 自制 USB-C 尾插转接板 | 决策 10:装原厂尾插小板,口温 NTC / CC / ESD / 天线馈点全在上面 | -| N4 | USB PD / BC1.2 协商,接快充充电器 | 决策 11:上游只接电脑 / Hub,VBUS 恒 5 V | -| N5 | 把过流保护做进 MCU 实时环路 | 那是 eFuse 的职责(SR-3) | -| N6 | 五路 Buck 错相同步 | §8.4 已否决:器件离散度远大于想制造的相位差 | -| ~~N7~~ | ~~上位机管理软件 / Web 界面~~ | **2026-09-27 撤销**,改为交付范围 D7 | - ---- - -## 3. 系统组成(需求视角) - -```diagram=system -12V/10A 适配器 - │ - ┌────┴─────────────── 下板(电源 + 大脑)────────────────────┐ - │ 输入保护 → 五路独立 Buck → eFuse → 分流检测 → 输出 │ - │ ESP32-S3 + DAC ×2 + INA226 ×6 + 74HC595 ×3 + PCA9555 │ - │ 侧键驱动管 ×15 · 风扇驱动 · 辅助 3V3 / 5V │ - └────┬──────────── 2×20 排针 ×2(无高速信号)─────────────────┘ - ┌────┴─────────────── 上板(载板)────────────────────────────┐ - │ 5 × 小米8 主板(NPTH 定位孔 + 尼龙立柱) │ - │ USB2517I 7 口 Hub + 上游 USB-C + VBUS 负载开关 ×5 │ - │ 侧键顶针 ×15 · EDL 模拟开关 ×5 · P+/P− 焊盘 + 电容组 ×5 │ - └──────────────────────────────────────────────────────────────┘ -``` - -两板之间保留 **13 mm 缝**,既是装配空间也是散热风道。 - ---- - -## 4. 功能需求(FR) - -| ID | 需求 | 优先级 | 设计出处 | -|---|---|---|---| -| **FR-1** | 同时为 **5 台**小米8 主板供电,各路**电气独立**(独立回流地,不在手机端共地) | 必须 | §1 / §6.4 | -| **FR-2** | 每路输出电压可**独立微调**,调节在正常锂电范围内进行,步进优于 1 mV | 必须 | §4 | -| **FR-3** | 每路可**独立硬断电并主动放电**,实现真正意义上的「拔电池」 | 必须 | §7 | -| **FR-4** | 每台手机可模拟 **3 个侧键**(PWR / VOL+ / VOL−),支持组合键 | 必须 | §13.3 | -| **FR-5** | 每台手机可远程进入 **EDL(9008)** 模式 | 必须 | §22.4 | -| **FR-6** | 每台手机的 **USB VBUS 可独立通断**,默认断开 | 必须 | §15.1 | -| **FR-7** | 对外只用**一根 USB 线**,五路下行由板载 Hub 分 | 必须 | §15.1 | -| **FR-8** | 每路可实时读取**输出电压与电流**;整板可读取**输入电压与电流** | 必须 | §8.2 | -| **FR-9** | 上报每路 eFuse 故障、电源好(PG)等状态 | 必须 | §8.2 | -| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」 | 必须 | §22.6 | -| **FR-11** | 记录过流 / 故障事件(哪一路、何时、多大电流) | 高 | §8.3 | -| **FR-12** | 模拟电量曲线,避免系统因「电量永远 100%」触发异常逻辑 | 中(可选) | §8.3 | -| **FR-13** | 板级温度监控,超阈值先降流再关路 | 高 | §8.3 | -| **FR-14** | 整板功率预算,超过适配器能力时按优先级降载 | 中 | §8.3 | -| **FR-15** | 调试口不依赖外部转串口芯片,插上 USB-C 即可下载与看日志 | 必须 | §8.1 | - -### 4.1 崩溃恢复流程(FR-3 / FR-4 的组合,本项目的核心价值) - -固件必须实现**两级重启**,且**先软后硬**: - -| 级 | 动作 | 触发条件 | 要求 | -|---|---|---|---| -| 一级(软) | 长按电源键 ≈10 s | `adb` 探活连续失败 N 次,或电流曲线卡死 | 优先使用,对闪存和文件系统友好 | -| 二级(硬) | `EFUSE_EN` ↓ → 放电 → 等 3~5 s → `EFUSE_EN` ↑ | 一级无效 | 输出必须真的掉到 <0.5 V,不能靠自然放电 | - -> **为什么二级必须有主动放电**:手机侧 2200 µF 配 ≈50 µA 关机待机电流,自然放电到安全 -> 电压要 **154 秒**,「断电 10 秒」根本不触发重启。见 §7.1。 - ---- - -## 5. 电气与性能需求(PR) - -| ID | 参数 | 要求 | 出处 | -|---|---|---|---| -| **PR-1** | 输入电压 | 12 V ±10%,成品适配器 | §1 | -| **PR-2** | 输入电流 | ≤ 8 A(含风扇 ≈8.3 A),建议配 12 V / 10 A(120 W) | §1 / §22.6 | -| **PR-3** | 输出路数 | 5,彼此独立不共地 | §1 | -| **PR-4** | 输出电压范围 | **3.52 ~ 4.01 V** 连续可调 | §4.3 | -| **PR-5** | **硬件电压上限** | 标称 **4.01 V**,最坏容差 **4.12 V**,**软件无法越过** | §4.4 | -| **PR-6** | 电压设定分辨率 | 12 位 DAC 微调,步进 ≈0.12 mV | §4.5 | -| **PR-7** | 输出电流 | 4 A / 路(峰值);手机常态 0.3 ~ 1 A | §1 | -| **PR-8** | 输出纹波 | < 20 mVpp(板端测试点) | §1 | -| **PR-9** | 开关频率 | 480 kHz,五路统一,不错相 | §1 / §8.4 | -| **PR-10** | 整板功率 | 峰值 80 W(不会发生);常态 10 ~ 25 W | §1 | -| **PR-11** | 电流测量分辨率 | ≥ 0.5 mA(5 mΩ 分流 + Kelvin 四线) | §8.2 | -| **PR-12** | 负载阶跃响应 | 0→4 A 阶跃(带 20 cm 线 + 电容组)压降 < 300 mV,**无 6 kHz 振铃** | §6.2 | -| **PR-13** | 满载温升 | 4 A 连续 30 min,电感 / IC 温升 < 40 K | §11 | -| **PR-14** | 断电放电时间 | `EFUSE_EN` ↓ 后 **200 ms 内**输出 < 0.5 V | §7.2 | -| **PR-15** | 上电斜坡 | 缓启动 0.36 V/ms,涌流 ≤ 0.8 A;`EN`↑ 到稳定 ≈17 ms | §5 | - -### 5.1 输出上限为什么定在 4.01 V(这条需求的来源) - -去掉原厂保护板后,输出上限**由 1S 锂电端子的绝对上限 4.45 V 反推**,而不是由保护板决定。 -4.01 V 这个数字不是「够用就行」,是为了**给 eFuse 的过压跳闸窗口让开 86 mV** —— -若按早期的 4.22 V,第一层的最坏输出(4.34 V)会和 eFuse 的最坏跳闸下沿(4.206 V)重叠, -坏角落下 DAC 失效会**误跳保护**。详见 §5 的容差窗口分析。 - -**因此 PR-4 / PR-5 是安全需求的一部分,不得为了「多给一点电压」而放宽。** - ---- - -## 6. 安全需求(SR)—— 本项目最高优先级 - -> **必须记录的失效模式**:直供电最致命的失效**不是固件写错电压值**,而是 -> **反馈分压的下电阻开路 → FB = 0 V → Buck 满占空比 → 输出直接跳到 12 V**。 -> **软件对此完全无能为力。** 全部安全需求都要围绕这一条来读。 - -| ID | 需求 | 出处 | -|---|---|---| -| **SR-1** | **三层保护彼此独立**:任何一层单独失效都不得导致手机损坏 | §5 | -| **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 或低于设定 >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 | -| **SR-9** | **12 V 母线泄放**:母线 >13.2 V 时导通泄放,防 USB 反灌或突卸能量抬高母线 | §15.2 | -| **SR-10** | **USB 反灌三条对策全做,一条都不能省**:① 软件关充电 ② VBUS 负载开关 ③ 母线泄放 | 决策 12 / §13.5 | -| **SR-11** | 上游 USB 不做 PD 协商(CC 双 5.1 kΩ 下拉,纯 UFP),杜绝 VBUS 被抬到 9 / 12 V | §15.1 | -| **SR-12** | 五台手机的地**不得**在载板上并成一根:定位孔做 **NPTH**,立柱用**尼龙** | §25.2 | -| **SR-13** | 固件**不得**提供任何能让输出超过 4.2 V 的接口 | §8.3 | -| **SR-14** | 任一路输出**不得**给 ESP32 供电(用独立的 12 V→3.3 V 小 Buck) | §8.3 | -| **SR-15** | 电源**必须**接原厂排线的 `P+`/`P−`(保护板输出侧),**不是** `B+`/`B−` | 决策 6 / §13.5 | - -> ⚠️ **SR-15 是最容易在装配环节做错、且常温下完全测不出来的一条。** -> 焊在 `B+`/`B−` 时一切表现正常,只有过压时才暴露 —— 那时保护板仍串在通路里。 -> 判别与验证步骤见 §13.5 / §13.7,已列为打样前阻塞项(V-1)。 - -> ⚠️ **`J600` 的 `GND3`/`GND4` 固定脚实际接的是 VBATT,`J1401` 的 `GND2` 是 USB_VBUS。** -> 按名字当地用会直接短路电池。 - ---- - -## 7. 接口需求(IF) - -> **编号只增不改**:新增接口一律取下一个空号,位置按逻辑归类 —— -> 所以表内序号不一定连续(如 IF-19 排在 IF-14 后面)。ID 是稳定的引用标识, -> 不是目录序号;改动已有编号会让所有交叉引用与验收记录失效。 - -### 7.1 手机侧接口(每台一套) - -| ID | 接口 | 要求 | 出处 | -|---|---|---|---| -| **IF-1** | 供电 | 原装 BM3E 电池排线的 `P+`/`P−` 焊点;保留 BTB 公座 / NTC / ID,**旁路保护板**;电芯移除 | §13.2 / §13.5 | -| **IF-2** | 侧键 | J601(VOL+) / J602(VOL−) / J603(PWR) / J604(公共地),**对地短接**方式,绝不从外部灌电压 | §13.3 | -| **IF-3** | EDL | 短接主板上的 TP1204 ↔ TP1205(1.8 V / 1 mA),用**模拟开关**,不用普通 N-MOS | §22.4 | -| **IF-4** | USB | 装**原厂尾插小板**,本板只接 VBUS / D± / GND 四根 | 决策 10 / §15.1 | -| **IF-5** | 离板电容组 | 每路 2200 µF **普通铝电解**(靠 ESR 阻尼)+ 100 µF ×2 + 22 µF ×2 + 100 nF,**必须装在排线端,不在电源板上** | §6.3 | -| **IF-6** | 线缆 | ≤ 20 cm、18 AWG 或更粗、正负双绞或紧贴平行;板端用 Micro-Fit 3.0 / XT30,**不用杜邦线** | §6.4 | - -> ⚠️ **IF-5 的 2200 µF 千万不能买成聚合物电容。** 整个阻尼设计靠的就是普通铝电解那 -> 几十 mΩ 的 ESR;换成 12 mΩ 的聚合物,Q 从 0.19 跳回 4.5,线缆 LC 会在 6.2 kHz 上振铃。 - -> ⚠️ **IF-5 的大电容必须在排线端。** 放在电源板上等于隔着 300 nH 的线缆电感,基本无效。 - -### 7.2 板对板接口 - -| ID | 需求 | 出处 | -|---|---|---| -| **IF-7** | 两条 2×20 排针(共 80 针),分列板子两端 | §22.2 / §25.10 | -| **IF-8** | **一根高速差分都不穿连接器** —— Hub、上游 USB-C、VBUS 开关全部上移到载板 | §22.1 | -| **IF-9** | 排针只走电源与低速控制;每路 `P+`/`P−` 独立分配针位,保证独立回流(FR-1) | §22.2 | -| **IF-10** | 两板间净空 13 mm;载板底面元件与下板高元件的高度预算必须逐点核过 | §26.2 | - -### 7.3 对外接口 - -| ID | 接口 | 要求 | -|---|---|---| -| **IF-11** | 电源输入 | DC 12 V 插座(XT30 级),配 10 A 适配器 | -| **IF-12** | 上游 USB | 载板 USB-C(UFP),一根线接电脑 | -| **IF-13** | 调试 USB | 下板 USB-C,ESP32-S3 原生 USB-Serial-JTAG;**VBUS 故意不接**(物理上杜绝反灌 3V3)。**同一个口兼作 SW-12 的兜底命令链路**(原生 USB-Serial-JTAG 本身就是 CDC-ACM,不影响 esptool 下载),分帧要求见 §10.8 | -| **IF-14** | 风扇 | 5 × KF2510-3A(+12 V / 回流 / TACH) | -| **IF-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时先用 IF-13 的 USB 链路配网 | - -> ⚠️ **IF-12 与 IF-13 是两个长得一样的 USB-C,插错的现象是「adb 整排不见」**, -> 很难联想到是插错口。**丝印必须写清楚**,建议用不同颜色座子或在外壳上做标记。 - -### 7.4 内部数字接口 - -| ID | 需求 | -|---|---| -| **IF-15** | I²C 单条总线挂 10 个器件:INA226 ×6(0x40/41/44/45/42/43)、DAC63204 ×2(0x4A / 0x4B)、PCA9555(0x20)、LM75BD(0x48),地址互不冲突 | -| **IF-16** | ⚠️ **INA226 的通道号 ≠ 地址低位**(为布线让路重排过),固件必须**查表**,不能算 | -| **IF-17** | 74HC595 ×3 级联成 **24 位**移位链:15 路侧键 + `HUB_RST` + 5 路 `EDL_EN`;**先移入的字节落在 U33 上** | -| **IF-18** | `EFUSE_EN` ×5 与 `VBUS_EN` ×5 **必须 GPIO 直连**,不得经移位寄存器 —— 安全关键,故障瞬间要能立刻关断 | - ---- - -## 8. 机械与热需求(MR / TR) - -| ID | 需求 | 出处 | -|---|---|---| -| **MR-1** | 载板 220 × 135 mm,四角 R5;五块主板 **3+2 双排面对面**,第二排居中 | §25.1 | -| **MR-2** | 下板 160 × 125 mm,装配时相对载板原点偏移 (30, 5) | §25.2 | -| **MR-3** | 每台手机 7 个定位孔,全部 **NPTH Φ2.5 + 尼龙立柱**(见 SR-12) | §25.2 | -| **MR-4** | 侧键用顶针接触,顶针焊盘必须避免把相邻触点短路 | §25.3 | -| **MR-5** | 排线穿槽:电池排线 8×38 → 槽 10×3.0;尾插排线 18×55 → 槽 20×3.0(在 J1401 正下方) | §25.16 | -| **TR-1** | 总热负荷 ≈25 W(5×SDM845 满载 10~15 W + 下板功率级 ≈9 W) | §22.6 | -| **TR-2** | 强制风冷 **4 进 1 出 = 机箱正压**(灰尘往外走);两板间 13 mm 缝作天然风道 | §22.6 | -| **TR-3** | 所需风量仅 ≈2.9 CFM,**风量不是瓶颈,风道与正压才是设计要点** | §22.6 | -| **TR-4** | 风扇 5 × 12 V ≈0.70 A;低边 N-MOS + PWM,漏极**必须并续流二极管** | §22.6 | -| **TR-5** | 风扇闭环的测温点**放载板**(测的是手机),不能用下板功率级的两路 NTC 做反馈 | §22.6 | -| **TR-6** | TACH 必须分压后再进 GPIO;⚠️ 低边调速下 **PWM 关断期间读不到转速**,固件需在导通窗口采样 | §22.6 | - ---- - -## 9. 固件需求(SW) - -> 本节只列「固件要做到什么」。**怎么组织成一份固件见 §10 软件架构设计。** - -### 9.1 必做 - -| ID | 需求 | -|---|---| -| **SW-1** | 崩溃检测 + 两级自动重启(§4.1)—— **整块板子最核心的价值** | -| **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 唤醒 / 长按 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 | -| **SW-9** | 风扇 PWM 闭环(以载板 LM75BD 为反馈)+ TACH 停转告警 | -| **SW-10** | 整板功率预算:读输入侧 INA226,超适配器能力时按优先级降载 | -| **SW-11** | 上电默认全关:五路输出关、VBUS 断、按键不按、EDL 不触发 | -| **SW-12** | **双链路命令接口**:WiFi 为常态、USB CDC 为兜底,两条同时在线、共用同一套命令语义与同一个命令处理器;命令带全局序号做幂等判重。链路失联**不触发任何动作**。架构见 §10.8 | - -### 9.2 明确禁止 - -| ID | 禁止事项 | 理由 | -|---|---|---| -| **SW-N1** | 让 ESP32 参与过流保护的实时环路 | 那是 eFuse 的活,MCU 不够快也不够可靠 | -| **SW-N2** | 提供任何能让输出超过 4.2 V 的接口 | SR-13 | -| **SW-N3** | 烧写 `DIS_USB_JTAG` eFuse | 烧了之后引脚 JTAG 和 USB 两边都进不去,板上没有 JTAG 排针 | -| **SW-N4** | 把 INA226 通道号当地址算 | IF-16 | - ---- - -## 10. 软件架构设计 - -> **这一节为什么在需求文档里**:本文原则上只讲「做成什么样」,但**固件在设计文档里没有 -> 落脚点** —— 设计文档 §8.3 只有一张按价值排序的功能清单,没有结构。在补出一份独立的 -> 软件设计文档之前,架构先放这里,与 §9 的固件需求贴在一起看。 -> 硬件的「怎么做」仍然一律在设计文档。 - -### 10.1 分层与职责边界 - -```diagram=layers -┌─ 农场管理系统(既有,本项目不交付 —— N7)────────────────────────┐ -│ adb 探活 / 业务调度 / 崩溃判定 │ -└───────┬────────────────────────────────┬──────────────────────────┘ - │ adb over WiFi(业务面) │ 控制面(10.8,⚠️ 链路待定) - ▼ ▼ - 5 台小米8 主板 ┌─────────────────────┐ - ▲ │ PhoneRail 控制器 │ - │ 供电 / 侧键 / EDL / VBUS │ ESP32-S3 固件 │ - └──────────────────────────┤ 遥测 · 执行 · 记录 │ - └──────────┬──────────┘ - │ 只能建议,不能否决 - ┌───────────────▼───────────────┐ - │ 硬实时层:eFuse OVP / 限流 │ - │ 放电 MOS 反相联锁 / 上下拉 │ - │ —— 不含任何软件(SR-3、SR-7) │ - └───────────────────────────────┘ -``` - -**一条边界决定了下面所有取舍**:固件是**遥测 + 执行器 + 记录仪**,不是保护装置 -(SR-5、SW-N1)。凡是「必须及时」的事都已经在硬件里了;固件里没有任何一条路径 -是「不跑到就会烧手机」的。因此: - -- 任务优先级不需要为保护让路,只需要保证遥测不被执行器序列饿死; -- 看门狗复位是**可接受的失效**(复位即全关,SR-6),不需要为了「保持输出」而弱化看门狗; - (计划内重启不断电是另一回事:那是固件自己选的时机、自己留的标记,§10.9「重启不断电」。) -- 固件可以随时被重启、被降级运行,硬件安全性不变。 - -### 10.2 架构的第一个判断:失联 ≠ 断电 - -| 状态 | 输出该怎么办 | 为什么 | -|---|---|---| -| ESP32 复位 / 上电中 | **全关**(硬件下拉保证,SR-6) | 状态未知,且此刻手机本来就没在跑 | -| ESP32 **计划内**重启(OTA / 回滚) | **保持**(EN 锁住,新固件接管) | 重启前的状态是已知的;为换一次固件让五台手机全掉电不值得 | -| 与上位机失联 | **保持供电不动** | 五台手机正在跑业务;因为控制链路断了就把它们全断电,是把一次网络故障放大成一次全场停机 | -| 本地判据触发(SR-5 / SW-8 / 过温) | **关对应那一路** | 已知的危险,且范围可控 | - -⚠️ 这三行看起来像常识,但它们互相矛盾过一次:**「安全默认值 = 关」只对未知状态成立**。 -失联是已知状态,此时最安全的动作是「什么都不做」。固件里不要写「心跳超时就关输出」。 - -**推论**:失联期间固件必须能独立运行全部本地职责 —— 巡检、风扇闭环、事件记录、 -按队列执行未完成的序列。上位机回来之后靠事件日志补齐时间线(10.7)。 - -> **A10 已落地**(2026-09-07,`firmware/main/t_safety.{h,c}`,判据全在 -> `t_safety_step()` 里、放锁之后、直接写引脚)。本表第三行「本地判据触发 → 关对应 -> 那一路」的两条判据是 SR-5 与 SW-8,实现时定下来的七条: -> -> 1. **「持续 20 ms」数的是圈,不是墙钟毫秒。** 巡检的采样时刻带亚毫秒残差 -> (一圈自己占 1.56 ms 总线,而「睡到下一拍」按整毫秒算),实测相邻两次判据的 -> 时刻是 **21 / 19 ms 交替**。按 `>= 20 ms` 判,19 ms 那半数情况要拖到第三圈 -> ——**40 ms**,超了 §12.1 的验收,而且是间歇性的:同一个故障注两次,一次过 -> 一次不过。改成「连着两圈都越界」,在 20 ms 的周期下就是 20 ms。 -> (SR-5 说「持续 20 ms 才算」、§12.1 说「20 ms 内关路」,字面互相顶着, -> 只有「第一次看见的下一圈就断」同时满足两条。这 20 ms 是从**第一次看见**算的, -> 对外部世界的最坏延迟是 2 个周期 —— 不要紧,SR-5 原文就写着它 -> 「仅承担记录与告警,不承担实时保护」。) -> 2. **反灌不设确认窗口,一圈看见就断**(设计文档 §15.2 对策 2 原话「立即」)。 -> 与过压那 20 ms 的不对称是有意的:误断 VBUS 只丢一次 USB 数据,手机还由直供 -> 电着;漏判则把能量泵回 12V 母线**连累另外四台**。最坏 2 圈 = 40 ms, -> SW-8 给的预算是 200 ms。 -> 3. **判据只在「这一路该有电」且「这一圈的值是新鲜的」时武装**,另加一个 -> `EFUSE_EN↑` 之后 **60 ms** 的缓启动窗口(tON 5.1 + 斜坡 11.5 ≈ 17 ms 的 3 倍)。 -> 少了第一条,每圈都会「发现」关着的五路各偏低 3900 mV;少了第三条, -> **每次上电都会自己把自己关掉**;第二条就是 §10.10 那行「盲跑 → 保持供电, -> 禁用该路的 SR-5 / SW-8 判据」。 -> 4. **比的是该路 DAC 的设定值**(`rail_setpoint_mv()` 的影子,不碰总线), -> 不是 3900 这个常数 —— SW-4 允许运维单路微调,拿常数比会把一台调到 4.0 V 的 -> 机器当成过压关掉。 -> 5. **SR-5 关路 = 先断 VBUS 再关 eFuse**(`rail_all_off()` 的同一条理由:中间不留 -> 「手机只靠 USB 供着电」那个既不是开也不是关的窗口);**SW-8 只断 VBUS, -> 不碰 eFuse** —— 反灌是 USB 那一侧的事,把直供也关掉等于「因为充电器有问题 -> 就把手机关机」。过压且 VBUS 通着时两条都成立,于是**先撤能量来源, -> 20 ms 后再决定要不要关这一路**。 -> 6. **断电之前先 `t_actuator_abort()`。** 不 abort 的现象最阴:硬重启的 `HR_OFF` -> 会在 4 s 后自己把 `EFUSE_EN` 抬回去,「关掉的那一路」几秒后又带电了, -> 而日志里只有一条「已关路」。断 VBUS 时只打断 **EDL**(三个序列里只有它写 -> `VBUS_EN`)—— 无条件打断会把一次正在进行的救砖重启也顺手停掉。 -> 7. **动作在前,记录在后**,事件出口是一个回调(`t_safety_set_event_sink()`), -> A11 在那里接环形缓冲。记录用的 `safety_event_t` 已经按 §10.7 的「单条」 -> 形状定好(时间戳 + 路号 + 类型 + 当时的 V/I,16 B)。 -> -> 29 条 ctest 用例,25 条「一定会被写出来的错」逐个注进去跑过,**24 条变红**; -> 剩下那一条(关路之后把 `dev_n` 清零)在 sim 里造不出「写引脚失败」,抓不到 —— -> 所以那一行干脆没写,理由记在代码里。 - -### 10.3 任务划分(FreeRTOS / ESP-IDF) - -| 任务 | 周期 | 优先级 | 职责 | 失败后果 | -|---|---|---|---|---| -| `t_safety` | **20 ms** | 最高 | 读 6 片 INA226(V+I)与 PCA9555 输入;执行 SW-2 / SW-8 判据;越界直接操作 `EFUSE_EN` / `VBUS_EN`(**GPIO 直连,不经任何队列或抽象层**) | 告警延迟;保护本身不受影响(eFuse 是硬件) | -| `t_thermal` | 1 s | 中 | 两路 NTC(下板功率级)+ 载板 LM75BD(手机侧)→ 风扇 PWM 闭环;过温先降流后关路(FR-13、TR-5) | 风扇维持上一个占空比 | -| `t_actuator` | 事件驱动 | 中 | 每路一个状态机:断电重启 / 按键序列 / EDL 序列(10.6) | 序列停在中间 —— 必须有超时兜底 | -| `t_link` | 事件驱动 | 低 | 上位机命令解析与遥测上报(10.8) | 见 10.2:不影响本地职责 | -| `t_log` | 空闲 | 最低 | 环形缓冲落盘(10.7) | 丢日志,不丢功能 | - -**共享资源只有两个**,各配一把互斥锁: - -1. **I²C 总线** —— 10 个器件在同一条上(IF-15),其中 LM75BD 还在排针另一侧的载板上。 -2. **74HC595 的 24 位链** —— 全板共享,见 10.6 的影子寄存器。 - -**I²C 带宽核算**(400 kHz,`t_safety` 是唯一的高频用户): - -``` -一次 INA226 寄存器读 ≈ 46 个位周期 ≈ 115 µs -6 片 × 2 个寄存器(总线电压 + 分流电压) = 12 次 ≈ 1.4 ms -PCA9555 读两个输入端口 ≈ 0.12 ms - 合计 ≈ 1.5 ms / 20 ms = 约 8 % 占用 -``` - -余量充足,`t_thermal` 和偶发的 DAC 写完全挤得下。**首板上电时用逻辑分析仪实测复核一次** -——这个数是按理想时序算的,没算时钟延展和重试。 - -> **A8 已落地**(2026-09-07,`firmware/main/{tasks,sched,t_safety}.c` + -> `components/phonerail_hal/{include/hal_lock.h,hal_i2c_acct.c}`)。五条实现约定: -> -> 1. **五个任务是一张 const 表**(`tasks.c`),周期与优先级写在一起,开机 `task_table_ok()` -> 自检,不过就 abort。查的是**相对顺序**:safety 唯一最高、log 唯一最低、 -> thermal 与 actuator 同级、link 夹在中间;外加「巡检周期必须长于 INA226 的 -> 一帧转换 8.8 ms」,否则同一帧被读两次,现象是「电流特别稳」。 -> 2. **周期是间隔,不是延时。** `sched_periodic_*()` 记的是下一次该醒的**绝对时刻** -> (回绕安全,一律用 `int32_t` 差判先后)。写成「干完睡 20 ms」的话, -> 实际周期是 21.6 ms 且随总线忙闲抖动。过载时**跳过错过的整周期、保住相位**, -> 不补跑 —— 补跑的现象是「卡一下之后连着打满总线」。 -> 3. **两把锁不许嵌套持有。** 595 是纯 GPIO、I²C 是纯总线,两者没有交集, -> 所以「拿着 A 去拿 B」永远是写错了,`hal_lock_take()` 直接判 `HAL_ERR_STATE` -> ——与其定一套加锁顺序再指望人人记得,不如让它不可能发生。ESP 侧用的是 -> **mutex 不是 binary semaphore**(要优先级继承:t_link 拿着总线时 -> t_safety 不能被中优先级任务无限期挡住)。 -> 4. **巡检不死等锁**:等 5 ms(周期的 1/4)拿不到就跳过这一圈并计数。 -> 死等的现象是最高优先级的任务整个停摆,而且没有任何人会发现。 -> 同样地,**一个器件读失败不中断这一圈**,失败那一路保留上一圈的值并把 `ok` 标 false -> ——清零的话 A10 的过压判据会看到一个「正常」的 0 V。 -> 5. **那 8 % 是算出来的,不是抄的。** `hal_i2c_acct` 在 HAL 里按线上真实字节数记账 -> (两个目标同一份代码),`t_safety_budget_us()` 按事务形状静态预算,两者必须相等。 -> 实测:一次寄存器读 **48** 个位周期(本节手算的 46 没数 STOP 和一个重复 START), -> 一圈 13 次事务 = **1560 µs / 20 ms = 7.8 %**,与本节的「约 1.5 ms / 约 8 %」相符。 -> 多读一次寄存器就会让静态预算与实测对不上,测试当场变红。 -> 首板仍要按本节末尾那句用逻辑分析仪复核一次(这个数不含时钟延展与重试)。 - -### 10.4 五路一张表:禁止用通道号算任何东西 - -这是**本项目最容易写出「看起来对、实际错一路」的地方**。三组编号都不是逻辑顺序: - -- **INA226 地址**按布线难度重排过(IF-16,设计文档 §8.2); -- **74HC595 的位序**按 PCB 几何排(设计文档 §17.2 / §22.4); -- **GPIO 编号**按出线方向倒推(设计文档 §26.18)。 - -所以固件里**只允许有一张静态配置表**,全部五路循环都索引它: - -```c -typedef struct { - uint8_t ina_addr; /* 0x40 0x41 0x44 0x45 0x42 —— 查表,不能算 */ - uint8_t dac_chip; /* 0x4A: CH1-3 0x4B: CH4-5 */ - uint8_t dac_ch; /* U6.OUT0/1/2 U7.OUT0/1 */ - gpio_num_t efuse_en; /* GPIO 直连 */ - gpio_num_t vbus_en; /* GPIO 直连 */ - uint8_t key_bit[3]; /* PWR / VUP / VDN 在 24 位链里的位号 */ - uint8_t edl_bit; /* U33 上的位号 */ - uint8_t flt_bit; /* PCA9555 IO0_0..4 */ - uint8_t pg_bit; /* PCA9555 IO1_0..4 */ -} rail_cfg_t; -``` - -> ⚠️ **`ina_addr = 0x40 + ch` 这行代码一定会被写出来,而且只有 CH3/CH4/CH5 是错的。** -> 实际地址是 `0x40 / 0x41 / 0x44 / 0x45 / 0x42`。查表,不要算。 - -> **A3 已落地**:表在 `firmware/components/drv/include/ina226.h`,取值只走 -> `ina226_rail_addr(rail)`(rail 是 1~5,越界 abort), -> `test_channel_index_is_not_the_address_low_bits` 专门拦「改回算式」这一手。 -> 同处还记了两条 INA226 的实现约定:**寄存器指针不自增**(每次读前重写指针, -> sim 会自增、真件不会,靠自增在宿主机上测不出来);**Calibration 丢了不报错、 -> 只让 Current/Power 恒读 0**,所以巡检要定期 `ina226_check()`。 - -同理,**PCA9555 的方向寄存器只能配成全输入**:备用脚 Port0[7] / Port1[7:5] 在硬件上 -直接接地,配成输出并写 1 就是对地短路(设计文档 §17.2 已在图纸上注明)。 - -> **A6 已落地**:驱动在 `firmware/components/drv/include/pca9555.h`, -> **根本不提供「把某个脚配成输出」的接口**(和 sr595.h 不提供「抬 OE#」同一条规矩)。 -> 方向字一律过 `pca9555_dir_safe()`,不安全直接 abort。 -> 同处还记了四条这一片的实现约定: -> ① ⚠️ **Output Port 寄存器的复位值是 0xFF** —— 所以把接地脚配成输出的那一刻它就 -> 输出高电平,中间没有「先写个 0 再说」的余地,这比「配错方向」本身更急; -> ② **极性反转寄存器必须无条件清零**,不能赌复位值:看门狗只复位 ESP32, -> PCA9555 不掉电,上一条命的残留值原样留着,而写错的现象是「五路同时报故障」 -> 或者更糟的「五路故障全都看不见」,I²C 一切正常; -> ③ **三种极性**(FLT# 低有效 / PGOOD 高有效 / INA_ALERT 低有效)各自只在 -> `pca9555_rail_fault()` 等四个函数里出现一次,上层不许自己去 & 位; -> ④ **INT# 是加速路径不是主路径**:它要读走输入端口才释放,漏读一次就再也等不到 -> 下一个沿,所以 `t_safety` 必须无条件轮询(§10.3 的 20 ms 就是按这个排的)。 -> -> ⚠️ **FLT 与 PG 两张位表这次确实是顺排的**(都是 `bit = rail - 1`), -> 和 INA226 地址、595 位号那两张表不一样。所以这里最容易写出来的错不是「算式」, -> 而是**把 Port0 和 Port1 搞反** —— 两个端口的 bit0~4 长得一模一样,搞反之后 -> 「有故障」会被读成「电源良好」,方向正好相反,而且**静息态两个端口读回来都是 -> 全 1,单看一帧完全看不出来**。`test_one_rail_at_a_time` 专门拦这一手。 - -> **A7 已落地**(2026-09-07):那张 `rail_cfg_t` 表在 `firmware/main/rail_cfg.h` / -> `rail_cfg.c`,取值只走 `rail_cfg(rail)`(1~5,越界 abort)。六列的样子: -> -> | CH | INA226 | DAC | 按键位 PWR/VUP/VDN | EDL 位 | EFUSE_EN | VBUS_EN | -> |---|---|---|---|---|---|---| -> | 1 | 0x40 | U6.OUT2 | 1 / 2 / 3 | 16 | IO39 | IO40 | -> | 2 | 0x41 | U6.OUT0 | 19 / 17 / 18 | 7 | **IO48** | IO10 | -> | 3 | 0x44 | **U7**.OUT0 | 22 / 21 / 20 | 6 | IO6 | IO11 | -> | 4 | 0x45 | U6.OUT1 | 9 / 10 / 11 | 0 | IO41 | IO42 | -> | 5 | 0x42 | U7.OUT1 | 15 / 14 / 13 | 5 | IO8 | IO17 | -> -> 落地时定下来的五条: -> ① **本表与四个驱动那四张表是「两处写同一个数」,所以要对账而不是现算。** -> 开机自检 `rail_selftest()` 逐格拿本表和 `ina226_rail_addr()` 等取值函数比一遍, -> 对不上就报 `RAIL_SELFTEST_TABLE`。做成「开机从驱动现算」的话按定义就查不出分家, -> 而且表不再是 const 的、用之前得先记着初始化。 -> ② **判据本身也要被测**:真表是 const 的,测试改不动它,所以判据单拎成 -> `rail_selftest_table(t)`,测试喂几张故意写坏的表进去看它认不认 -> (分家 / 引脚重复 / 撞 SDA / strapping / 不存在的号 / 越界,六种都验过)。 -> ③ **两列 GPIO 是全固件唯一一处出处**,没有第二张表能对账 —— 所以 -> 「`EFUSE_EN1` 和 `VBUS_EN1` 写反」这种错自检**查不出来**(两个都是合法空闲脚、 -> 不重复、不越界)。唯一的拦网是 `test_en_pins_match_the_pcb_netlist` 里钉死的 -> 十个值(出处:`.claude/pcb1-parts.json` 的 U20 焊盘网络)和 B1 上板实测。 -> ④ **「不是顺排」要看整列,不能逐格撞算式**:CH4 的按键位正好是 9/10/11、 -> CH1 的 EDL 位正好是 16、CH3 的两根 EN 正好等于「IO4 起 / IO9 起」那个算式 —— -> 单看任何一路都「像顺排」。测试改成断言整列**不是等差数列**。 -> ⑤ ⚠️ **HAL 两侧的 GPIO 上限原来是 `pin < 48`,把 IO48 关在了门外**, -> 而 `EFUSE_EN2` 就在 IO48 上 —— 现象是 CH2 的 eFuse 既开不了也**关不掉**。 -> 已改成 0~48(含)。ESP32-S3 的编号是连续的但芯片不是:IO22~IO25 不存在、 -> IO26~IO32 是模组内部 flash/PSRAM,写进配置表 `gpio_config()` 不报错也不干活, -> 所以自检把这些号也当成越界。 -> -> 同处还落地了**全板引脚表** `firmware/main/board_pins.h`(§8.1 那张表里 -> 「全板独一份」的那些脚)与两根 EN 线的表驱动遍历 -> (`rail_en_init()` / `rail_efuse_enable()` / `rail_vbus_enable()` / `rail_all_off()`)。 -> `rail_all_off()` **先断五路 VBUS 再关五路 eFuse**,中间不留「手机只靠 USB -> 供着电」的窗口;十个脚全部试一遍,不因为某一个失败就提前返回。 -> 高有效这件事只在 `en_write()` 里出现一次。 - -### 10.5 电压通道:DAC 只做微调,而且是反向的 - -| 事实 | 含义 | -|---|---| -| `Vd = 0 V` → 输出 **4.010 V**(上限) | **DAC 写得越大,输出越低** | -| `Vd = 0.548 V` → 输出 3.90 V(NVM 出厂默认) | 上电即安全值,不依赖 ESP32 初始化成功 | -| `Vd = 2.424 V`(满量程)→ 输出 **3.524 V**(下限) | 满量程是 2.424 V,不是 2.5 V(内部基准 1.212 V ×2) | - -> ⚠️ **反向关系是本节最容易写反的一条。** 写反的现象不是报错,而是 -> 「调高电压的命令把手机调到了 3.52 V」——手机照样能开机,只是更容易在负载尖峰下掉电, -> 而且**要跑一阵子才看得出来**。固件里把这一步封成 `rail_set_mv()` 单一入口, -> 内部做 mV → Vd → code 的换算,**不要在别处直接写 DAC 码值**。 - -三条固件规矩: - -1. **越界即拒绝**:`rail_set_mv()` 收到 >4010 mV 的请求直接返回错误并记事件(SR-13)。 - 注意这是**第四道**限制,前三道在硬件里 —— 它的作用只是让操作者早点知道自己写错了。 -2. **不用 DAC 做保护**:任何时候都不要靠「把电压调低」来处理异常,那是 `EFUSE_EN` 的活。 -3. **出厂标定**:分压网络与 DAC 基准都有 ±1 % 级的容差,用该路 INA226 读回实际输出, - 做一次两点线性标定存进 NVS。标定值缺失时退回理论公式(误差约 ±40 mV,可用)。 - -> **A4 已落地**:换算与设定在 `firmware/main/rail.c`,唯一入口 `rail_set_mv(rail, mv)` -> (rail 是 **1~5**)。三个验收点各有一条测试:4020 mV 被拒、码大电压低、3900 mV ↔ Vd 0.548 V。 -> 标定(本节规矩 3)**还没做**,现在走的是理论公式;`RAIL_VOUT0_UV` / `RAIL_DAC_SPAN_UV` -> 两个常量就是将来要被标定值顶替的地方。 -> -> 同处记下三条 DAC63204 的实现约定(出处 SLASEX3A): -> - **12 位码在 `DAC-X-DATA`(19h~1Ch) 里是左对齐的**(bit15-4)。忘了 `<< 4` 不报错, -> 只让输出变成 1/16 —— 而在本板上那表现为「五路都是 4.010 V 满电」,看不出异常。 -> - **`EN-INT-REF` 要先于增益设定写**,且**上电安全码要写在通道上电之前**: -> 复位码是 0,而 Vd = 0 就是输出上限 4.010 V。`dac63204_init()` 因此把 -> `COMMON-CONFIG` 写两次(先按住输出,写完码再上电)。 -> - **通道分配不是 `rail−1`**(设计文档 §26.18 重排过,五路里四路对不上): -> CH1→U6.OUT2 / CH2→U6.OUT0 / CH3→**U7**.OUT0 / CH4→U6.OUT1 / CH5→U7.OUT1。 -> 四通道电气对等 ⇒ 接错了照样工作,只是调错路,功能测试测不出来。 - -### 10.6 执行器:三个状态机 + 一个影子寄存器 - -**每路三个序列,彼此互斥、不可重入**(同一路上同时只能跑一个): - -| 序列 | 时序 | 关键约束 | -|---|---|---| -| 硬重启 | `EN↓` → 等 3~5 s → `EN↑` → 等 **17 ms**(tON 5.1 ms + 缓启动斜坡 11.5 ms)→ 等 PG 置位 | 放电由硬件联锁自动完成(SR-7),固件**不要**去控制放电 MOS | -| 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | 组合键(VOL−+PWR)必须**同一帧**写进去,不能先后两次 | -| EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | 顺序固定(SW-6):先短接再插 USB | - -**影子寄存器是这一节的核心**:15 路按键 + `HUB_RST` + 5 路 `EDL_EN` 全在**同一条 24 位 -移位链**上,写任何一位都要把整条链重发一遍。 - -``` -ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U33) -写入顺序:U33 字节 → U22 字节 → U21 字节,最后一个 RCLK 上升沿统一生效 -``` - -规矩:**内存里保存一份 24 位影子值,全固件只有一个函数能写 595**,任何任务改按键 -或 EDL 都是「改影子 → 请求刷新」。多处直接操作移位链,症状是**另一台手机的按键被顺手 -清掉**,而且复现要靠两个动作正好撞在一起 —— 属于会拖很久的那类 bug。 - -> `RCLK` 打点之前输出不变,所以整帧刷新是原子的;`OE#` 保持有效,**不要**用拉高 `OE#` -> 的方式「临时全松开」——那会让 15 个栅极同时进高阻,靠的是每个栅极自己的 100 kΩ 下拉, -> 是复位路径,不是正常控制路径。 - -> **2026-09-06 A5 落地**:`components/drv/sr595.{h,c}`。影子是一个 24 位整数 -> (bit0 = U21.QA 链首 … bit23 = U33.QH 链尾),唯一写入点是 `sr595_flush()`; -> 别处只准 `sr595_modify()` 改影子,组合键走 `sr595_write(mask, value)` 一帧发完。 -> 位表见设计文档 §17.2(§26.18 重排后的那张,**不是 09-04 那张**)。 -> -> 同处记下三条这一层的实现约定: -> - **上电顺序是「先锁存安全值,再使能 `OE#`」,不能换。** 三片的 `MR#` 接死在 +3V3, -> 没有复位线 ⇒ 上电时移位寄存器内容**不确定**。先拉低 `OE#` 就是把那坨随机值 -> 放到 15 个栅极上 —— 五台手机的电源键可能一起被按下去。所以 `sr595_init()` 里 -> `KEY_OE#` 在安全值锁存完之前**根本不配成输出**,靠板上 R609 上拉着。 -> (ESP-IDF 的 `gpio_config()` 一配成输出就开始驱动,输出寄存器复位值是 0。) -> - **`HUB_RST` 低有效**,所以上电安全影子是 `1 << 23` 而不是 0。写成 0 的现象是 -> 「Hub 插上枚举不出来」,而五路电压电流全都正常,看不出跟固件有关。 -> - **发送失败要留着脏**(`sr595_flush()` 失败不清 dirty,下次重发整帧)。 -> 丢了脏标志 = 链上和影子永久对不上,而后面每一次 flush 都会「成功」。 - -> **2026-09-07 A9 落地**:`firmware/main/t_actuator.{h,c}`(+ `test/test_t_actuator.c` 23 例)。 -> 三个序列成了一张**每路一份**的状态机:`HR_OFF → HR_RAMP → HR_PG` / -> `KEY_HOLD` / `EDL_SHORT → EDL_ENUM`,全部以 `CLEANUP` 收尾。时间常量都带出处 -> (断电 4000 ms 落在 §7.4 的 3~5 s 窗口里、斜坡 17 ms、等 PG 上限 500 ms、 -> 按键 100/2000/10000 ms、EDL 短接 10 ms + 等枚举 3000 ms)。 -> 这一层的六条实现约定: -> -> 1. **一个函数一步,不许 `while(1)`。** `t_actuator_step()` 只看时钟、推状态、 -> 返回「下次最晚多久再来」;任务体在 `app_main.c` 里还是三行。写进死循环的 -> 时序在宿主机上一行也跑不到 —— 而这一包的全部内容就是那些时间常量与它们的先后。 -> 2. **按下去的东西一定要松开,`CLEANUP` 不放弃。** 正常结束、超时、出错、被 -> `t_actuator_abort()` 打断走的是**同一条**收尾路径;写不进 595 就一直重试, -> 这一路一直算 busy。放弃的现象是电源键留在按下状态、手机再也起不来, -> 而遥测上五路电压电流全都正常。连「按下那一步就失败」也要走收尾 —— -> 否则影子里留着一个脏的「按下」,谁下一次 `flush` 谁就替你把键按下去。 -> 3. **等不到 PG 不许再断电。** `ACT_RES_NO_PG` 的语义是「电已经开着,只是没能确认」。 -> PCA9555 失联时把「看不见」当成「坏了」去断电,等于一片 I/O 扩展器失联 -> 就让五台手机一起停机 —— §10.10 明写着这片不应答**不关输出**。 -> 4. **PG 是喂进来的,执行器不碰 I²C。** `PGOOD` 已经被 `t_safety` 每 20 ms 读一遍, -> 再读一次等于凭空多一份总线占用和一份两边不一致的可能。回调(`act_pg_fn`) -> 同时也是「两把锁永远不会嵌套」的物理保证:本文件只碰 GPIO 与 595。 -> 5. **不可重入 = 直接拒**(`HAL_ERR_STATE`),要抢占得先显式 `t_actuator_abort()`。 -> 被拒的请求**一个字节都不发**,也不改正在跑的那个序列的时长。 -> 6. **组合键一次 `chain_write()` 发完**(同一个 RCLK 沿)。分两次写的实现在 -> `sim_sr595_latches()` 上会 +2,而且中间那一帧只按下了一个键。 -> -> 验收之外还钉了两条容易漏的:硬重启**开始前**先松掉本路三键(否则手机开进 -> Recovery/Fastboot),以及 EDL 结束后 **VBUS 留着不动**(手机正挂在 9008 上)。 -> 21 条注错逐个跑过,全部变红。 - -### 10.7 事件记录与存储 - -事件是 SW-3 的交付物,也是排查排线接触不良的唯一线索,所以**记录路径不能依赖上位机在线**。 - -| 项 | 设计 | -|---|---| -| 缓冲 | RAM 环形缓冲,≥256 条;`t_safety` 只入队,不落盘 | -| 落盘 | `t_log` 空闲时批量写 NVS / SPIFFS 分区 | -| 单条 | 时间戳 + 路号 + 事件类型 + 当时的 V/I(16 B 量级) | -| 容量 | 4096 条 ≈ 64 KB,按每天几十条算够存半年以上 | -| 时间 | 上电从 0 计;上位机连上后下发一次墙钟做偏移校正(不加 RTC 芯片) | - -必记事件:eFuse FAULT / PG 变化、SR-5 触发关路、SW-8 反灌断 VBUS、过温降载、 -每次重启序列(软 / 硬、谁下的命令)、越界命令被拒、I²C 器件失联、风扇停转。 - -> **A11 已落地**(2026-09-07,`firmware/main/evlog.{h,c}` + 新增的 -> `hal_store`(掉电不丢区)+ `partitions.csv` 的 `evlog` 分区)。落地时定下来的八条: -> -> 1. **落盘不用 NVS,也不用 SPIFFS,是一块裸的 data 分区。** NVS 是键值表 -> (4096 条记录 = 4096 个键,页式 GC 会把「写一条事件」变成一次不可预测的搬移), -> SPIFFS 的掉电一致性自己就是一整章。evlog 本身已经是环形缓冲,它要的只是 -> 「擦一个扇区 / 写一段字节」—— HAL 因此多出第六样 `hal_store`。 -> 2. **容量是 17 个扇区 = 4335 条,不是 16 个。** 每个扇区要拿一个 16 B 的槽 -> 当扇区头(epoch),于是 4 KB 只装得下 255 条,16 扇区 = 4080, -> **差 16 条不到本表要求的 4096**。8 MB flash 上多这 4 KB 没有代价。 -> 3. **序号 = flash 里的位置**(`epoch × 255 + 槽号`),不是一个自增计数器。 -> 重启之后接着数不用把计数器存起来,掉电也不会倒退。代价是**没落盘的事件 -> 还没有序号** ⇒ `events` 命令(10.8)要先 `evlog_flush()` 再读。 -> 反过来做(入队时发号)会在缓冲溢出丢事件时留下一段永远补不上的空号, -> 上位机那边的现象是「拉日志卡在某个序号上不动」。 -> 4. **入队在临界区里做完,不是加第三把锁**(§10.3 的共享资源只有两个)。 -> 入队方是 `t_safety`(最高优先级、断电之后调),停在一把 `t_log` 持着的锁上 -> 就等于让最低优先级的任务决定巡检的节拍。关中断的临界区里**只改内存**: -> 时间戳与校验都在外面算完。 -> 5. **丢事件可以,悄悄丢不行。** RAM 缓冲(256 条)满了盖最旧的 ——「最新的一条 -> 永远进得去」——但被盖掉的会在下一次落盘时留下一条 `LOG_EV_LOST`, -> 值是丢了多少条。一份会安静少掉几行的日志比没有日志更糟:排查的人会拿它 -> 当完整的时间线看。flash 写失败丢掉的那一批同样补这条。 -> 6. **每条记录带一个 CRC-8,而且只能是 CRC-8。** NOR 的写会被掉电打断在任意 -> 字节上,半条记录在字节层面跟真记录长得一模一样。第一版用异或和, -> 漏掉的正是最常见的那条:`bus_mv` 与 `current_ua` 都是 0 的记录被切在第 8 个 -> 字节上时,后 8 个字节留在 0xFF,而 **8 个 0xFF 的异或恰好是 0** —— -> 半条的校验和与整条一模一样。 -> 7. **写失败之后放弃这个扇区的剩余部分。** 接着写的话,重启扫描会停在那半条上, -> 把后面写进去的全部当成不存在。代价是最多浪费 254 个槽,换来的是 -> 「扫描停在哪 = 日志到哪为止」这条不变量永远成立。被放弃的那段在序号空间里 -> 是个洞,`evlog_read()` 给它一条空记录**占住位置** —— 跳过它的话上位机的 -> 游标会卡在洞上。 -> 8. **每次上电记一条 `LOG_EV_BOOT`。** 本表写着「上电从 0 计」,所以时间戳会 -> 跳回去;没有这条记录的话,两次上电的事件在时间轴上互相穿插, -> 而日志里看不出中间重启过。 -> -> 事件类型的**数字是 flash 格式的一部分**(改一个值就等于把跑了半年的历史记录 -> 重新解释了一遍,而且读出来仍然是合法记录、没有一处报错),所以 `log_ev_t` -> 只许在末尾追加;`safety_ev_t` → `log_ev_t` 是一个显式的 switch,不是加偏移。 -> 上表里还没人产生的那几类(eFuse FAULT / PG 变化 / 过温降载 / 重启序列 / -> 命令被拒 / I²C 失联 / 风扇停转)**号已经占住**,各归各的包去填。 -> -> 27 条 ctest 用例;23 条「一定会被写出来的错」逐个注进去跑过,**21 条变红**。 -> 剩下两条抓不到,理由见 `firmware/HANDOFF.md`。 - -### 10.8 上位机接口 ✅ 已定稿(2026-09-06 用户拍板:方案 C) - -**这一节回答的问题**:SW-1 的触发条件写的是「`adb` 探活连续失败 N 次」, -而 **ESP32 跑不了 adb** —— 所以崩溃判定必然发生在上位机,板子只负责执行动作。 -这意味着必须有一条控制链路,而在此之前**全部文档里都没有定过它**。 - -**结论:WiFi 为常态链路,USB CDC 为兜底链路,两条同时在线。** - -选它不是因为「都要」,而是因为 **SW-1 是这块板子的核心价值, -而 WiFi 与手机 adb 走的是同一张网** —— 只做 WiFi 等于让「救援通道」和「故障通道」 -共命运,恰好在最需要它的时候一起失效。USB 的增量成本很低:线本来就插着。 - -留档备查的两个单选方案: - -| 方案 | 优点 | 为什么不单独用 | -|---|---|---| -| A. 只做 WiFi | 无线、可多台组网;设计文档 §9.5 的 3V3 预算里**已经给 ESP32-S3 的 WiFi 突发留了 ≈350 mA**,说明当初就是按带 WiFi 设计的 | 网炸了救砖通道跟着炸,见上 | -| B. 只做 USB CDC | 最可靠,不依赖网络 | 每块板占上位机一个 USB 口;机架规模上去之后不现实 | - -#### 两条链路怎么共存 - -```diagram=links - WiFi (MQTT/HTTP) USB CDC (J22) - │ │ - ┌───────▼────────┐ ┌────────▼───────┐ - │ 传输适配器 A │ │ 传输适配器 B │ ← 只管收发字节与分帧 - └───────┬────────┘ └────────┬───────┘ - └──────────┬─────────────┘ - ┌──────▼───────┐ - │ 单一命令处理器 │ ← 全部鉴权、限幅、幂等判重都在这里 - └──────┬───────┘ - ▼ - 执行器 / 遥测 -``` - -四条规矩: - -1. **命令处理器只有一个。** 两条链路是**传输**,不是两套功能。任何安全约束 - (SR-13 的限幅、越界拒绝、序号判重)都写在处理器里,绝不写进适配器 —— - 否则以后加第三条链路时必然漏掉一处,而**功能测试完全测不出来**。 -2. **不做主备切换,两条都常开。** 谁先送到算谁的,靠 3 的序号去重。 - 做「主备」就要判定"主链路死了没有",而那个判定本身就是新的故障点。 -3. **序号是全局的,不分链路。** 上位机给每条命令一个单调递增的序号, - 板子记住最近 N 个已执行的序号。WiFi 重传 + USB 又发了一遍同一条命令时, - 第二次必须被丢弃 —— 否则「按 10 秒电源键」会执行两遍,第二遍正好按在刚开机的手机上。 -4. **USB 链路与日志共用一条流。** ESP32-S3 的原生 USB-Serial-JTAG 本身就是 - CDC-ACM,不需要切到 TinyUSB,也不影响 esptool 下载(FR-15 不变)。 - 代价是命令响应和运行日志在同一条串流上,**必须分帧**(建议命令用带前缀的单行 - JSON,日志走另一个前缀),否则日志一刷就把响应冲散。 - -> ⚠️ **别把 WiFi 凭据和 MQTT 地址编译进固件。** 放 NVS,用 USB 链路配置 —— -> 这正好是兜底链路的第二个用途:新板子上架时先插 USB 配网。 - -命令语义与链路无关,两条链路完全一致: - -| 命令 | 参数 | 说明 | -|---|---|---| -| `status` | — | 五路 V/I/PG/FLT、输入 V/I、两路 NTC + 载板温度、风扇转速、各路状态机状态 | -| `set_voltage` | 路号, mV | 走 `rail_set_mv()`,越界拒绝(10.5) | -| `power_cycle` | 路号, 断电秒数 | 二级硬重启(4.1) | -| `key` | 路号, 键位掩码, 保持 ms | 单键与组合键同一个接口 | -| `edl` | 路号, on/off | 内部按 SW-6 的固定时序展开 | -| `vbus` | 路号, on/off | 默认断开 | -| `fan` | 占空比 / auto | `auto` 交回 `t_thermal` 闭环 | -| `events` | 起始序号 | 增量拉取事件日志 | - -三条接口硬约束: - -- **没有任何命令能让输出超过 4.2 V**(SR-13)—— 包括「原始 DAC 写入」这类调试命令, - 它压根不该存在于发布固件里。 -- **命令必须幂等或带序号**:链路重传时不能让「按 10 秒电源键」执行两遍(上面第 3 条)。 -- **链路失联不触发任何动作**(10.2):没有心跳超时关输出这回事。 - -#### A12 定稿的线上格式(2026-09-27,`firmware/main/cmd.h`) - -一行一条、单层扁平 JSON(嵌套 / 数组 / 转义 / 浮点 / null / 重复键一律 `parse`)。 - -``` -→ {"seq":17,"cmd":"key","rail":3,"keys":"pwr","ms":10000} -← {"seq":17,"ok":true} -← {"seq":17,"ok":false,"err":"range"} 重传命中:{"seq":17,"ok":true,"dup":true} -``` - -| 命令 | 参数 | 与上表的差别 | -|---|---|---| -| `status` | — | 只读,seq 可省,不判重。读不到的量回 `null`(不是 0) | -| `set_voltage` | `rail`, `mv` | 限 3524~4010(DAC 物理范围,比 SR-13 的 4.2 V 更紧) | -| `power_cycle` | `rail` | **不收断电秒数**:固定 4 s(§7.4 的 3~5 s 窗口) | -| `key` | `rail`, `keys`(`"pwr"` / `"vdn+pwr"`…), `ms`(1~15000) | 单按电源键 ≥10 s 记一条软重启事件 | -| `edl` | `rail` | **不收 on/off**:跑一遍 SW-6 固定序列;退出 EDL 用 `power_cycle` | -| `vbus` | `rail`, `on` | eFuse 关着时 `on:true` 拒(`state`);序列进行中拒(`busy`) | -| `power` ⭐新增 | `rail`, `on` | 管理界面要能停一台。关 = 先断 VBUS 再关 eFuse;VBUS 断不掉也照样关 eFuse,回 `io` | -| `fan` | `duty`(0~1000,0 = 自动) | **只是下限**:实际 = max(温度曲线, duty),远程不能把风扇调小 | -| `events` | `from`(可省) | 一次最多 8 条 `[序号,上电ms,路,类型,arg,mV,µA]`,类型是 `log_ev_t` 的冻结数字 | - -- 会动东西的 7 条**必须带 seq(1~2³²−1)**;判重窗口 32 条、全局、按值相等判。 - 上位机同一个 seq 只用于同一条命令的重传,「再试一次」要换新 seq。 -- 错误码:`parse / unknown_cmd / no_seq / arg / range / empty / busy / state / io / overflow`。 - `range / empty / state` 记 `LOG_EV_CMD_REJECT`(arg = 命令号,bus_mv = 越界值,current_ua = 原因号),其余不记。 -- 空槽(`PRSNT#` 高)的按路命令一律 `empty`。 -- 板子重启会清空判重窗口;上位机看 `status.up` 变小识别重启。 - -#### A13 定稿的 USB 分帧(2026-09-27,`firmware/main/ulink.h`) - -``` -上位机 → 板 @C [*XXXX] - CRC 可省(给人手敲);带了就必须对 -板 → 上位机 @R *XXXX - CRC 必带;XXXX = CRC-16/CCITT-FALSE(json),4 位大写十六进制 -其余任何一行 = 日志 -``` - -- 前缀只在行首算数;日志行若以 `@` 开头,固件会垫一个空格 ⇒ 日志永远不会被当成帧。 -- 链路层自己的错误(不进命令处理器、不记事件):`too_long` / `bad_crc` / `bad_frame`(NUL 或控制字符)/ `queue_full`,能解析出 seq 就回显。 -- **USB 副控制台已关**(`CONFIG_ESP_CONSOLE_SECONDARY_NONE`):USB 上只有「日志钩子的副本 + 回应」两路,同一把锁;`printf` / panic 只去 UART0(J33)。 -- 上位机打开串口时 **DTR / RTS 保持低**,否则可能把板子复位。参考实现 `firmware/tools/prlink.py`。 -- 实测(板 B,2026-09-27):200 条 status 连发 0 坏帧,混进的 `t_safety` 日志全部正确识别;往返 status ≈3 ms、小命令 <1 ms。 - -#### A14 定稿的 MQTT 链路(2026-09-27,`firmware/main/mlink.h` / `netcfg.h`) - -base = `phonerail/`,id = 板子 STA MAC 的 12 位小写十六进制(板 B = `b81f3fc9c8bc`)。 - -| topic | 方向 | QoS | retain | 内容 | -|---|---|---|---|---| -| `/cmd` | 服务器 → 板 | 1 | 否 | 一条命令 JSON(与 USB `@C` 负载相同,无前缀无 CRC) | -| `/rsp` | 板 → 服务器 | 1 | 否 | 一条回应 JSON | -| `/tele` | 板 → 服务器 | 0 | 否 | 周期遥测 = 一条 status 回应(无 seq),默认 1 s(`tele_ms` 200~60000) | -| `/state` | 板 → 服务器 | 1 | 是 | `{"online":true,"fw":..,"ip":..}`;掉线由遗嘱改成 `{"online":false}`(keepalive 15 s) | - -- **配网(C1)**:`wifi`(ssid / pass)与 `mqtt`(uri / user / pass / tele_ms)两条命令,存 NVS,**只收 USB**(经 WiFi 来回 `link` 并记事件)。 - `net` 查网络状态,**永不回显密码**(只报 `pass_set` / `mpass_set`);含 `wifi_reason`(最近一次断开原因码)。 -- uri 收 `mqtts://`(TLS,CA 编在固件:`firmware/main/certs/evse_ca.pem`)或 `mqtt://`(只该用在局域网)。 -- **broker = home 服务器现有 mosquitto 的 8883(TLS)**,与 evse 共用:板子用户名 = id,ACL 用 `pattern … phonerail/%u/…` - 只许碰自己的四个 topic;后端用户 `phonerail-backend` 可读写 `phonerail/#`。口令在服务器 `/root/phonerail/mqtt.env`(仅 root)。 -- 失联不动作:WiFi 断按 1/2/4…60 s 退避重连,MQTT 由 esp-mqtt 每 10 s 重连;没有任何分支会碰输出。 -- 实测(板 B,2026-09-27):经 WiFi `CANBOX-5G` → 公网 → 8883 TLS 连上;判重 / `link` / `empty` / `parse` 回应与遥测(1 条/s)全部符合。 - -#### C2 定稿的 OTA(2026-09-27,`firmware/main/ota.h` / `ota_esp.c`) - -用户定的三条:**明文 HTTP 下载 + 命令带 sha256**(哈希走 MQTT/TLS,下载被改了只会哈希不对)、**不签名**(不烧 eFuse)、 -**新固件 120 s 内连上 broker 才算健康,否则回滚**。 - -- 命令(两条链路都收,会动东西 ⇒ 必须带 seq、进判重窗口): - `{"seq":N,"cmd":"ota","url":"http://home.example:7765/fw/.bin","sha256":"<64 位小写>","size":N,"reboot":false}` - 回 `{"seq":N,"ok":true,"act":"start"|"reboot"|"ready"}`;下载中再来 `busy`;新固件还没自证时 `state`;size 超一个槽(3 MB)`range`。 - url 只收 `http://`、≤64 字节(命令字符串上限)。 -- ⚠️ **`reboot` 默认 false**:只下载 + 校验 + 切启动分区、下次复位生效;同一个镜像已就绪时再发带 `"reboot":true` 的,不重下、直接重启。 - 重启 / 超时回滚:板上固件 ≥0.1.0-c3 且目标镜像认接管 ⇒ **手机不断电**(§10.9「重启不断电」);否则 ESP 一复位 EN 就被下拉,**五台手机硬断电一次**。 -- status 多一块 `"ota":{"st":"idle|dl|ready","got":N,"size":N,"err":null|"http|size|hash|flash|image|boot|noslot","pend":bool,"bad":null|"版本号"}`; - `pend` = 正在跑的固件还没自证,`bad` = 被判无效回滚掉的那份的版本号(镜像头版本 = `PHONERAIL_FW_VERSION`)。 -- 新事件 **`LOG_EV_OTA`(18)**,arg:1 开始 / 2 就绪 / 3 失败(bus_mv = 原因号)/ 4 自证健康 / 5 回滚 / 6 为换固件重启。新命令号 **`ota` = 13**。 -- 健康判据:启动走完 + 配了 broker 就要 MQTT 连上(只配 WiFi 则 WiFi 通、都没配只看启动);在 `t_link` 里判,另有 esp_timer 在 150 s 兜底 - (app_main 卡死也能回滚);自证前崩溃 / 看门狗由 bootloader 回滚(`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`)。 -- 分区(16 MB):nvs 0x9000(没动)/ phy_init / **ota_0 0x10000、ota_1 0x310000 各 3 MB** / otadata 0x610000 / evlog 0x612000。 -- 服务器:nginx 7765(`rack/deploy/nginx-phonerail-fw.conf`)只放行 `/fw/<16 位十六进制>.bin`,**不过门户**(板子过不了门禁; - 完整性靠哈希、镜像里无机密);路由器 7765 转发 2026-09-27 已加。推送工具 `firmware/tools/ota_push.py [--reboot]`。 -- 实测(板 B,2026-09-27):1 MB 下载 ≈8 s,推送到自证健康 19 s;错哈希 ⇒ `err:hash`、不切分区;删掉板子 broker 账号后推送 ⇒ - 120.1 s 回滚到旧版、`bad` 报出被回滚的版本;下载期间巡检 `skipped` 0。 - -### 10.9 启动顺序 - -``` -1 上电:GPIO 全部显式置安全态(硬件下拉已保证,固件再确认一次) -2 I²C 扫描 10 个器件 —— 缺任何一个都进「降级模式」(10.10),不要照常启动 -3 读回 DAC 通道值(NVM 默认 3.90 V),与期望比对 -4 载入 NVS 里的标定值与上次的每路使能状态 -5 逐路上电,**路与路之间错开 100 ms** -6 等各路 PG,超时则记事件并保持该路关闭 -7 起风扇闭环,起链路任务 -``` - -> ⚠️ **第 5 步的错开不是讲究,是必须**:单路缓启动涌流 0.8 A,五路同时开就是 4 A -> 叠在 12 V 输入上,而输入预算本来就是 8.3 A(PR-2)。错开 100 ms 之后 -> 各路的斜坡(11.5 ms)完全不重叠。 - -**A15 定稿的落地(2026-09-27,`firmware/main/bringup.{h,c}`,宿主机 24 例)**: -- 分两段:`bringup_devices()` = 第 1~4 步;**`t_safety` 在两段之间起来**,再 `bringup_power_restore()` = 第 5~6 步 - —— 上电那一刻巡检已经在看(原文第 7 步没说巡检何时起,等五路开完才起就有近 1 s 的盲区)。 -- 第 2 步 R2 实际是 15 片:主干 mux / 两片 DAC / 输入侧 INA226 / CH224A,五片刀片各 INA226 + PCA9555。 - 六片 INA226 都在这一步写 Config / Calibration(A14 之前没做,电流恒读 0);输入侧 0x43 在这一步登记对调接。 -- 第 3 步 DAC 读回比对**只在上电复位时做**:热重启时 DAC 没断过电,里面是上次命令设的值。 -- 第 4 步「上次的每路使能状态」= **意图**:只有 `power` 命令改它(NVS 键 `phonerail/rail_want`,1 字节); - 判据跳闸、硬重启、mux 全断都不改。新板没有这个键 = 一路都不开。 - ⚠️ 设定电压**不**持久化:重启后五路回到 3.90 V,上位机要的话自己重下。 -- 第 5 步错开按「上一路 EN 抬高的时刻」算(不等上一路 PG),ESP 上实际 100~110 ms。 -- 第 6 步每路各等 500 ms(= 硬重启的 PG 上限);等不到就关回去并记 **`LOG_EV_PG_TIMEOUT`(17,新增)**。 - PCA9555 读不到时改按 INA226 电压(≥ 设定 − 100 mV)判;两样都读不到 = **开着、报「确认不了」**,不当成坏了。 -- `HUB_RST#` 只在上电复位时脉冲,其余复位原因连配置都不做(配成输出那一刻就是一次复位)。 - -**重启不断电(2026-09-27,`firmware/main/warm_esp.c` + `bringup.h` 的 `adopt`)**——用户定:只管计划内重启,VBUS 也保持。 -- 重启前(`warm_restart()`):记下开着的路(RTC_NOINIT:魔数 + 掩码 + 反码)→ `gpio_hold` 锁住五根 `EFUSE_EN` → 重启。 - **目标固件不认接管就不锁**(镜像第 288 字节的能力标记 `PRCAPS1`,`.rodata_custom_desc`):推老版本(≤0.1.0-c2)时 - 它起来写不动被锁住的 EN,五路会**永远关不掉** —— 所以对它照老样子全断重启。 -- 起来后(`warm_take()`,bringup 之前):复位原因 = `ESP_RST_SW` **且**标记对得上才接管;标记一次性。 - 再读 EN 脚真电平核对(输出脚配成 INPUT_OUTPUT,锁住时输入缓冲还开着),线上是低的那路不接管。 -- 第 1 步 `rail_en_init_keep(adopt)`:接管的路写高,**五根一律放开 hold** —— 崩溃 / 看门狗时上一条命的锁可能还在,不放开就关不掉。 - 刀片不在 / mux 失联的接管路立刻关(`adopt_dropped`,空槽记 `SLOT_EMPTY` arg 1)。 -- PCA9555 用 `pca9555_init_keep(VBUS 位)`:方向寄存器已是本固件写的才留 VBUS(掉过电的片子锁存器是 0xFF,照留 = 凭空开 VBUS),键 / EDL 清零。 -- 第 5~6 步:已开着的路不重开、不等 PG。DAC 照常 init(中间约 1 ms Hi-Z ⇒ 输出退化到 3.890 V,设计文档「DAC 未供电 / 输出 Hi-Z」那一行),自定义电压回到 3.90 V(本来就不持久化)。 -- 事件:`LOG_EV_OTA` arg 7,bus_mv = 接管掩码,µA 列 = 关掉的掩码。 -- 代价:esp_restart → 新固件 t_safety 起来约 1~2 s,SR-5 / SW-8 不判(eFuse 硬件保护在)。兜底定时器回滚、新固件崩溃都**不**走接管。 -- 实测(板 B,空槽强制 CH1 在位的台架镜像):CH1 开着 → OTA 带重启 → 新固件读到 IO9 仍为高、记 `18/7 mv=1` → CH1 一直开着。 - -### 10.10 降级矩阵 - -| 失效 | 检测方式 | 行为 | -|---|---|---| -| 某片 INA226 无应答 | I²C NAK | 该路失去遥测 → **保持供电**,标记为「盲跑」,禁用该路的 SR-5 / SW-8 判据并持续告警 | -| I²C 总线挂死(SDA 被拉低) | 连续超时 | 发 9 个时钟脉冲做总线恢复;仍不通则复位 I²C 外设;再不通进降级模式 | -| DAC 无应答 | I²C NAK | **不关输出** —— DAC 不响应时输出停在 NVM 默认的 3.90 V,是安全值 | -| PCA9555 无应答 | I²C NAK | 失去 FAULT / PG 可见性;改用 INA226 的电压判据兜底 | -| 载板 LM75BD 无应答 | I²C NAK | 多半是排针接触不良(它在排针另一侧)→ **风扇转到 100 %** 并告警,不要按「读不到温度=不热」处理 | -| 上位机失联 | 心跳超时 | 见 10.2:**什么都不做** | -| 看门狗复位 | — | 复位即全关(SR-6),重启后按 10.9 恢复,并记一条复位事件 | - -**降级模式**的定义:只保留「关断能力」与「事件记录」,不再自动上电、不接受调压命令, -持续告警。宁可五台手机不开机,也不要在盲态下带电跑。 - -**A15 落地时补的三条(2026-09-27,逐行用例在 `firmware/test/test_bringup.c`):** -- **mux 失联时主干也够不着**:`i2cbus_xact(主干)` 要先给 mux 写「全关」,这一步失败回调就不跑。 - 所以启动时 mux 复位后仍不通 ⇒ 不去碰 DAC / 输入侧 / CH224A(不刷三条误导性的 I2C_LOST), - 设定值影子回 NVM 默认、功率按 60 W,只记一条 MUX_LOST。 -- **看门狗复位**的「复位事件」就是 `LOG_EV_BOOT`,arg = `esp_reset_reason()`(1 上电 / 4 panic / 6 任务看门狗 / 11 USB …)。 -- **I²C 总线挂死**那一行(9 个时钟恢复 + 复位外设)**还没做**,在 HAL 层,待办。 -- CH224A:设计上挂 mux 通道 5,**板 B 实测在主干应答**(0x22/0x23);启动时两处都找。 - -### 10.11 关于 FR-12「模拟电量曲线」的一个说明 - -这条需求的实现方式和它的字面意思不一样,值得在架构层写清楚:**手机的电量计是从 -电池端电压推算 SoC 的**,我们给的电压就是它看到的全部信息。所以「模拟电量曲线」 -= **按锂电放电曲线缓慢改变输出电压**(比如 12 小时从 4.01 V 走到 3.60 V,再跳回去)。 - -两个后果: - -- 它**不需要**任何新硬件,是 `rail_set_mv()` 之上的一个慢速调度器; -- 它与 SW-4(单路电压微调)**共用同一个执行器**,两者必须互斥 —— - 否则运维把某台调到 4.0 V,曲线任务转头又把它拉回 3.6 V。 - -FR-12 是「中(可选)」,建议**首版先不做**,把接口留出来即可。 - -### 10.12 架构层面的待定项 - -| 项 | 状态 | -|---|---| -| ~~上位机链路选型(10.8)~~ | ✅ **2026-09-06 用户拍板:方案 C**,WiFi 常态 + USB CDC 兜底 | -| ~~OTA 升级~~ | ✅ **2026-09-27 C2 完成**:双槽 + 失败回滚,定稿见 §10.8「C2 定稿的 OTA」 | -| WiFi 掉线重连策略 | 指数退避 + 上限;**重连过程不得阻塞 `t_safety`**(10.3),链路任务优先级最低就是为这个 | -| **MQTT broker 与 topic** | 2026-09-27 起由本项目自定(D7,broker 在 home 服务器);topic 约定随 A14 写进 §10.8 | -| 时间同步 | 当前方案是上位机下发墙钟做偏移,不加 RTC。若日志要脱机长期归档需重议 | -| FR-12 电量曲线 | 建议首版不做,只留接口(10.11) | -| `input_suspend` 节点路径 | 与 SW-7 / V-8 是同一件事,首台机上确认后写死 | - ---- - -## 11. 约束与假设 - -### 11.1 硬约束 - -| ID | 约束 | -|---|---| -| **C-1** | 全部为 1S 机型,负载固定为小米8(dipper)主板 | -| **C-2** | 原厂电池保护板**被旁路** ⇒ 失去一层与本板独立的防线,全部安全责任落在 SR-2 / 3 / 5 上 | -| **C-3** | 无电池 ⇒ 失去一个内阻只有几十 mΩ 的缓冲电容。**瞬态阻抗设计(§6)比电压精度重要得多**,是这类板子的主要故障来源 | -| **C-4** | ESP32-S3-WROOM-1 可用 IO 有限(19 根信号已排满),新增功能只能走移位寄存器或 I²C 扩展 | -| **C-5** | USB2517I 要求 D± 走线 ≤ 6 inch(152 mm) ⇒ Hub 只能放在载板通道正中 | -| **C-6** | BM24(尾插)连接器只有 **10 次插拔寿命** ⇒ 装配流程必须减少反复插拔 | -| **C-7** | 小米8 原理图 PDF 是第三方扫描件,**一切引脚 / 机械结论以实测为准** | - -### 11.2 假设(若不成立需重新评估) - -| ID | 假设 | -|---|---| -| **A-1** | 上游 USB 恒为 5 V(只接电脑 / Hub,不接快充头) | -| **A-2** | 手机关机待机电流在 ≈50 µA 量级 | -| **A-3** | 目标 ROM 上存在可写的「关充电」sysfs 节点(SW-7,路径待确认) | -| **A-4** | 手机常态功耗 0.3 ~ 1 A / 路,五路同时 4 A 满载不会发生 | - ---- - -## 12. 验收准则 - -### 12.1 首板上电验收(17 步,**严禁第一次上电就接手机**) - -完整步骤与通过标准见设计文档 **§11**。其中: - -- **第 5 步(人为断开 R2,模拟致命故障)** 与 **第 6 步(负载阶跃 + 阻尼验证)** - 是这块板子的两个**核心验证项,不得跳过** —— 它们分别验证 SR-3 和 PR-12 / C-3。 -- **第 12 步(ESP32 复位安全测试)** 验证 SR-6。 -- **第 14 步(USB 反灌测试)** 验证 SR-10 / SW-8。 - -### 12.2 需求 → 验收方法对照 - -| 需求 | 验收方法 | 通过标准 | -|---|---|---| -| 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 / −300 mV 偏差 | 20 ms 内关路并产生告警记录 | -| SR-6 | 手动复位 ESP32 | 示波器确认五路立即关断、15 路按键无一误触发、EDL 无触发 | -| SR-7 | 固件同时置位 EN 与放电 | 硬件互斥,10 Ω 上无持续功耗 | -| SR-9 | 可调电源把母线升到 13.5 V | 泄放导通并拉回 13.2 V 以下 | -| SR-10 / SW-8 | 不关充电直接接 USB | 电流转负 → 固件 200 ms 内断 VBUS | -| SR-15 | 排线加 4.40 V | **不断开**(断开说明焊到了 `B+`/`B−`) | -| PR-12 | 0→4 A 阶跃(带 20 cm 线 + 电容组) | 压降 <300 mV,**无 6 kHz 振铃** | -| PR-13 | 满载 4 A 跑 30 min | 温升 <40 K | -| PR-14 | 断电重启时序 | 输出 200 ms 内 <0.5 V | -| FR-4 / SW-5 | 逐项试按键 | 短按唤醒 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot 均有效 | -| FR-5 / SW-6 | 下发 EDL 序列 | 主机可见 9008 设备 | -| FR-7 | 五台全接 | `adb devices` 与 `fastboot devices` 均可见 | -| FR-10 / TR-6 | 拔掉一个风扇 | TACH 频率下降约 1/5,产生告警 | -| SW-12 | 断开 WiFi(拔 AP / 关热点),从 USB 下命令 | 命令照常执行;**五路输出不受影响**(10.2 的失联行为) | -| SW-12 | 同一序号的命令从两条链路各发一遍 | **只执行一次**,第二条被判重丢弃并记事件 | -| **总体目标** | 五台长稳 | **24 h 无重启、无告警** | - -### 12.3 装配前必做的实物验证(阻塞项) - -| 项 | 内容 | 出处 | -|---|---|---| -| V-1 | **排线旁路验证**:确认焊在 `P+`/`P−`,4.40 V 不断开 | §13.7 | -| V-2 | **侧键触点定位**:万用表 5 分钟,拍照存档 | §13.3 | -| V-3 | **环路补偿实测扫频**(三个位置已留焊盘) | §9.2 | -| V-4 | **量两个新连接器实物高度**(XT30PW-M / 2510-3A)—— 余量只有 1~3 mm,**别信估计值** | §26.16 | -| V-5 | **两条排线路径试装**(电池 / 尾插穿槽走背面) | §25.16 | -| V-6 | **J7n 镜像位按底层装法复做**;装好后做两点通断复核 | §24.3 / §25.16 | -| V-7 | **风扇线装配顺序**:必须「先插五根风扇线,再合上载板」 | §26.16 | -| V-8 | 确认目标 ROM 上 `input_suspend` 的实际路径 | §15.2 | - -> 完整的待办 / 已关闭清单以设计文档 **§12** 为准,本表只列对需求验收有约束的。 - ---- - -## 13. 已知风险与已接受的取舍 - -| # | 风险 | 处置 | -|---|---|---| -| R1 | 去掉原厂保护板 = 少一层独立防线 | **已接受**。定量核算 eFuse 余量 ≈11 倍;且 eFuse 断开后 2200 µF 仍撑着,手机继续运行不掉电,表现优于保护板断开 | -| R2 | 最坏角落下 eFuse 可能到 4.519 V 才跳,超 1S 上限约 60 mV | **已接受**。走到这一步说明第一层已失效,输出以 6.4 mV/µs 冲过这 70 mV 只需 ≈10 µs,远短于锂电过压损伤时间常数 | -| R3 | 两个同款 USB-C 插错 | **已接受**(用户在 USB-B / USB-C 之间选了 C)。靠丝印 + 颜色 + 外壳标记缓解 | -| R4 | 低边调速导致 PWM 关断期间读不到转速 | **已接受**。若最终选 4 线 PWM 风扇,MOS + 续流管 + 分压可全删 | -| R5 | IF-5 的 100 µF MLCC 在 3.9 V 偏压下只剩 ≈40 µF,中频储能从 200 µF 降到 ≈80 µF | **待复核**。严格照原方案需换真聚合物 | -| R6 | 电感 SPM6530T-4R7M 的 Isat 6.2 A < 原定的 7 A | **已接受**,裕量分析见设计文档「已知遗留」 | -| R7 | BM24 仅 10 次插拔寿命 | 靠装配流程规避(C-6) | -| R8 | 手机主板机械固定方式(是否连中框)未定 | **不阻塞打样** | - ---- - -## 14. 需求 → 设计追溯表(速查) - -| 需求域 | 设计文档章节 | -|---|---| -| 规格 / 输入输出 | §1、§3 | -| 电压范围与本质安全 | §4(含 §4.4 失效态验算) | -| 三层保护链 | §5 | -| 瞬态阻抗与阻尼(C-3) | §6 | -| 断电重启与放电 | §7 | -| MCU 接口 / GPIO / I²C | §8、§26.18 | -| 选型与 BOM | §9 | -| PCB 与叠层 | §10、§25、§26 | -| 验收与调试 | §11、§14 | -| 手机侧接口 | §13 | -| USB 子系统 | §15、§18、§21 | -| 两板架构与机械 | §22、§25 | -| 散热 | §22.6 | -| EDL | §22.4 | -| **软件架构** | **设计文档里没有对应章节 —— 见本文 §10** | - ---- - -## 15. 术语 - -| 术语 | 含义 | -|---|---| -| **直供** | 拆掉电芯与保护板,从电池座直接供给受控电压 | -| **dipper** | 小米8 的代号 | -| **P+ / P−** | 电池保护板的**输出侧**焊点(本项目的接入点) | -| **B+ / B−** | 电芯侧焊点(**接错这里是本项目最隐蔽的装配错误**) | -| **eFuse** | 带可调过压 / 限流 / 缓启动的电子保险丝(TPS25940A) | -| **EDL / 9008** | 高通紧急下载模式 | -| **本质安全** | 靠拓扑与元件取值在物理上限死危险状态,而不是靠软件判断 | -| **下板 / 载板** | 下板 = 电源板;载板(上板)= 承载五块手机主板的板 | +# 五路手机直供电源板 —— 项目需求文档 + +| 项 | 值 | +|---|---| +| 文档版本 | **v1.2**(2026-09-06)—— 上位机链路定稿为**方案 C(WiFi 常态 + USB CDC 兜底)**,回填 §10.8,新增 D5a / SW-12 / IF-19 与两条验收
v1.1 —— 新增 **§10 软件架构设计**,原 §10~§14 顺延为 §11~§15 | +| 项目代号 | PhoneRail | +| 对应设计文档 | `五路直供电源板设计.md` v1.6 及其后续增补(§22 起的两板架构) | +| 状态 | 需求已冻结;硬件处于 PCB 布线阶段(见 `PROGRESS.md`) | + +> **本文档与设计文档的分工** +> 本文只回答 **「要做成什么样、凭什么算做到了」**; +> **「怎么做到」** 一律在 `五路直供电源板设计.md` 里,本文只给条目号交叉引用。 +> 两者冲突时:**电气数值以设计文档为准**(它是逐项算过并对过 datasheet 的), +> **需求的取舍与验收标准以本文为准**。 +> 设计文档 §0.1 的 13 条决策已定稿,本文把它们表述成需求,不重新讨论。 +> **唯一的例外是 §10 软件架构设计** —— 那是「怎么做」,但固件在设计文档里没有落脚点, +> 暂放本文,理由写在该节开头。 + +--- + +## 1. 背景与目标 + +### 1.1 要解决的问题 + +手机农场里成排运行的小米8(dipper)主板,用原装电池供电有三个绕不开的问题: + +1. **电池会老化、会鼓包**,几十台规模下变成持续的维护负担和安全隐患; +2. **手机卡死时没法远程恢复** —— 人必须跑到机架前拔电池 / 长按电源键; +3. **充电管理系统一直在工作**,长期 100% 电量反而触发系统的异常逻辑。 + +### 1.2 项目目标 + +做一块**电池位直供**的电源板:拆掉电芯与保护板,从主板电池座直接灌一个受控的、 +稳定在正常锂电范围内的电压;同时把「断电重启、按键模拟、进 Fastboot / 9008、 +USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU 远程执行。 + +**一句话验收目标**:五台小米8 主板挂在这套板子上连续跑 24 小时无异常重启, +任何一台卡死时都能在**不到机架前**的情况下恢复到正常运行状态。 + +### 1.3 目标用户与使用场景 + +| 用户 | 场景 | +|---|---| +| 农场运维 | 日常挂机;某台无响应时下发一次重启;查某台的电流曲线定位接触不良 | +| 刷机 / 开发 | 远程进 Fastboot 或 9008(EDL) 刷包;有线 adb 抓日志 | +| 本项目自身 | 首板调试、长稳测试、失效注入验证 | + +--- + +## 2. 范围 + +### 2.1 交付范围(In Scope) + +| # | 交付物 | +|---|---| +| D1 | **下板(电源板)**:12V 输入 → 五路 4 A 直供 + eFuse + 遥测 + ESP32-S3 控制 | +| D2 | **上板(载板)**:五块主板的机械承载 + 板载 USB Hub + VBUS 开关 + 侧键顶针 + EDL 口 | +| D3 | 两板对插的板对板接口(两条 2×20 排针,共 80 针) | +| D4 | 手机侧线束与离板电容组(电池排线改造、尾插 USB 线、EDL 引线) | +| D5 | ESP32-S3 固件(遥测、两级重启、按键 / EDL 模拟、VBUS 通断、风扇闭环、告警) | +| D5a | **双链路命令接口**(2026-09-06 用户拍板):**WiFi 为常态、USB CDC 为兜底**,两条链路共用同一套命令语义。本项目交付板侧协议与实现,见 §10.8;上位机见 D7 | +| D7 | **机架管理前后端**(2026-09-27 用户拍板,取代原 N7):**架构 A 纯中心化** —— home 服务器(10.5.5.13)上跑 mosquitto + Python(FastAPI)后端 + 时序库 + Vue3 前端,走统一门户;板子经 WiFi MQTT 直连。首版功能:实时总览、手动操作、自动崩溃恢复(**服务器侧 adb over WiFi 探活 + 电流曲线确认**,先软后硬)、历史曲线与事件。**不做机架旁 agent,USB CDC 只用于上架配网与调试**(代价:WiFi 故障时没有远程救援通道,见 §10.8)。开发顺序:先补固件 A12/A13/A14,再写前后端。**2026-09-27 已上线**:代码 `rack/`(说明见 `rack/README.md`),服务 `phonerail-rack`(127.0.0.1:7764),门户 key `rack` | +| D6 | 完整原理图(11 页)+ 两块 PCB + BOM + 首板调试与验收记录 | + +### 2.2 明确不做(Out of Scope) + +| # | 不做 | 理由 | +|---|---|---| +| N1 | 电池充放电管理、模拟真实电池阻抗 | 直供不需要,电芯已移除 | +| N2 | 支持 1S 以外的机型 / 非小米8 主板 | 全部机械与引脚定义都是 dipper 专用(§13) | +| N3 | 自制 USB-C 尾插转接板 | 决策 10:装原厂尾插小板,口温 NTC / CC / ESD / 天线馈点全在上面 | +| N4 | USB PD / BC1.2 协商,接快充充电器 | 决策 11:上游只接电脑 / Hub,VBUS 恒 5 V | +| N5 | 把过流保护做进 MCU 实时环路 | 那是 eFuse 的职责(SR-3) | +| N6 | 五路 Buck 错相同步 | §8.4 已否决:器件离散度远大于想制造的相位差 | +| ~~N7~~ | ~~上位机管理软件 / Web 界面~~ | **2026-09-27 撤销**,改为交付范围 D7 | + +--- + +## 3. 系统组成(需求视角) + +```diagram=system +12V/10A 适配器 + │ + ┌────┴─────────────── 下板(电源 + 大脑)────────────────────┐ + │ 输入保护 → 五路独立 Buck → eFuse → 分流检测 → 输出 │ + │ ESP32-S3 + DAC ×2 + INA226 ×6 + 74HC595 ×3 + PCA9555 │ + │ 侧键驱动管 ×15 · 风扇驱动 · 辅助 3V3 / 5V │ + └────┬──────────── 2×20 排针 ×2(无高速信号)─────────────────┘ + ┌────┴─────────────── 上板(载板)────────────────────────────┐ + │ 5 × 小米8 主板(NPTH 定位孔 + 尼龙立柱) │ + │ USB2517I 7 口 Hub + 上游 USB-C + VBUS 负载开关 ×5 │ + │ 侧键顶针 ×15 · EDL 模拟开关 ×5 · P+/P− 焊盘 + 电容组 ×5 │ + └──────────────────────────────────────────────────────────────┘ +``` + +两板之间保留 **13 mm 缝**,既是装配空间也是散热风道。 + +--- + +## 4. 功能需求(FR) + +| ID | 需求 | 优先级 | 设计出处 | +|---|---|---|---| +| **FR-1** | 同时为 **5 台**小米8 主板供电,各路**电气独立**(独立回流地,不在手机端共地) | 必须 | §1 / §6.4 | +| **FR-2** | 每路输出电压可**独立微调**,调节在正常锂电范围内进行,步进优于 1 mV | 必须 | §4 | +| **FR-3** | 每路可**独立硬断电并主动放电**,实现真正意义上的「拔电池」 | 必须 | §7 | +| **FR-4** | 每台手机可模拟 **3 个侧键**(PWR / VOL+ / VOL−),支持组合键 | 必须 | §13.3 | +| **FR-5** | 每台手机可远程进入 **EDL(9008)** 模式 | 必须 | §22.4 | +| **FR-6** | 每台手机的 **USB VBUS 可独立通断**,默认断开 | 必须 | §15.1 | +| **FR-7** | 对外只用**一根 USB 线**,五路下行由板载 Hub 分 | 必须 | §15.1 | +| **FR-8** | 每路可实时读取**输出电压与电流**;整板可读取**输入电压与电流** | 必须 | §8.2 | +| **FR-9** | 上报每路 eFuse 故障、电源好(PG)等状态 | 必须 | §8.2 | +| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」 | 必须 | §22.6 | +| **FR-11** | 记录过流 / 故障事件(哪一路、何时、多大电流) | 高 | §8.3 | +| **FR-12** | 模拟电量曲线,避免系统因「电量永远 100%」触发异常逻辑 | 中(可选) | §8.3 | +| **FR-13** | 板级温度监控,超阈值先降流再关路 | 高 | §8.3 | +| **FR-14** | 整板功率预算,超过适配器能力时按优先级降载 | 中 | §8.3 | +| **FR-15** | 调试口不依赖外部转串口芯片,插上 USB-C 即可下载与看日志 | 必须 | §8.1 | + +### 4.1 崩溃恢复流程(FR-3 / FR-4 的组合,本项目的核心价值) + +固件必须实现**两级重启**,且**先软后硬**: + +| 级 | 动作 | 触发条件 | 要求 | +|---|---|---|---| +| 一级(软) | 长按电源键 ≈10 s | `adb` 探活连续失败 N 次,或电流曲线卡死 | 优先使用,对闪存和文件系统友好 | +| 二级(硬) | `EFUSE_EN` ↓ → 放电 → 等 3~5 s → `EFUSE_EN` ↑ | 一级无效 | 输出必须真的掉到 <0.5 V,不能靠自然放电 | + +> **为什么二级必须有主动放电**:手机侧 2200 µF 配 ≈50 µA 关机待机电流,自然放电到安全 +> 电压要 **154 秒**,「断电 10 秒」根本不触发重启。见 §7.1。 + +--- + +## 5. 电气与性能需求(PR) + +| ID | 参数 | 要求 | 出处 | +|---|---|---|---| +| **PR-1** | 输入电压 | 12 V ±10%,成品适配器 | §1 | +| **PR-2** | 输入电流 | ≤ 8 A(含风扇 ≈8.3 A),建议配 12 V / 10 A(120 W) | §1 / §22.6 | +| **PR-3** | 输出路数 | 5,彼此独立不共地 | §1 | +| **PR-4** | 输出电压范围 | **3.52 ~ 4.01 V** 连续可调 | §4.3 | +| **PR-5** | **硬件电压上限** | 标称 **4.01 V**,最坏容差 **4.12 V**,**软件无法越过** | §4.4 | +| **PR-6** | 电压设定分辨率 | 12 位 DAC 微调,步进 ≈0.12 mV | §4.5 | +| **PR-7** | 输出电流 | 4 A / 路(峰值);手机常态 0.3 ~ 1 A | §1 | +| **PR-8** | 输出纹波 | < 20 mVpp(板端测试点) | §1 | +| **PR-9** | 开关频率 | 480 kHz,五路统一,不错相 | §1 / §8.4 | +| **PR-10** | 整板功率 | 峰值 80 W(不会发生);常态 10 ~ 25 W | §1 | +| **PR-11** | 电流测量分辨率 | ≥ 0.5 mA(5 mΩ 分流 + Kelvin 四线) | §8.2 | +| **PR-12** | 负载阶跃响应 | 0→4 A 阶跃(带 20 cm 线 + 电容组)压降 < 300 mV,**无 6 kHz 振铃** | §6.2 | +| **PR-13** | 满载温升 | 4 A 连续 30 min,电感 / IC 温升 < 40 K | §11 | +| **PR-14** | 断电放电时间 | `EFUSE_EN` ↓ 后 **200 ms 内**输出 < 0.5 V | §7.2 | +| **PR-15** | 上电斜坡 | 缓启动 0.36 V/ms,涌流 ≤ 0.8 A;`EN`↑ 到稳定 ≈17 ms | §5 | + +### 5.1 输出上限为什么定在 4.01 V(这条需求的来源) + +去掉原厂保护板后,输出上限**由 1S 锂电端子的绝对上限 4.45 V 反推**,而不是由保护板决定。 +4.01 V 这个数字不是「够用就行」,是为了**给 eFuse 的过压跳闸窗口让开 86 mV** —— +若按早期的 4.22 V,第一层的最坏输出(4.34 V)会和 eFuse 的最坏跳闸下沿(4.206 V)重叠, +坏角落下 DAC 失效会**误跳保护**。详见 §5 的容差窗口分析。 + +**因此 PR-4 / PR-5 是安全需求的一部分,不得为了「多给一点电压」而放宽。** + +--- + +## 6. 安全需求(SR)—— 本项目最高优先级 + +> **必须记录的失效模式**:直供电最致命的失效**不是固件写错电压值**,而是 +> **反馈分压的下电阻开路 → FB = 0 V → Buck 满占空比 → 输出直接跳到 12 V**。 +> **软件对此完全无能为力。** 全部安全需求都要围绕这一条来读。 + +| ID | 需求 | 出处 | +|---|---|---| +| **SR-1** | **三层保护彼此独立**:任何一层单独失效都不得导致手机损坏 | §5 | +| **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 或低于设定 >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 | +| **SR-9** | **12 V 母线泄放**:母线 >13.2 V 时导通泄放,防 USB 反灌或突卸能量抬高母线 | §15.2 | +| **SR-10** | **USB 反灌三条对策全做,一条都不能省**:① 软件关充电 ② VBUS 负载开关 ③ 母线泄放 | 决策 12 / §13.5 | +| **SR-11** | 上游 USB 不做 PD 协商(CC 双 5.1 kΩ 下拉,纯 UFP),杜绝 VBUS 被抬到 9 / 12 V | §15.1 | +| **SR-12** | 五台手机的地**不得**在载板上并成一根:定位孔做 **NPTH**,立柱用**尼龙** | §25.2 | +| **SR-13** | 固件**不得**提供任何能让输出超过 4.2 V 的接口 | §8.3 | +| **SR-14** | 任一路输出**不得**给 ESP32 供电(用独立的 12 V→3.3 V 小 Buck) | §8.3 | +| **SR-15** | 电源**必须**接原厂排线的 `P+`/`P−`(保护板输出侧),**不是** `B+`/`B−` | 决策 6 / §13.5 | + +> ⚠️ **SR-15 是最容易在装配环节做错、且常温下完全测不出来的一条。** +> 焊在 `B+`/`B−` 时一切表现正常,只有过压时才暴露 —— 那时保护板仍串在通路里。 +> 判别与验证步骤见 §13.5 / §13.7,已列为打样前阻塞项(V-1)。 + +> ⚠️ **`J600` 的 `GND3`/`GND4` 固定脚实际接的是 VBATT,`J1401` 的 `GND2` 是 USB_VBUS。** +> 按名字当地用会直接短路电池。 + +--- + +## 7. 接口需求(IF) + +> **编号只增不改**:新增接口一律取下一个空号,位置按逻辑归类 —— +> 所以表内序号不一定连续(如 IF-19 排在 IF-14 后面)。ID 是稳定的引用标识, +> 不是目录序号;改动已有编号会让所有交叉引用与验收记录失效。 + +### 7.1 手机侧接口(每台一套) + +| ID | 接口 | 要求 | 出处 | +|---|---|---|---| +| **IF-1** | 供电 | 原装 BM3E 电池排线的 `P+`/`P−` 焊点;保留 BTB 公座 / NTC / ID,**旁路保护板**;电芯移除 | §13.2 / §13.5 | +| **IF-2** | 侧键 | J601(VOL+) / J602(VOL−) / J603(PWR) / J604(公共地),**对地短接**方式,绝不从外部灌电压 | §13.3 | +| **IF-3** | EDL | 短接主板上的 TP1204 ↔ TP1205(1.8 V / 1 mA),用**模拟开关**,不用普通 N-MOS | §22.4 | +| **IF-4** | USB | 装**原厂尾插小板**,本板只接 VBUS / D± / GND 四根 | 决策 10 / §15.1 | +| **IF-5** | 离板电容组 | 每路 2200 µF **普通铝电解**(靠 ESR 阻尼)+ 100 µF ×2 + 22 µF ×2 + 100 nF,**必须装在排线端,不在电源板上** | §6.3 | +| **IF-6** | 线缆 | ≤ 20 cm、18 AWG 或更粗、正负双绞或紧贴平行;板端用 Micro-Fit 3.0 / XT30,**不用杜邦线** | §6.4 | + +> ⚠️ **IF-5 的 2200 µF 千万不能买成聚合物电容。** 整个阻尼设计靠的就是普通铝电解那 +> 几十 mΩ 的 ESR;换成 12 mΩ 的聚合物,Q 从 0.19 跳回 4.5,线缆 LC 会在 6.2 kHz 上振铃。 + +> ⚠️ **IF-5 的大电容必须在排线端。** 放在电源板上等于隔着 300 nH 的线缆电感,基本无效。 + +### 7.2 板对板接口 + +| ID | 需求 | 出处 | +|---|---|---| +| **IF-7** | 两条 2×20 排针(共 80 针),分列板子两端 | §22.2 / §25.10 | +| **IF-8** | **一根高速差分都不穿连接器** —— Hub、上游 USB-C、VBUS 开关全部上移到载板 | §22.1 | +| **IF-9** | 排针只走电源与低速控制;每路 `P+`/`P−` 独立分配针位,保证独立回流(FR-1) | §22.2 | +| **IF-10** | 两板间净空 13 mm;载板底面元件与下板高元件的高度预算必须逐点核过 | §26.2 | + +### 7.3 对外接口 + +| ID | 接口 | 要求 | +|---|---|---| +| **IF-11** | 电源输入 | DC 12 V 插座(XT30 级),配 10 A 适配器 | +| **IF-12** | 上游 USB | 载板 USB-C(UFP),一根线接电脑 | +| **IF-13** | 调试 USB | 下板 USB-C,ESP32-S3 原生 USB-Serial-JTAG;**VBUS 故意不接**(物理上杜绝反灌 3V3)。**同一个口兼作 SW-12 的兜底命令链路**(原生 USB-Serial-JTAG 本身就是 CDC-ACM,不影响 esptool 下载),分帧要求见 §10.8 | +| **IF-14** | 风扇 | 5 × KF2510-3A(+12 V / 回流 / TACH) | +| **IF-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时先用 IF-13 的 USB 链路配网 | + +> ⚠️ **IF-12 与 IF-13 是两个长得一样的 USB-C,插错的现象是「adb 整排不见」**, +> 很难联想到是插错口。**丝印必须写清楚**,建议用不同颜色座子或在外壳上做标记。 + +### 7.4 内部数字接口 + +| ID | 需求 | +|---|---| +| **IF-15** | I²C 单条总线挂 10 个器件:INA226 ×6(0x40/41/44/45/42/43)、DAC63204 ×2(0x4A / 0x4B)、PCA9555(0x20)、LM75BD(0x48),地址互不冲突 | +| **IF-16** | ⚠️ **INA226 的通道号 ≠ 地址低位**(为布线让路重排过),固件必须**查表**,不能算 | +| **IF-17** | 74HC595 ×3 级联成 **24 位**移位链:15 路侧键 + `HUB_RST` + 5 路 `EDL_EN`;**先移入的字节落在 U33 上** | +| **IF-18** | `EFUSE_EN` ×5 与 `VBUS_EN` ×5 **必须 GPIO 直连**,不得经移位寄存器 —— 安全关键,故障瞬间要能立刻关断 | + +--- + +## 8. 机械与热需求(MR / TR) + +| ID | 需求 | 出处 | +|---|---|---| +| **MR-1** | 载板 220 × 135 mm,四角 R5;五块主板 **3+2 双排面对面**,第二排居中 | §25.1 | +| **MR-2** | 下板 160 × 125 mm,装配时相对载板原点偏移 (30, 5) | §25.2 | +| **MR-3** | 每台手机 7 个定位孔,全部 **NPTH Φ2.5 + 尼龙立柱**(见 SR-12) | §25.2 | +| **MR-4** | 侧键用顶针接触,顶针焊盘必须避免把相邻触点短路 | §25.3 | +| **MR-5** | 排线穿槽:电池排线 8×38 → 槽 10×3.0;尾插排线 18×55 → 槽 20×3.0(在 J1401 正下方) | §25.16 | +| **TR-1** | 总热负荷 ≈25 W(5×SDM845 满载 10~15 W + 下板功率级 ≈9 W) | §22.6 | +| **TR-2** | 强制风冷 **4 进 1 出 = 机箱正压**(灰尘往外走);两板间 13 mm 缝作天然风道 | §22.6 | +| **TR-3** | 所需风量仅 ≈2.9 CFM,**风量不是瓶颈,风道与正压才是设计要点** | §22.6 | +| **TR-4** | 风扇 5 × 12 V ≈0.70 A;低边 N-MOS + PWM,漏极**必须并续流二极管** | §22.6 | +| **TR-5** | 风扇闭环的测温点**放载板**(测的是手机),不能用下板功率级的两路 NTC 做反馈 | §22.6 | +| **TR-6** | TACH 必须分压后再进 GPIO;⚠️ 低边调速下 **PWM 关断期间读不到转速**,固件需在导通窗口采样 | §22.6 | + +--- + +## 9. 固件需求(SW) + +> 本节只列「固件要做到什么」。**怎么组织成一份固件见 §10 软件架构设计。** + +### 9.1 必做 + +| ID | 需求 | +|---|---| +| **SW-1** | 崩溃检测 + 两级自动重启(§4.1)—— **整块板子最核心的价值** | +| **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 唤醒 / 长按 5 s 开机(小米8 实测 2 s 不够,2026-09-28)/ 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot / VOL++PWR 进 Recovery。硬重启末尾的开机键**按完看电流**:松键后 3~5 s 内 < 100 mA 就重按,最多 3 次,仍不开记事件 19「开机重按」并保持供电(固件 0.1.2,起因是电源键弹片接触不良) | +| **SW-6** | EDL 时序**固定为** `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0`(先短接再插 USB) | +| **SW-7** | 开机脚本执行「关充电」(写 `input_suspend` 类节点),路径需在首台机上确认后写死 | +| **SW-8** | USB 反灌保护:检测到该路电流转负或母线上升,**200 ms 内**断开该路 VBUS | +| **SW-9** | 风扇 PWM 闭环(以载板 LM75BD 为反馈)+ TACH 停转告警 | +| **SW-10** | 整板功率预算:读输入侧 INA226,超适配器能力时按优先级降载 | +| **SW-11** | 上电默认全关:五路输出关、VBUS 断、按键不按、EDL 不触发 | +| **SW-12** | **双链路命令接口**:WiFi 为常态、USB CDC 为兜底,两条同时在线、共用同一套命令语义与同一个命令处理器;命令带全局序号做幂等判重。链路失联**不触发任何动作**。架构见 §10.8 | + +### 9.2 明确禁止 + +| ID | 禁止事项 | 理由 | +|---|---|---| +| **SW-N1** | 让 ESP32 参与过流保护的实时环路 | 那是 eFuse 的活,MCU 不够快也不够可靠 | +| **SW-N2** | 提供任何能让输出超过 4.2 V 的接口 | SR-13 | +| **SW-N3** | 烧写 `DIS_USB_JTAG` eFuse | 烧了之后引脚 JTAG 和 USB 两边都进不去,板上没有 JTAG 排针 | +| **SW-N4** | 把 INA226 通道号当地址算 | IF-16 | + +--- + +## 10. 软件架构设计 + +> **这一节为什么在需求文档里**:本文原则上只讲「做成什么样」,但**固件在设计文档里没有 +> 落脚点** —— 设计文档 §8.3 只有一张按价值排序的功能清单,没有结构。在补出一份独立的 +> 软件设计文档之前,架构先放这里,与 §9 的固件需求贴在一起看。 +> 硬件的「怎么做」仍然一律在设计文档。 + +### 10.1 分层与职责边界 + +```diagram=layers +┌─ 农场管理系统(既有,本项目不交付 —— N7)────────────────────────┐ +│ adb 探活 / 业务调度 / 崩溃判定 │ +└───────┬────────────────────────────────┬──────────────────────────┘ + │ adb over WiFi(业务面) │ 控制面(10.8,⚠️ 链路待定) + ▼ ▼ + 5 台小米8 主板 ┌─────────────────────┐ + ▲ │ PhoneRail 控制器 │ + │ 供电 / 侧键 / EDL / VBUS │ ESP32-S3 固件 │ + └──────────────────────────┤ 遥测 · 执行 · 记录 │ + └──────────┬──────────┘ + │ 只能建议,不能否决 + ┌───────────────▼───────────────┐ + │ 硬实时层:eFuse OVP / 限流 │ + │ 放电 MOS 反相联锁 / 上下拉 │ + │ —— 不含任何软件(SR-3、SR-7) │ + └───────────────────────────────┘ +``` + +**一条边界决定了下面所有取舍**:固件是**遥测 + 执行器 + 记录仪**,不是保护装置 +(SR-5、SW-N1)。凡是「必须及时」的事都已经在硬件里了;固件里没有任何一条路径 +是「不跑到就会烧手机」的。因此: + +- 任务优先级不需要为保护让路,只需要保证遥测不被执行器序列饿死; +- 看门狗复位是**可接受的失效**(复位即全关,SR-6),不需要为了「保持输出」而弱化看门狗; + (计划内重启不断电是另一回事:那是固件自己选的时机、自己留的标记,§10.9「重启不断电」。) +- 固件可以随时被重启、被降级运行,硬件安全性不变。 + +### 10.2 架构的第一个判断:失联 ≠ 断电 + +| 状态 | 输出该怎么办 | 为什么 | +|---|---|---| +| ESP32 复位 / 上电中 | **全关**(硬件下拉保证,SR-6) | 状态未知,且此刻手机本来就没在跑 | +| ESP32 **计划内**重启(OTA / 回滚) | **保持**(EN 锁住,新固件接管) | 重启前的状态是已知的;为换一次固件让五台手机全掉电不值得 | +| 与上位机失联 | **保持供电不动** | 五台手机正在跑业务;因为控制链路断了就把它们全断电,是把一次网络故障放大成一次全场停机 | +| 本地判据触发(SR-5 / SW-8 / 过温) | **关对应那一路** | 已知的危险,且范围可控 | + +⚠️ 这三行看起来像常识,但它们互相矛盾过一次:**「安全默认值 = 关」只对未知状态成立**。 +失联是已知状态,此时最安全的动作是「什么都不做」。固件里不要写「心跳超时就关输出」。 + +**推论**:失联期间固件必须能独立运行全部本地职责 —— 巡检、风扇闭环、事件记录、 +按队列执行未完成的序列。上位机回来之后靠事件日志补齐时间线(10.7)。 + +> **A10 已落地**(2026-09-07,`firmware/main/t_safety.{h,c}`,判据全在 +> `t_safety_step()` 里、放锁之后、直接写引脚)。本表第三行「本地判据触发 → 关对应 +> 那一路」的两条判据是 SR-5 与 SW-8,实现时定下来的七条: +> +> 1. **「持续 20 ms」数的是圈,不是墙钟毫秒。** 巡检的采样时刻带亚毫秒残差 +> (一圈自己占 1.56 ms 总线,而「睡到下一拍」按整毫秒算),实测相邻两次判据的 +> 时刻是 **21 / 19 ms 交替**。按 `>= 20 ms` 判,19 ms 那半数情况要拖到第三圈 +> ——**40 ms**,超了 §12.1 的验收,而且是间歇性的:同一个故障注两次,一次过 +> 一次不过。改成「连着两圈都越界」,在 20 ms 的周期下就是 20 ms。 +> (SR-5 说「持续 20 ms 才算」、§12.1 说「20 ms 内关路」,字面互相顶着, +> 只有「第一次看见的下一圈就断」同时满足两条。这 20 ms 是从**第一次看见**算的, +> 对外部世界的最坏延迟是 2 个周期 —— 不要紧,SR-5 原文就写着它 +> 「仅承担记录与告警,不承担实时保护」。) +> 2. **反灌不设确认窗口,一圈看见就断**(设计文档 §15.2 对策 2 原话「立即」)。 +> 与过压那 20 ms 的不对称是有意的:误断 VBUS 只丢一次 USB 数据,手机还由直供 +> 电着;漏判则把能量泵回 12V 母线**连累另外四台**。最坏 2 圈 = 40 ms, +> SW-8 给的预算是 200 ms。 +> 3. **判据只在「这一路该有电」且「这一圈的值是新鲜的」时武装**,另加一个 +> `EFUSE_EN↑` 之后 **60 ms** 的缓启动窗口(tON 5.1 + 斜坡 11.5 ≈ 17 ms 的 3 倍)。 +> 少了第一条,每圈都会「发现」关着的五路各偏低 3900 mV;少了第三条, +> **每次上电都会自己把自己关掉**;第二条就是 §10.10 那行「盲跑 → 保持供电, +> 禁用该路的 SR-5 / SW-8 判据」。 +> 4. **比的是该路 DAC 的设定值**(`rail_setpoint_mv()` 的影子,不碰总线), +> 不是 3900 这个常数 —— SW-4 允许运维单路微调,拿常数比会把一台调到 4.0 V 的 +> 机器当成过压关掉。 +> 5. **SR-5 关路 = 先断 VBUS 再关 eFuse**(`rail_all_off()` 的同一条理由:中间不留 +> 「手机只靠 USB 供着电」那个既不是开也不是关的窗口);**SW-8 只断 VBUS, +> 不碰 eFuse** —— 反灌是 USB 那一侧的事,把直供也关掉等于「因为充电器有问题 +> 就把手机关机」。过压且 VBUS 通着时两条都成立,于是**先撤能量来源, +> 20 ms 后再决定要不要关这一路**。 +> 6. **断电之前先 `t_actuator_abort()`。** 不 abort 的现象最阴:硬重启的 `HR_OFF` +> 会在 4 s 后自己把 `EFUSE_EN` 抬回去,「关掉的那一路」几秒后又带电了, +> 而日志里只有一条「已关路」。断 VBUS 时只打断 **EDL**(三个序列里只有它写 +> `VBUS_EN`)—— 无条件打断会把一次正在进行的救砖重启也顺手停掉。 +> 7. **动作在前,记录在后**,事件出口是一个回调(`t_safety_set_event_sink()`), +> A11 在那里接环形缓冲。记录用的 `safety_event_t` 已经按 §10.7 的「单条」 +> 形状定好(时间戳 + 路号 + 类型 + 当时的 V/I,16 B)。 +> +> 29 条 ctest 用例,25 条「一定会被写出来的错」逐个注进去跑过,**24 条变红**; +> 剩下那一条(关路之后把 `dev_n` 清零)在 sim 里造不出「写引脚失败」,抓不到 —— +> 所以那一行干脆没写,理由记在代码里。 + +### 10.3 任务划分(FreeRTOS / ESP-IDF) + +| 任务 | 周期 | 优先级 | 职责 | 失败后果 | +|---|---|---|---|---| +| `t_safety` | **20 ms** | 最高 | 读 6 片 INA226(V+I)与 PCA9555 输入;执行 SW-2 / SW-8 判据;越界直接操作 `EFUSE_EN` / `VBUS_EN`(**GPIO 直连,不经任何队列或抽象层**) | 告警延迟;保护本身不受影响(eFuse 是硬件) | +| `t_thermal` | 1 s | 中 | 两路 NTC(下板功率级)+ 载板 LM75BD(手机侧)→ 风扇 PWM 闭环;过温先降流后关路(FR-13、TR-5) | 风扇维持上一个占空比 | +| `t_actuator` | 事件驱动 | 中 | 每路一个状态机:断电重启 / 按键序列 / EDL 序列(10.6) | 序列停在中间 —— 必须有超时兜底 | +| `t_link` | 事件驱动 | 低 | 上位机命令解析与遥测上报(10.8) | 见 10.2:不影响本地职责 | +| `t_log` | 空闲 | 最低 | 环形缓冲落盘(10.7) | 丢日志,不丢功能 | + +**共享资源只有两个**,各配一把互斥锁: + +1. **I²C 总线** —— 10 个器件在同一条上(IF-15),其中 LM75BD 还在排针另一侧的载板上。 +2. **74HC595 的 24 位链** —— 全板共享,见 10.6 的影子寄存器。 + +**I²C 带宽核算**(400 kHz,`t_safety` 是唯一的高频用户): + +``` +一次 INA226 寄存器读 ≈ 46 个位周期 ≈ 115 µs +6 片 × 2 个寄存器(总线电压 + 分流电压) = 12 次 ≈ 1.4 ms +PCA9555 读两个输入端口 ≈ 0.12 ms + 合计 ≈ 1.5 ms / 20 ms = 约 8 % 占用 +``` + +余量充足,`t_thermal` 和偶发的 DAC 写完全挤得下。**首板上电时用逻辑分析仪实测复核一次** +——这个数是按理想时序算的,没算时钟延展和重试。 + +> **A8 已落地**(2026-09-07,`firmware/main/{tasks,sched,t_safety}.c` + +> `components/phonerail_hal/{include/hal_lock.h,hal_i2c_acct.c}`)。五条实现约定: +> +> 1. **五个任务是一张 const 表**(`tasks.c`),周期与优先级写在一起,开机 `task_table_ok()` +> 自检,不过就 abort。查的是**相对顺序**:safety 唯一最高、log 唯一最低、 +> thermal 与 actuator 同级、link 夹在中间;外加「巡检周期必须长于 INA226 的 +> 一帧转换 8.8 ms」,否则同一帧被读两次,现象是「电流特别稳」。 +> 2. **周期是间隔,不是延时。** `sched_periodic_*()` 记的是下一次该醒的**绝对时刻** +> (回绕安全,一律用 `int32_t` 差判先后)。写成「干完睡 20 ms」的话, +> 实际周期是 21.6 ms 且随总线忙闲抖动。过载时**跳过错过的整周期、保住相位**, +> 不补跑 —— 补跑的现象是「卡一下之后连着打满总线」。 +> 3. **两把锁不许嵌套持有。** 595 是纯 GPIO、I²C 是纯总线,两者没有交集, +> 所以「拿着 A 去拿 B」永远是写错了,`hal_lock_take()` 直接判 `HAL_ERR_STATE` +> ——与其定一套加锁顺序再指望人人记得,不如让它不可能发生。ESP 侧用的是 +> **mutex 不是 binary semaphore**(要优先级继承:t_link 拿着总线时 +> t_safety 不能被中优先级任务无限期挡住)。 +> 4. **巡检不死等锁**:等 5 ms(周期的 1/4)拿不到就跳过这一圈并计数。 +> 死等的现象是最高优先级的任务整个停摆,而且没有任何人会发现。 +> 同样地,**一个器件读失败不中断这一圈**,失败那一路保留上一圈的值并把 `ok` 标 false +> ——清零的话 A10 的过压判据会看到一个「正常」的 0 V。 +> 5. **那 8 % 是算出来的,不是抄的。** `hal_i2c_acct` 在 HAL 里按线上真实字节数记账 +> (两个目标同一份代码),`t_safety_budget_us()` 按事务形状静态预算,两者必须相等。 +> 实测:一次寄存器读 **48** 个位周期(本节手算的 46 没数 STOP 和一个重复 START), +> 一圈 13 次事务 = **1560 µs / 20 ms = 7.8 %**,与本节的「约 1.5 ms / 约 8 %」相符。 +> 多读一次寄存器就会让静态预算与实测对不上,测试当场变红。 +> 首板仍要按本节末尾那句用逻辑分析仪复核一次(这个数不含时钟延展与重试)。 + +### 10.4 五路一张表:禁止用通道号算任何东西 + +这是**本项目最容易写出「看起来对、实际错一路」的地方**。三组编号都不是逻辑顺序: + +- **INA226 地址**按布线难度重排过(IF-16,设计文档 §8.2); +- **74HC595 的位序**按 PCB 几何排(设计文档 §17.2 / §22.4); +- **GPIO 编号**按出线方向倒推(设计文档 §26.18)。 + +所以固件里**只允许有一张静态配置表**,全部五路循环都索引它: + +```c +typedef struct { + uint8_t ina_addr; /* 0x40 0x41 0x44 0x45 0x42 —— 查表,不能算 */ + uint8_t dac_chip; /* 0x4A: CH1-3 0x4B: CH4-5 */ + uint8_t dac_ch; /* U6.OUT0/1/2 U7.OUT0/1 */ + gpio_num_t efuse_en; /* GPIO 直连 */ + gpio_num_t vbus_en; /* GPIO 直连 */ + uint8_t key_bit[3]; /* PWR / VUP / VDN 在 24 位链里的位号 */ + uint8_t edl_bit; /* U33 上的位号 */ + uint8_t flt_bit; /* PCA9555 IO0_0..4 */ + uint8_t pg_bit; /* PCA9555 IO1_0..4 */ +} rail_cfg_t; +``` + +> ⚠️ **`ina_addr = 0x40 + ch` 这行代码一定会被写出来,而且只有 CH3/CH4/CH5 是错的。** +> 实际地址是 `0x40 / 0x41 / 0x44 / 0x45 / 0x42`。查表,不要算。 + +> **A3 已落地**:表在 `firmware/components/drv/include/ina226.h`,取值只走 +> `ina226_rail_addr(rail)`(rail 是 1~5,越界 abort), +> `test_channel_index_is_not_the_address_low_bits` 专门拦「改回算式」这一手。 +> 同处还记了两条 INA226 的实现约定:**寄存器指针不自增**(每次读前重写指针, +> sim 会自增、真件不会,靠自增在宿主机上测不出来);**Calibration 丢了不报错、 +> 只让 Current/Power 恒读 0**,所以巡检要定期 `ina226_check()`。 + +同理,**PCA9555 的方向寄存器只能配成全输入**:备用脚 Port0[7] / Port1[7:5] 在硬件上 +直接接地,配成输出并写 1 就是对地短路(设计文档 §17.2 已在图纸上注明)。 + +> **A6 已落地**:驱动在 `firmware/components/drv/include/pca9555.h`, +> **根本不提供「把某个脚配成输出」的接口**(和 sr595.h 不提供「抬 OE#」同一条规矩)。 +> 方向字一律过 `pca9555_dir_safe()`,不安全直接 abort。 +> 同处还记了四条这一片的实现约定: +> ① ⚠️ **Output Port 寄存器的复位值是 0xFF** —— 所以把接地脚配成输出的那一刻它就 +> 输出高电平,中间没有「先写个 0 再说」的余地,这比「配错方向」本身更急; +> ② **极性反转寄存器必须无条件清零**,不能赌复位值:看门狗只复位 ESP32, +> PCA9555 不掉电,上一条命的残留值原样留着,而写错的现象是「五路同时报故障」 +> 或者更糟的「五路故障全都看不见」,I²C 一切正常; +> ③ **三种极性**(FLT# 低有效 / PGOOD 高有效 / INA_ALERT 低有效)各自只在 +> `pca9555_rail_fault()` 等四个函数里出现一次,上层不许自己去 & 位; +> ④ **INT# 是加速路径不是主路径**:它要读走输入端口才释放,漏读一次就再也等不到 +> 下一个沿,所以 `t_safety` 必须无条件轮询(§10.3 的 20 ms 就是按这个排的)。 +> +> ⚠️ **FLT 与 PG 两张位表这次确实是顺排的**(都是 `bit = rail - 1`), +> 和 INA226 地址、595 位号那两张表不一样。所以这里最容易写出来的错不是「算式」, +> 而是**把 Port0 和 Port1 搞反** —— 两个端口的 bit0~4 长得一模一样,搞反之后 +> 「有故障」会被读成「电源良好」,方向正好相反,而且**静息态两个端口读回来都是 +> 全 1,单看一帧完全看不出来**。`test_one_rail_at_a_time` 专门拦这一手。 + +> **A7 已落地**(2026-09-07):那张 `rail_cfg_t` 表在 `firmware/main/rail_cfg.h` / +> `rail_cfg.c`,取值只走 `rail_cfg(rail)`(1~5,越界 abort)。六列的样子: +> +> | CH | INA226 | DAC | 按键位 PWR/VUP/VDN | EDL 位 | EFUSE_EN | VBUS_EN | +> |---|---|---|---|---|---|---| +> | 1 | 0x40 | U6.OUT2 | 1 / 2 / 3 | 16 | IO39 | IO40 | +> | 2 | 0x41 | U6.OUT0 | 19 / 17 / 18 | 7 | **IO48** | IO10 | +> | 3 | 0x44 | **U7**.OUT0 | 22 / 21 / 20 | 6 | IO6 | IO11 | +> | 4 | 0x45 | U6.OUT1 | 9 / 10 / 11 | 0 | IO41 | IO42 | +> | 5 | 0x42 | U7.OUT1 | 15 / 14 / 13 | 5 | IO8 | IO17 | +> +> 落地时定下来的五条: +> ① **本表与四个驱动那四张表是「两处写同一个数」,所以要对账而不是现算。** +> 开机自检 `rail_selftest()` 逐格拿本表和 `ina226_rail_addr()` 等取值函数比一遍, +> 对不上就报 `RAIL_SELFTEST_TABLE`。做成「开机从驱动现算」的话按定义就查不出分家, +> 而且表不再是 const 的、用之前得先记着初始化。 +> ② **判据本身也要被测**:真表是 const 的,测试改不动它,所以判据单拎成 +> `rail_selftest_table(t)`,测试喂几张故意写坏的表进去看它认不认 +> (分家 / 引脚重复 / 撞 SDA / strapping / 不存在的号 / 越界,六种都验过)。 +> ③ **两列 GPIO 是全固件唯一一处出处**,没有第二张表能对账 —— 所以 +> 「`EFUSE_EN1` 和 `VBUS_EN1` 写反」这种错自检**查不出来**(两个都是合法空闲脚、 +> 不重复、不越界)。唯一的拦网是 `test_en_pins_match_the_pcb_netlist` 里钉死的 +> 十个值(出处:`.claude/pcb1-parts.json` 的 U20 焊盘网络)和 B1 上板实测。 +> ④ **「不是顺排」要看整列,不能逐格撞算式**:CH4 的按键位正好是 9/10/11、 +> CH1 的 EDL 位正好是 16、CH3 的两根 EN 正好等于「IO4 起 / IO9 起」那个算式 —— +> 单看任何一路都「像顺排」。测试改成断言整列**不是等差数列**。 +> ⑤ ⚠️ **HAL 两侧的 GPIO 上限原来是 `pin < 48`,把 IO48 关在了门外**, +> 而 `EFUSE_EN2` 就在 IO48 上 —— 现象是 CH2 的 eFuse 既开不了也**关不掉**。 +> 已改成 0~48(含)。ESP32-S3 的编号是连续的但芯片不是:IO22~IO25 不存在、 +> IO26~IO32 是模组内部 flash/PSRAM,写进配置表 `gpio_config()` 不报错也不干活, +> 所以自检把这些号也当成越界。 +> +> 同处还落地了**全板引脚表** `firmware/main/board_pins.h`(§8.1 那张表里 +> 「全板独一份」的那些脚)与两根 EN 线的表驱动遍历 +> (`rail_en_init()` / `rail_efuse_enable()` / `rail_vbus_enable()` / `rail_all_off()`)。 +> `rail_all_off()` **先断五路 VBUS 再关五路 eFuse**,中间不留「手机只靠 USB +> 供着电」的窗口;十个脚全部试一遍,不因为某一个失败就提前返回。 +> 高有效这件事只在 `en_write()` 里出现一次。 + +### 10.5 电压通道:DAC 只做微调,而且是反向的 + +| 事实 | 含义 | +|---|---| +| `Vd = 0 V` → 输出 **4.010 V**(上限) | **DAC 写得越大,输出越低** | +| `Vd = 0.548 V` → 输出 3.90 V(NVM 出厂默认) | 上电即安全值,不依赖 ESP32 初始化成功 | +| `Vd = 2.424 V`(满量程)→ 输出 **3.524 V**(下限) | 满量程是 2.424 V,不是 2.5 V(内部基准 1.212 V ×2) | + +> ⚠️ **反向关系是本节最容易写反的一条。** 写反的现象不是报错,而是 +> 「调高电压的命令把手机调到了 3.52 V」——手机照样能开机,只是更容易在负载尖峰下掉电, +> 而且**要跑一阵子才看得出来**。固件里把这一步封成 `rail_set_mv()` 单一入口, +> 内部做 mV → Vd → code 的换算,**不要在别处直接写 DAC 码值**。 + +三条固件规矩: + +1. **越界即拒绝**:`rail_set_mv()` 收到 >4010 mV 的请求直接返回错误并记事件(SR-13)。 + 注意这是**第四道**限制,前三道在硬件里 —— 它的作用只是让操作者早点知道自己写错了。 +2. **不用 DAC 做保护**:任何时候都不要靠「把电压调低」来处理异常,那是 `EFUSE_EN` 的活。 +3. **出厂标定**:分压网络与 DAC 基准都有 ±1 % 级的容差,用该路 INA226 读回实际输出, + 做一次两点线性标定存进 NVS。标定值缺失时退回理论公式(误差约 ±40 mV,可用)。 + +> **A4 已落地**:换算与设定在 `firmware/main/rail.c`,唯一入口 `rail_set_mv(rail, mv)` +> (rail 是 **1~5**)。三个验收点各有一条测试:4020 mV 被拒、码大电压低、3900 mV ↔ Vd 0.548 V。 +> 标定(本节规矩 3)**还没做**,现在走的是理论公式;`RAIL_VOUT0_UV` / `RAIL_DAC_SPAN_UV` +> 两个常量就是将来要被标定值顶替的地方。 +> +> 同处记下三条 DAC63204 的实现约定(出处 SLASEX3A): +> - **12 位码在 `DAC-X-DATA`(19h~1Ch) 里是左对齐的**(bit15-4)。忘了 `<< 4` 不报错, +> 只让输出变成 1/16 —— 而在本板上那表现为「五路都是 4.010 V 满电」,看不出异常。 +> - **`EN-INT-REF` 要先于增益设定写**,且**上电安全码要写在通道上电之前**: +> 复位码是 0,而 Vd = 0 就是输出上限 4.010 V。`dac63204_init()` 因此把 +> `COMMON-CONFIG` 写两次(先按住输出,写完码再上电)。 +> - **通道分配不是 `rail−1`**(设计文档 §26.18 重排过,五路里四路对不上): +> CH1→U6.OUT2 / CH2→U6.OUT0 / CH3→**U7**.OUT0 / CH4→U6.OUT1 / CH5→U7.OUT1。 +> 四通道电气对等 ⇒ 接错了照样工作,只是调错路,功能测试测不出来。 + +### 10.6 执行器:三个状态机 + 一个影子寄存器 + +**每路三个序列,彼此互斥、不可重入**(同一路上同时只能跑一个): + +| 序列 | 时序 | 关键约束 | +|---|---|---| +| 硬重启 | `EN↓` → 等 3~5 s → `EN↑` → 等 **17 ms**(tON 5.1 ms + 缓启动斜坡 11.5 ms)→ 等 PG 置位 | 放电由硬件联锁自动完成(SR-7),固件**不要**去控制放电 MOS | +| 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | 组合键(VOL−+PWR)必须**同一帧**写进去,不能先后两次 | +| EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | 顺序固定(SW-6):先短接再插 USB | + +**影子寄存器是这一节的核心**:15 路按键 + `HUB_RST` + 5 路 `EDL_EN` 全在**同一条 24 位 +移位链**上,写任何一位都要把整条链重发一遍。 + +``` +ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U33) +写入顺序:U33 字节 → U22 字节 → U21 字节,最后一个 RCLK 上升沿统一生效 +``` + +规矩:**内存里保存一份 24 位影子值,全固件只有一个函数能写 595**,任何任务改按键 +或 EDL 都是「改影子 → 请求刷新」。多处直接操作移位链,症状是**另一台手机的按键被顺手 +清掉**,而且复现要靠两个动作正好撞在一起 —— 属于会拖很久的那类 bug。 + +> `RCLK` 打点之前输出不变,所以整帧刷新是原子的;`OE#` 保持有效,**不要**用拉高 `OE#` +> 的方式「临时全松开」——那会让 15 个栅极同时进高阻,靠的是每个栅极自己的 100 kΩ 下拉, +> 是复位路径,不是正常控制路径。 + +> **2026-09-06 A5 落地**:`components/drv/sr595.{h,c}`。影子是一个 24 位整数 +> (bit0 = U21.QA 链首 … bit23 = U33.QH 链尾),唯一写入点是 `sr595_flush()`; +> 别处只准 `sr595_modify()` 改影子,组合键走 `sr595_write(mask, value)` 一帧发完。 +> 位表见设计文档 §17.2(§26.18 重排后的那张,**不是 09-04 那张**)。 +> +> 同处记下三条这一层的实现约定: +> - **上电顺序是「先锁存安全值,再使能 `OE#`」,不能换。** 三片的 `MR#` 接死在 +3V3, +> 没有复位线 ⇒ 上电时移位寄存器内容**不确定**。先拉低 `OE#` 就是把那坨随机值 +> 放到 15 个栅极上 —— 五台手机的电源键可能一起被按下去。所以 `sr595_init()` 里 +> `KEY_OE#` 在安全值锁存完之前**根本不配成输出**,靠板上 R609 上拉着。 +> (ESP-IDF 的 `gpio_config()` 一配成输出就开始驱动,输出寄存器复位值是 0。) +> - **`HUB_RST` 低有效**,所以上电安全影子是 `1 << 23` 而不是 0。写成 0 的现象是 +> 「Hub 插上枚举不出来」,而五路电压电流全都正常,看不出跟固件有关。 +> - **发送失败要留着脏**(`sr595_flush()` 失败不清 dirty,下次重发整帧)。 +> 丢了脏标志 = 链上和影子永久对不上,而后面每一次 flush 都会「成功」。 + +> **2026-09-07 A9 落地**:`firmware/main/t_actuator.{h,c}`(+ `test/test_t_actuator.c` 23 例)。 +> 三个序列成了一张**每路一份**的状态机:`HR_OFF → HR_RAMP → HR_PG` / +> `KEY_HOLD` / `EDL_SHORT → EDL_ENUM`,全部以 `CLEANUP` 收尾。时间常量都带出处 +> (断电 4000 ms 落在 §7.4 的 3~5 s 窗口里、斜坡 17 ms、等 PG 上限 500 ms、 +> 按键 100/2000/10000 ms、EDL 短接 10 ms + 等枚举 3000 ms)。 +> 这一层的六条实现约定: +> +> 1. **一个函数一步,不许 `while(1)`。** `t_actuator_step()` 只看时钟、推状态、 +> 返回「下次最晚多久再来」;任务体在 `app_main.c` 里还是三行。写进死循环的 +> 时序在宿主机上一行也跑不到 —— 而这一包的全部内容就是那些时间常量与它们的先后。 +> 2. **按下去的东西一定要松开,`CLEANUP` 不放弃。** 正常结束、超时、出错、被 +> `t_actuator_abort()` 打断走的是**同一条**收尾路径;写不进 595 就一直重试, +> 这一路一直算 busy。放弃的现象是电源键留在按下状态、手机再也起不来, +> 而遥测上五路电压电流全都正常。连「按下那一步就失败」也要走收尾 —— +> 否则影子里留着一个脏的「按下」,谁下一次 `flush` 谁就替你把键按下去。 +> 3. **等不到 PG 不许再断电。** `ACT_RES_NO_PG` 的语义是「电已经开着,只是没能确认」。 +> PCA9555 失联时把「看不见」当成「坏了」去断电,等于一片 I/O 扩展器失联 +> 就让五台手机一起停机 —— §10.10 明写着这片不应答**不关输出**。 +> 4. **PG 是喂进来的,执行器不碰 I²C。** `PGOOD` 已经被 `t_safety` 每 20 ms 读一遍, +> 再读一次等于凭空多一份总线占用和一份两边不一致的可能。回调(`act_pg_fn`) +> 同时也是「两把锁永远不会嵌套」的物理保证:本文件只碰 GPIO 与 595。 +> 5. **不可重入 = 直接拒**(`HAL_ERR_STATE`),要抢占得先显式 `t_actuator_abort()`。 +> 被拒的请求**一个字节都不发**,也不改正在跑的那个序列的时长。 +> 6. **组合键一次 `chain_write()` 发完**(同一个 RCLK 沿)。分两次写的实现在 +> `sim_sr595_latches()` 上会 +2,而且中间那一帧只按下了一个键。 +> +> 验收之外还钉了两条容易漏的:硬重启**开始前**先松掉本路三键(否则手机开进 +> Recovery/Fastboot),以及 EDL 结束后 **VBUS 留着不动**(手机正挂在 9008 上)。 +> 21 条注错逐个跑过,全部变红。 + +### 10.7 事件记录与存储 + +事件是 SW-3 的交付物,也是排查排线接触不良的唯一线索,所以**记录路径不能依赖上位机在线**。 + +| 项 | 设计 | +|---|---| +| 缓冲 | RAM 环形缓冲,≥256 条;`t_safety` 只入队,不落盘 | +| 落盘 | `t_log` 空闲时批量写 NVS / SPIFFS 分区 | +| 单条 | 时间戳 + 路号 + 事件类型 + 当时的 V/I(16 B 量级) | +| 容量 | 4096 条 ≈ 64 KB,按每天几十条算够存半年以上 | +| 时间 | 上电从 0 计;上位机连上后下发一次墙钟做偏移校正(不加 RTC 芯片) | + +必记事件:eFuse FAULT / PG 变化、SR-5 触发关路、SW-8 反灌断 VBUS、过温降载、 +每次重启序列(软 / 硬、谁下的命令)、越界命令被拒、I²C 器件失联、风扇停转。 + +> **A11 已落地**(2026-09-07,`firmware/main/evlog.{h,c}` + 新增的 +> `hal_store`(掉电不丢区)+ `partitions.csv` 的 `evlog` 分区)。落地时定下来的八条: +> +> 1. **落盘不用 NVS,也不用 SPIFFS,是一块裸的 data 分区。** NVS 是键值表 +> (4096 条记录 = 4096 个键,页式 GC 会把「写一条事件」变成一次不可预测的搬移), +> SPIFFS 的掉电一致性自己就是一整章。evlog 本身已经是环形缓冲,它要的只是 +> 「擦一个扇区 / 写一段字节」—— HAL 因此多出第六样 `hal_store`。 +> 2. **容量是 17 个扇区 = 4335 条,不是 16 个。** 每个扇区要拿一个 16 B 的槽 +> 当扇区头(epoch),于是 4 KB 只装得下 255 条,16 扇区 = 4080, +> **差 16 条不到本表要求的 4096**。8 MB flash 上多这 4 KB 没有代价。 +> 3. **序号 = flash 里的位置**(`epoch × 255 + 槽号`),不是一个自增计数器。 +> 重启之后接着数不用把计数器存起来,掉电也不会倒退。代价是**没落盘的事件 +> 还没有序号** ⇒ `events` 命令(10.8)要先 `evlog_flush()` 再读。 +> 反过来做(入队时发号)会在缓冲溢出丢事件时留下一段永远补不上的空号, +> 上位机那边的现象是「拉日志卡在某个序号上不动」。 +> 4. **入队在临界区里做完,不是加第三把锁**(§10.3 的共享资源只有两个)。 +> 入队方是 `t_safety`(最高优先级、断电之后调),停在一把 `t_log` 持着的锁上 +> 就等于让最低优先级的任务决定巡检的节拍。关中断的临界区里**只改内存**: +> 时间戳与校验都在外面算完。 +> 5. **丢事件可以,悄悄丢不行。** RAM 缓冲(256 条)满了盖最旧的 ——「最新的一条 +> 永远进得去」——但被盖掉的会在下一次落盘时留下一条 `LOG_EV_LOST`, +> 值是丢了多少条。一份会安静少掉几行的日志比没有日志更糟:排查的人会拿它 +> 当完整的时间线看。flash 写失败丢掉的那一批同样补这条。 +> 6. **每条记录带一个 CRC-8,而且只能是 CRC-8。** NOR 的写会被掉电打断在任意 +> 字节上,半条记录在字节层面跟真记录长得一模一样。第一版用异或和, +> 漏掉的正是最常见的那条:`bus_mv` 与 `current_ua` 都是 0 的记录被切在第 8 个 +> 字节上时,后 8 个字节留在 0xFF,而 **8 个 0xFF 的异或恰好是 0** —— +> 半条的校验和与整条一模一样。 +> 7. **写失败之后放弃这个扇区的剩余部分。** 接着写的话,重启扫描会停在那半条上, +> 把后面写进去的全部当成不存在。代价是最多浪费 254 个槽,换来的是 +> 「扫描停在哪 = 日志到哪为止」这条不变量永远成立。被放弃的那段在序号空间里 +> 是个洞,`evlog_read()` 给它一条空记录**占住位置** —— 跳过它的话上位机的 +> 游标会卡在洞上。 +> 8. **每次上电记一条 `LOG_EV_BOOT`。** 本表写着「上电从 0 计」,所以时间戳会 +> 跳回去;没有这条记录的话,两次上电的事件在时间轴上互相穿插, +> 而日志里看不出中间重启过。 +> +> 事件类型的**数字是 flash 格式的一部分**(改一个值就等于把跑了半年的历史记录 +> 重新解释了一遍,而且读出来仍然是合法记录、没有一处报错),所以 `log_ev_t` +> 只许在末尾追加;`safety_ev_t` → `log_ev_t` 是一个显式的 switch,不是加偏移。 +> 上表里还没人产生的那几类(eFuse FAULT / PG 变化 / 过温降载 / 重启序列 / +> 命令被拒 / I²C 失联 / 风扇停转)**号已经占住**,各归各的包去填。 +> +> 27 条 ctest 用例;23 条「一定会被写出来的错」逐个注进去跑过,**21 条变红**。 +> 剩下两条抓不到,理由见 `firmware/HANDOFF.md`。 + +### 10.8 上位机接口 ✅ 已定稿(2026-09-06 用户拍板:方案 C) + +**这一节回答的问题**:SW-1 的触发条件写的是「`adb` 探活连续失败 N 次」, +而 **ESP32 跑不了 adb** —— 所以崩溃判定必然发生在上位机,板子只负责执行动作。 +这意味着必须有一条控制链路,而在此之前**全部文档里都没有定过它**。 + +**结论:WiFi 为常态链路,USB CDC 为兜底链路,两条同时在线。** + +选它不是因为「都要」,而是因为 **SW-1 是这块板子的核心价值, +而 WiFi 与手机 adb 走的是同一张网** —— 只做 WiFi 等于让「救援通道」和「故障通道」 +共命运,恰好在最需要它的时候一起失效。USB 的增量成本很低:线本来就插着。 + +留档备查的两个单选方案: + +| 方案 | 优点 | 为什么不单独用 | +|---|---|---| +| A. 只做 WiFi | 无线、可多台组网;设计文档 §9.5 的 3V3 预算里**已经给 ESP32-S3 的 WiFi 突发留了 ≈350 mA**,说明当初就是按带 WiFi 设计的 | 网炸了救砖通道跟着炸,见上 | +| B. 只做 USB CDC | 最可靠,不依赖网络 | 每块板占上位机一个 USB 口;机架规模上去之后不现实 | + +#### 两条链路怎么共存 + +```diagram=links + WiFi (MQTT/HTTP) USB CDC (J22) + │ │ + ┌───────▼────────┐ ┌────────▼───────┐ + │ 传输适配器 A │ │ 传输适配器 B │ ← 只管收发字节与分帧 + └───────┬────────┘ └────────┬───────┘ + └──────────┬─────────────┘ + ┌──────▼───────┐ + │ 单一命令处理器 │ ← 全部鉴权、限幅、幂等判重都在这里 + └──────┬───────┘ + ▼ + 执行器 / 遥测 +``` + +四条规矩: + +1. **命令处理器只有一个。** 两条链路是**传输**,不是两套功能。任何安全约束 + (SR-13 的限幅、越界拒绝、序号判重)都写在处理器里,绝不写进适配器 —— + 否则以后加第三条链路时必然漏掉一处,而**功能测试完全测不出来**。 +2. **不做主备切换,两条都常开。** 谁先送到算谁的,靠 3 的序号去重。 + 做「主备」就要判定"主链路死了没有",而那个判定本身就是新的故障点。 +3. **序号是全局的,不分链路。** 上位机给每条命令一个单调递增的序号, + 板子记住最近 N 个已执行的序号。WiFi 重传 + USB 又发了一遍同一条命令时, + 第二次必须被丢弃 —— 否则「按 10 秒电源键」会执行两遍,第二遍正好按在刚开机的手机上。 +4. **USB 链路与日志共用一条流。** ESP32-S3 的原生 USB-Serial-JTAG 本身就是 + CDC-ACM,不需要切到 TinyUSB,也不影响 esptool 下载(FR-15 不变)。 + 代价是命令响应和运行日志在同一条串流上,**必须分帧**(建议命令用带前缀的单行 + JSON,日志走另一个前缀),否则日志一刷就把响应冲散。 + +> ⚠️ **别把 WiFi 凭据和 MQTT 地址编译进固件。** 放 NVS,用 USB 链路配置 —— +> 这正好是兜底链路的第二个用途:新板子上架时先插 USB 配网。 + +命令语义与链路无关,两条链路完全一致: + +| 命令 | 参数 | 说明 | +|---|---|---| +| `status` | — | 五路 V/I/PG/FLT、输入 V/I、两路 NTC + 载板温度、风扇转速、各路状态机状态 | +| `set_voltage` | 路号, mV | 走 `rail_set_mv()`,越界拒绝(10.5) | +| `power_cycle` | 路号, 断电秒数 | 二级硬重启(4.1) | +| `key` | 路号, 键位掩码, 保持 ms | 单键与组合键同一个接口 | +| `edl` | 路号, on/off | 内部按 SW-6 的固定时序展开 | +| `vbus` | 路号, on/off | 默认断开 | +| `fan` | 占空比 / auto | `auto` 交回 `t_thermal` 闭环 | +| `events` | 起始序号 | 增量拉取事件日志 | + +三条接口硬约束: + +- **没有任何命令能让输出超过 4.2 V**(SR-13)—— 包括「原始 DAC 写入」这类调试命令, + 它压根不该存在于发布固件里。 +- **命令必须幂等或带序号**:链路重传时不能让「按 10 秒电源键」执行两遍(上面第 3 条)。 +- **链路失联不触发任何动作**(10.2):没有心跳超时关输出这回事。 + +#### A12 定稿的线上格式(2026-09-27,`firmware/main/cmd.h`) + +一行一条、单层扁平 JSON(嵌套 / 数组 / 转义 / 浮点 / null / 重复键一律 `parse`)。 + +``` +→ {"seq":17,"cmd":"key","rail":3,"keys":"pwr","ms":10000} +← {"seq":17,"ok":true} +← {"seq":17,"ok":false,"err":"range"} 重传命中:{"seq":17,"ok":true,"dup":true} +``` + +| 命令 | 参数 | 与上表的差别 | +|---|---|---| +| `status` | — | 只读,seq 可省,不判重。读不到的量回 `null`(不是 0) | +| `set_voltage` | `rail`, `mv` | 限 3524~4010(DAC 物理范围,比 SR-13 的 4.2 V 更紧) | +| `power_cycle` | `rail` | **不收断电秒数**:固定 4 s(§7.4 的 3~5 s 窗口) | +| `key` | `rail`, `keys`(`"pwr"` / `"vdn+pwr"`…), `ms`(1~15000) | 单按电源键 ≥10 s 记一条软重启事件 | +| `edl` | `rail` | **不收 on/off**:跑一遍 SW-6 固定序列;退出 EDL 用 `power_cycle` | +| `vbus` | `rail`, `on` | eFuse 关着时 `on:true` 拒(`state`);序列进行中拒(`busy`) | +| `power` ⭐新增 | `rail`, `on` | 管理界面要能停一台。关 = 先断 VBUS 再关 eFuse;VBUS 断不掉也照样关 eFuse,回 `io` | +| `fan` | `duty`(0~1000,0 = 自动) | **只是下限**:实际 = max(温度曲线, duty),远程不能把风扇调小 | +| `events` | `from`(可省) | 一次最多 8 条 `[序号,上电ms,路,类型,arg,mV,µA]`,类型是 `log_ev_t` 的冻结数字 | + +- 会动东西的 7 条**必须带 seq(1~2³²−1)**;判重窗口 32 条、全局、按值相等判。 + 上位机同一个 seq 只用于同一条命令的重传,「再试一次」要换新 seq。 +- 错误码:`parse / unknown_cmd / no_seq / arg / range / empty / busy / state / io / overflow`。 + `range / empty / state` 记 `LOG_EV_CMD_REJECT`(arg = 命令号,bus_mv = 越界值,current_ua = 原因号),其余不记。 +- 空槽(`PRSNT#` 高)的按路命令一律 `empty`。 +- 板子重启会清空判重窗口;上位机看 `status.up` 变小识别重启。 + +#### A13 定稿的 USB 分帧(2026-09-27,`firmware/main/ulink.h`) + +``` +上位机 → 板 @C [*XXXX] + CRC 可省(给人手敲);带了就必须对 +板 → 上位机 @R *XXXX + CRC 必带;XXXX = CRC-16/CCITT-FALSE(json),4 位大写十六进制 +其余任何一行 = 日志 +``` + +- 前缀只在行首算数;日志行若以 `@` 开头,固件会垫一个空格 ⇒ 日志永远不会被当成帧。 +- 链路层自己的错误(不进命令处理器、不记事件):`too_long` / `bad_crc` / `bad_frame`(NUL 或控制字符)/ `queue_full`,能解析出 seq 就回显。 +- **USB 副控制台已关**(`CONFIG_ESP_CONSOLE_SECONDARY_NONE`):USB 上只有「日志钩子的副本 + 回应」两路,同一把锁;`printf` / panic 只去 UART0(J33)。 +- 上位机打开串口时 **DTR / RTS 保持低**,否则可能把板子复位。参考实现 `firmware/tools/prlink.py`。 +- 实测(板 B,2026-09-27):200 条 status 连发 0 坏帧,混进的 `t_safety` 日志全部正确识别;往返 status ≈3 ms、小命令 <1 ms。 + +#### A14 定稿的 MQTT 链路(2026-09-27,`firmware/main/mlink.h` / `netcfg.h`) + +base = `phonerail/`,id = 板子 STA MAC 的 12 位小写十六进制(板 B = `b81f3fc9c8bc`)。 + +| topic | 方向 | QoS | retain | 内容 | +|---|---|---|---|---| +| `/cmd` | 服务器 → 板 | 1 | 否 | 一条命令 JSON(与 USB `@C` 负载相同,无前缀无 CRC) | +| `/rsp` | 板 → 服务器 | 1 | 否 | 一条回应 JSON | +| `/tele` | 板 → 服务器 | 0 | 否 | 周期遥测 = 一条 status 回应(无 seq),默认 1 s(`tele_ms` 200~60000) | +| `/state` | 板 → 服务器 | 1 | 是 | `{"online":true,"fw":..,"ip":..}`;掉线由遗嘱改成 `{"online":false}`(keepalive 15 s) | + +- **配网(C1)**:`wifi`(ssid / pass)与 `mqtt`(uri / user / pass / tele_ms)两条命令,存 NVS,**只收 USB**(经 WiFi 来回 `link` 并记事件)。 + `net` 查网络状态,**永不回显密码**(只报 `pass_set` / `mpass_set`);含 `wifi_reason`(最近一次断开原因码)。 +- uri 收 `mqtts://`(TLS,CA 编在固件:`firmware/main/certs/evse_ca.pem`)或 `mqtt://`(只该用在局域网)。 +- **broker = home 服务器现有 mosquitto 的 8883(TLS)**,与 evse 共用:板子用户名 = id,ACL 用 `pattern … phonerail/%u/…` + 只许碰自己的四个 topic;后端用户 `phonerail-backend` 可读写 `phonerail/#`。口令在服务器 `/root/phonerail/mqtt.env`(仅 root)。 +- 失联不动作:WiFi 断按 1/2/4…60 s 退避重连,MQTT 由 esp-mqtt 每 10 s 重连;没有任何分支会碰输出。 +- 实测(板 B,2026-09-27):经 WiFi `CANBOX-5G` → 公网 → 8883 TLS 连上;判重 / `link` / `empty` / `parse` 回应与遥测(1 条/s)全部符合。 + +#### C2 定稿的 OTA(2026-09-27,`firmware/main/ota.h` / `ota_esp.c`) + +用户定的三条:**明文 HTTP 下载 + 命令带 sha256**(哈希走 MQTT/TLS,下载被改了只会哈希不对)、**不签名**(不烧 eFuse)、 +**新固件 120 s 内连上 broker 才算健康,否则回滚**。 + +- 命令(两条链路都收,会动东西 ⇒ 必须带 seq、进判重窗口): + `{"seq":N,"cmd":"ota","url":"http://home.example:7765/fw/.bin","sha256":"<64 位小写>","size":N,"reboot":false}` + 回 `{"seq":N,"ok":true,"act":"start"|"reboot"|"ready"}`;下载中再来 `busy`;新固件还没自证时 `state`;size 超一个槽(3 MB)`range`。 + url 只收 `http://`、≤64 字节(命令字符串上限)。 +- ⚠️ **`reboot` 默认 false**:只下载 + 校验 + 切启动分区、下次复位生效;同一个镜像已就绪时再发带 `"reboot":true` 的,不重下、直接重启。 + 重启 / 超时回滚:板上固件 ≥0.1.0-c3 且目标镜像认接管 ⇒ **手机不断电**(§10.9「重启不断电」);否则 ESP 一复位 EN 就被下拉,**五台手机硬断电一次**。 +- status 多一块 `"ota":{"st":"idle|dl|ready","got":N,"size":N,"err":null|"http|size|hash|flash|image|boot|noslot","pend":bool,"bad":null|"版本号"}`; + `pend` = 正在跑的固件还没自证,`bad` = 被判无效回滚掉的那份的版本号(镜像头版本 = `PHONERAIL_FW_VERSION`)。 +- 新事件 **`LOG_EV_OTA`(18)**,arg:1 开始 / 2 就绪 / 3 失败(bus_mv = 原因号)/ 4 自证健康 / 5 回滚 / 6 为换固件重启。新命令号 **`ota` = 13**。 +- 健康判据:启动走完 + 配了 broker 就要 MQTT 连上(只配 WiFi 则 WiFi 通、都没配只看启动);在 `t_link` 里判,另有 esp_timer 在 150 s 兜底 + (app_main 卡死也能回滚);自证前崩溃 / 看门狗由 bootloader 回滚(`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`)。 +- 分区(16 MB):nvs 0x9000(没动)/ phy_init / **ota_0 0x10000、ota_1 0x310000 各 3 MB** / otadata 0x610000 / evlog 0x612000。 +- 服务器:nginx 7765(`rack/deploy/nginx-phonerail-fw.conf`)只放行 `/fw/<16 位十六进制>.bin`,**不过门户**(板子过不了门禁; + 完整性靠哈希、镜像里无机密);路由器 7765 转发 2026-09-27 已加。推送工具 `firmware/tools/ota_push.py [--reboot]`。 +- 实测(板 B,2026-09-27):1 MB 下载 ≈8 s,推送到自证健康 19 s;错哈希 ⇒ `err:hash`、不切分区;删掉板子 broker 账号后推送 ⇒ + 120.1 s 回滚到旧版、`bad` 报出被回滚的版本;下载期间巡检 `skipped` 0。 + +### 10.9 启动顺序 + +``` +1 上电:GPIO 全部显式置安全态(硬件下拉已保证,固件再确认一次) +2 I²C 扫描 10 个器件 —— 缺任何一个都进「降级模式」(10.10),不要照常启动 +3 读回 DAC 通道值(NVM 默认 3.90 V),与期望比对 +4 载入 NVS 里的标定值与上次的每路使能状态 +5 逐路上电,**路与路之间错开 100 ms** +6 等各路 PG,超时则记事件并保持该路关闭 +7 起风扇闭环,起链路任务 +``` + +> ⚠️ **第 5 步的错开不是讲究,是必须**:单路缓启动涌流 0.8 A,五路同时开就是 4 A +> 叠在 12 V 输入上,而输入预算本来就是 8.3 A(PR-2)。错开 100 ms 之后 +> 各路的斜坡(11.5 ms)完全不重叠。 + +**A15 定稿的落地(2026-09-27,`firmware/main/bringup.{h,c}`,宿主机 24 例)**: +- 分两段:`bringup_devices()` = 第 1~4 步;**`t_safety` 在两段之间起来**,再 `bringup_power_restore()` = 第 5~6 步 + —— 上电那一刻巡检已经在看(原文第 7 步没说巡检何时起,等五路开完才起就有近 1 s 的盲区)。 +- 第 2 步 R2 实际是 15 片:主干 mux / 两片 DAC / 输入侧 INA226 / CH224A,五片刀片各 INA226 + PCA9555。 + 六片 INA226 都在这一步写 Config / Calibration(A14 之前没做,电流恒读 0);输入侧 0x43 在这一步登记对调接。 +- 第 3 步 DAC 读回比对**只在上电复位时做**:热重启时 DAC 没断过电,里面是上次命令设的值。 +- 第 4 步「上次的每路使能状态」= **意图**:只有 `power` 命令改它(NVS 键 `phonerail/rail_want`,1 字节); + 判据跳闸、硬重启、mux 全断都不改。新板没有这个键 = 一路都不开。 + ⚠️ 设定电压**不**持久化:重启后五路回到 3.90 V,上位机要的话自己重下。 +- 第 5 步错开按「上一路 EN 抬高的时刻」算(不等上一路 PG),ESP 上实际 100~110 ms。 +- 第 6 步每路各等 500 ms(= 硬重启的 PG 上限);等不到就关回去并记 **`LOG_EV_PG_TIMEOUT`(17,新增)**。 + PCA9555 读不到时改按 INA226 电压(≥ 设定 − 100 mV)判;两样都读不到 = **开着、报「确认不了」**,不当成坏了。 +- `HUB_RST#` 只在上电复位时脉冲,其余复位原因连配置都不做(配成输出那一刻就是一次复位)。 + +**重启不断电(2026-09-27,`firmware/main/warm_esp.c` + `bringup.h` 的 `adopt`)**——用户定:只管计划内重启,VBUS 也保持。 +- 重启前(`warm_restart()`):记下开着的路(RTC_NOINIT:魔数 + 掩码 + 反码)→ `gpio_hold` 锁住五根 `EFUSE_EN` → 重启。 + **目标固件不认接管就不锁**(镜像第 288 字节的能力标记 `PRCAPS1`,`.rodata_custom_desc`):推老版本(≤0.1.0-c2)时 + 它起来写不动被锁住的 EN,五路会**永远关不掉** —— 所以对它照老样子全断重启。 +- 起来后(`warm_take()`,bringup 之前):复位原因 = `ESP_RST_SW` **且**标记对得上才接管;标记一次性。 + 再读 EN 脚真电平核对(输出脚配成 INPUT_OUTPUT,锁住时输入缓冲还开着),线上是低的那路不接管。 +- 第 1 步 `rail_en_init_keep(adopt)`:接管的路写高,**五根一律放开 hold** —— 崩溃 / 看门狗时上一条命的锁可能还在,不放开就关不掉。 + 刀片不在 / mux 失联的接管路立刻关(`adopt_dropped`,空槽记 `SLOT_EMPTY` arg 1)。 +- PCA9555 用 `pca9555_init_keep(VBUS 位)`:方向寄存器已是本固件写的才留 VBUS(掉过电的片子锁存器是 0xFF,照留 = 凭空开 VBUS),键 / EDL 清零。 +- 第 5~6 步:已开着的路不重开、不等 PG。DAC 照常 init(中间约 1 ms Hi-Z ⇒ 输出退化到 3.890 V,设计文档「DAC 未供电 / 输出 Hi-Z」那一行),自定义电压回到 3.90 V(本来就不持久化)。 +- 事件:`LOG_EV_OTA` arg 7,bus_mv = 接管掩码,µA 列 = 关掉的掩码。 +- 代价:esp_restart → 新固件 t_safety 起来约 1~2 s,SR-5 / SW-8 不判(eFuse 硬件保护在)。兜底定时器回滚、新固件崩溃都**不**走接管。 +- 实测(板 B,空槽强制 CH1 在位的台架镜像):CH1 开着 → OTA 带重启 → 新固件读到 IO9 仍为高、记 `18/7 mv=1` → CH1 一直开着。 + +### 10.10 降级矩阵 + +> ⚠️ **2026-09-16 按 R2 硬件更新**(底板 + 5 片刀片,见 `刀片式方案规划-R2.md`)。 +> 新增了三行(mux / `PRSNT#` / PD),改了两行(PCA9555、LM75),理由写在表后。 + +| 失效 | 检测方式 | 行为 | +|---|---|---| +| 某片 INA226 无应答 | I²C NAK | 该路失去遥测 → **保持供电**,标记为「盲跑」,禁用该路的 SR-5 / SW-8 判据并持续告警 | +| I²C 总线挂死(SDA 被拉低) | 连续超时 | 发 9 个时钟脉冲做总线恢复;仍不通则复位 I²C 外设;再不通进降级模式 | +| DAC 无应答 | I²C NAK | **不关输出** —— DAC 不响应时输出停在 NVM 默认的 3.90 V,是安全值 | +| **某片刀片的 PCA9555 无应答**(R2 改) | I²C NAK | 失去该路的 FAULT / PG 可见性 → **不关输出**,改用 INA226 的电压判据兜底并持续告警。⚠️ **R2 新增的代价:这一路的 `VBUS_EN` 也控制不了了** —— 它在这颗芯片的 P0.4 上。见表后第 1 条 | +| **TCA9548A(mux)连续选不中**(R2 新增) | 连续 N 次选通失败(N 待实测,暂定 3) | **先脉冲一次 `MUX_RST#`**;复位后仍失败 ⇒ **五路全部断电**并记事件。理由见表后第 2 条 | +| **`PRSNT#` 为高(刀片不在位)**(R2 新增) | GPIO 直读,不占总线 | 该路**不许上电**,已上电的立刻关断并记事件。空槽上电 = 金手指裸露带 12 V | +| **整片刀片不应答**:本路 INA226 **与** PCA9555 同时 NAK,mux 正常(0.1.4 新增) | 连续 10 圈(200 ms),只在 eFuse 开着时数 | **关这一路**(只关 eFuse,不再去写那片 PCA9555)并记事件 20 `BLADE_MUTE`。理由见表后第 3 条 | +| **CH224A 读不到 / PD 档位低于预期**(R2 新增) | I²C NAK 或 `0x50` 读数偏小 | **不关输出**,按读到的档位(读不到就按最保守的 60 W)设整板功率上限,逼近上限时按优先级降载(§8.3) | +| ~~载板 LM75BD 无应答~~(R2 作废) | — | **R2 没有载板,这片不存在**。温度只剩两路板载 NTC;两路都读不到时仍按老规矩处理:**风扇转到 100 %** 并告警,不要按「读不到温度 = 不热」处理 | +| 上位机失联 | 心跳超时 | 见 10.2:**什么都不做** | +| 看门狗复位 | — | 复位即全关(SR-6),重启后按 10.9 恢复,并记一条复位事件 | + +**A15 落地时补的三条(2026-09-27,逐行用例在 `firmware/test/test_bringup.c`):** +- **mux 失联时主干也够不着**:`i2cbus_xact(主干)` 要先给 mux 写「全关」,这一步失败回调就不跑。 + 所以启动时 mux 复位后仍不通 ⇒ 不去碰 DAC / 输入侧 / CH224A(不刷三条误导性的 I2C_LOST), + 设定值影子回 NVM 默认、功率按 60 W,只记一条 MUX_LOST。 +- **看门狗复位**的「复位事件」就是 `LOG_EV_BOOT`,arg = `esp_reset_reason()`(1 上电 / 4 panic / 6 任务看门狗 / 11 USB …)。 +- **I²C 总线挂死**那一行(9 个时钟恢复 + 复位外设)**还没做**,在 HAL 层,待办。 +- CH224A:设计上挂 mux 通道 5,**板 B 实测在主干应答**(0x22/0x23);启动时两处都找。 + +**表后的两条,都是 R2 的架构后果,不是待办:** + +1. **`VBUS_EN` 从 GPIO 变成了 I²C 后面的一个位。** R1 里 `t_safety` 断 VBUS 是一次 + `gpio_set_level()`,几百 ns、永远成功;R2 里它是「选 mux 通道 + 写刀片 PCA9555」, + 约 0.16 ms,而且**I²C 失联时断不掉**。兜底是关 `EFUSE_EN`(GPIO 直连,永远关得掉), + 但要说清楚:**关 eFuse 并不能阻止手机经 USB 往回灌**,它只是让这一路不再直供电。 + 真正的反灌保护是硬件那一层(设计文档 §15.2 的母线泄放 + 电容),固件这一层在 + I²C 失联时**确实降级了**。这是 R2 用一根跨板针换来的代价,验收在 §12.1 的 B7。 + ⚠️ 相应地,`EFUSE_EN` **必须永远是 ESP32 GPIO 直连**(金手指 A11 独占一针)—— + 它是保护链第三层唯一的执行手段,任何时候都不许挪到 I²C 后面。 + +2. **mux 是全板唯一的单点。** 它一挂,五路的电压电流**全部看不见**,SR-5 的整条监控链 + 同时断掉 —— 这时「继续供电但看不见」比「停机」危险,所以判据是**全断**, + 与上面「某片 INA226 无应答就保持供电」的处理方向相反。区别在于范围: + 一片失联时还有四路看得见,mux 失联时一路都看不见。 + 之所以先脉冲一次 `MUX_RST#` 再判,是因为一次总线毛刺就停掉五台手机代价太大。 + +3. **整片刀片不应答 ≠ 一颗器件不应答**(2026-09-28 加,用户拍板)。第一行「INA226 无应答 → 盲跑」 + 和 PCA9555 那一行的前提是刀片上**另一颗还答应**。两颗同时不答应、而 mux 正常(选不中的话读不到这一步), + 只剩两种可能:`PRSNT#` 假在位、刀片其实不在(板 B 第 2 槽实测:`PRSNT2#` 卡低,刀片挪走后空槽 eFuse + 开了约 4 h);或刀片在但这一路 V/I、PG/FLT、`VBUS_EN` 全都够不着。两种都是「继续供电但看不见」, + 按第 2 条同一个道理处理,范围是一路。关着的路不数(假在位的空槽不会每 200 ms 刷一条)。 + +**降级模式**的定义:只保留「关断能力」与「事件记录」,不再自动上电、不接受调压命令, +持续告警。宁可五台手机不开机,也不要在盲态下带电跑。 + +### 10.11 关于 FR-12「模拟电量曲线」的一个说明 + +这条需求的实现方式和它的字面意思不一样,值得在架构层写清楚:**手机的电量计是从 +电池端电压推算 SoC 的**,我们给的电压就是它看到的全部信息。所以「模拟电量曲线」 += **按锂电放电曲线缓慢改变输出电压**(比如 12 小时从 4.01 V 走到 3.60 V,再跳回去)。 + +两个后果: + +- 它**不需要**任何新硬件,是 `rail_set_mv()` 之上的一个慢速调度器; +- 它与 SW-4(单路电压微调)**共用同一个执行器**,两者必须互斥 —— + 否则运维把某台调到 4.0 V,曲线任务转头又把它拉回 3.6 V。 + +FR-12 是「中(可选)」,建议**首版先不做**,把接口留出来即可。 + +### 10.12 架构层面的待定项 + +| 项 | 状态 | +|---|---| +| ~~上位机链路选型(10.8)~~ | ✅ **2026-09-06 用户拍板:方案 C**,WiFi 常态 + USB CDC 兜底 | +| ~~OTA 升级~~ | ✅ **2026-09-27 C2 完成**:双槽 + 失败回滚,定稿见 §10.8「C2 定稿的 OTA」 | +| WiFi 掉线重连策略 | 指数退避 + 上限;**重连过程不得阻塞 `t_safety`**(10.3),链路任务优先级最低就是为这个 | +| **MQTT broker 与 topic** | 2026-09-27 起由本项目自定(D7,broker 在 home 服务器);topic 约定随 A14 写进 §10.8 | +| 时间同步 | 当前方案是上位机下发墙钟做偏移,不加 RTC。若日志要脱机长期归档需重议 | +| FR-12 电量曲线 | 建议首版不做,只留接口(10.11) | +| `input_suspend` 节点路径 | 与 SW-7 / V-8 是同一件事,首台机上确认后写死 | + +--- + +## 11. 约束与假设 + +### 11.1 硬约束 + +| ID | 约束 | +|---|---| +| **C-1** | 全部为 1S 机型,负载固定为小米8(dipper)主板 | +| **C-2** | 原厂电池保护板**被旁路** ⇒ 失去一层与本板独立的防线,全部安全责任落在 SR-2 / 3 / 5 上 | +| **C-3** | 无电池 ⇒ 失去一个内阻只有几十 mΩ 的缓冲电容。**瞬态阻抗设计(§6)比电压精度重要得多**,是这类板子的主要故障来源 | +| **C-4** | ESP32-S3-WROOM-1 可用 IO 有限(19 根信号已排满),新增功能只能走移位寄存器或 I²C 扩展 | +| **C-5** | USB2517I 要求 D± 走线 ≤ 6 inch(152 mm) ⇒ Hub 只能放在载板通道正中 | +| **C-6** | BM24(尾插)连接器只有 **10 次插拔寿命** ⇒ 装配流程必须减少反复插拔 | +| **C-7** | 小米8 原理图 PDF 是第三方扫描件,**一切引脚 / 机械结论以实测为准** | + +### 11.2 假设(若不成立需重新评估) + +| ID | 假设 | +|---|---| +| **A-1** | 上游 USB 恒为 5 V(只接电脑 / Hub,不接快充头) | +| **A-2** | 手机关机待机电流在 ≈50 µA 量级 | +| **A-3** | 目标 ROM 上存在可写的「关充电」sysfs 节点(SW-7,路径待确认) | +| **A-4** | 手机常态功耗 0.3 ~ 1 A / 路,五路同时 4 A 满载不会发生 | + +--- + +## 12. 验收准则 + +### 12.1 首板上电验收(17 步,**严禁第一次上电就接手机**) + +完整步骤与通过标准见设计文档 **§11**。其中: + +- **第 5 步(人为断开 R2,模拟致命故障)** 与 **第 6 步(负载阶跃 + 阻尼验证)** + 是这块板子的两个**核心验证项,不得跳过** —— 它们分别验证 SR-3 和 PR-12 / C-3。 +- **第 12 步(ESP32 复位安全测试)** 验证 SR-6。 +- **第 14 步(USB 反灌测试)** 验证 SR-10 / SW-8。 + +### 12.2 需求 → 验收方法对照 + +| 需求 | 验收方法 | 通过标准 | +|---|---|---| +| 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 / −300 mV 偏差 | 20 ms 内关路并产生告警记录 | +| SR-6 | 手动复位 ESP32 | 示波器确认五路立即关断、15 路按键无一误触发、EDL 无触发 | +| SR-7 | 固件同时置位 EN 与放电 | 硬件互斥,10 Ω 上无持续功耗 | +| SR-9 | 可调电源把母线升到 13.5 V | 泄放导通并拉回 13.2 V 以下 | +| SR-10 / SW-8 | 不关充电直接接 USB | 电流转负 → 固件 200 ms 内断 VBUS | +| SR-15 | 排线加 4.40 V | **不断开**(断开说明焊到了 `B+`/`B−`) | +| PR-12 | 0→4 A 阶跃(带 20 cm 线 + 电容组) | 压降 <300 mV,**无 6 kHz 振铃** | +| PR-13 | 满载 4 A 跑 30 min | 温升 <40 K | +| PR-14 | 断电重启时序 | 输出 200 ms 内 <0.5 V | +| FR-4 / SW-5 | 逐项试按键 | 短按唤醒 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot 均有效 | +| FR-5 / SW-6 | 下发 EDL 序列 | 主机可见 9008 设备 | +| FR-7 | 五台全接 | `adb devices` 与 `fastboot devices` 均可见 | +| FR-10 / TR-6 | 拔掉一个风扇 | TACH 频率下降约 1/5,产生告警 | +| SW-12 | 断开 WiFi(拔 AP / 关热点),从 USB 下命令 | 命令照常执行;**五路输出不受影响**(10.2 的失联行为) | +| SW-12 | 同一序号的命令从两条链路各发一遍 | **只执行一次**,第二条被判重丢弃并记事件 | +| **总体目标** | 五台长稳 | **24 h 无重启、无告警** | + +### 12.3 装配前必做的实物验证(阻塞项) + +| 项 | 内容 | 出处 | +|---|---|---| +| V-1 | **排线旁路验证**:确认焊在 `P+`/`P−`,4.40 V 不断开 | §13.7 | +| V-2 | **侧键触点定位**:万用表 5 分钟,拍照存档 | §13.3 | +| V-3 | **环路补偿实测扫频**(三个位置已留焊盘) | §9.2 | +| V-4 | **量两个新连接器实物高度**(XT30PW-M / 2510-3A)—— 余量只有 1~3 mm,**别信估计值** | §26.16 | +| V-5 | **两条排线路径试装**(电池 / 尾插穿槽走背面) | §25.16 | +| V-6 | **J7n 镜像位按底层装法复做**;装好后做两点通断复核 | §24.3 / §25.16 | +| V-7 | **风扇线装配顺序**:必须「先插五根风扇线,再合上载板」 | §26.16 | +| V-8 | 确认目标 ROM 上 `input_suspend` 的实际路径 | §15.2 | + +> 完整的待办 / 已关闭清单以设计文档 **§12** 为准,本表只列对需求验收有约束的。 + +--- + +## 13. 已知风险与已接受的取舍 + +| # | 风险 | 处置 | +|---|---|---| +| R1 | 去掉原厂保护板 = 少一层独立防线 | **已接受**。定量核算 eFuse 余量 ≈11 倍;且 eFuse 断开后 2200 µF 仍撑着,手机继续运行不掉电,表现优于保护板断开 | +| R2 | 最坏角落下 eFuse 可能到 4.519 V 才跳,超 1S 上限约 60 mV | **已接受**。走到这一步说明第一层已失效,输出以 6.4 mV/µs 冲过这 70 mV 只需 ≈10 µs,远短于锂电过压损伤时间常数 | +| R3 | 两个同款 USB-C 插错 | **已接受**(用户在 USB-B / USB-C 之间选了 C)。靠丝印 + 颜色 + 外壳标记缓解 | +| R4 | 低边调速导致 PWM 关断期间读不到转速 | **已接受**。若最终选 4 线 PWM 风扇,MOS + 续流管 + 分压可全删 | +| R5 | IF-5 的 100 µF MLCC 在 3.9 V 偏压下只剩 ≈40 µF,中频储能从 200 µF 降到 ≈80 µF | **待复核**。严格照原方案需换真聚合物 | +| R6 | 电感 SPM6530T-4R7M 的 Isat 6.2 A < 原定的 7 A | **已接受**,裕量分析见设计文档「已知遗留」 | +| R7 | BM24 仅 10 次插拔寿命 | 靠装配流程规避(C-6) | +| R8 | 手机主板机械固定方式(是否连中框)未定 | **不阻塞打样** | + +--- + +## 14. 需求 → 设计追溯表(速查) + +| 需求域 | 设计文档章节 | +|---|---| +| 规格 / 输入输出 | §1、§3 | +| 电压范围与本质安全 | §4(含 §4.4 失效态验算) | +| 三层保护链 | §5 | +| 瞬态阻抗与阻尼(C-3) | §6 | +| 断电重启与放电 | §7 | +| MCU 接口 / GPIO / I²C | §8、§26.18 | +| 选型与 BOM | §9 | +| PCB 与叠层 | §10、§25、§26 | +| 验收与调试 | §11、§14 | +| 手机侧接口 | §13 | +| USB 子系统 | §15、§18、§21 | +| 两板架构与机械 | §22、§25 | +| 散热 | §22.6 | +| EDL | §22.4 | +| **软件架构** | **设计文档里没有对应章节 —— 见本文 §10** | + +--- + +## 15. 术语 + +| 术语 | 含义 | +|---|---| +| **直供** | 拆掉电芯与保护板,从电池座直接供给受控电压 | +| **dipper** | 小米8 的代号 | +| **P+ / P−** | 电池保护板的**输出侧**焊点(本项目的接入点) | +| **B+ / B−** | 电芯侧焊点(**接错这里是本项目最隐蔽的装配错误**) | +| **eFuse** | 带可调过压 / 限流 / 缓启动的电子保险丝(TPS25940A) | +| **EDL / 9008** | 高通紧急下载模式 | +| **本质安全** | 靠拓扑与元件取值在物理上限死危险状态,而不是靠软件判断 | +| **下板 / 载板** | 下板 = 电源板;载板(上板)= 承载五块手机主板的板 |