From ece65f7c19ba23ef5feedbcff960dfb6745fc7aa Mon Sep 17 00:00:00 2001 From: zhou yang Date: Tue, 6 Oct 2026 14:35:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E4=BB=B6=200.1.19~0.1.21-c5=EF=BC=9A?= =?UTF-8?q?=E9=85=8D=E7=BD=91=E7=83=AD=E7=82=B9=EF=BC=88SoftAP=20+=20?= =?UTF-8?q?=E7=BD=91=E9=A1=B5=EF=BC=89=E3=80=81=E9=A3=8E=E6=89=87=2040=20?= =?UTF-8?q?=E2=84=83=20=E4=BB=A5=E4=B8=8B=E6=96=AD=E7=94=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0.1.19 配网热点:没配 WiFi / 连续 60 s 连不上时自己开 SoftAP,192.168.4.1 网页里加删 WiFi、改 MQTT; 配置拼成 wifi / mqtt 命令走唯一的命令处理器(新链路号 3 CMD_LINK_AP),MQTT 仍不收配置命令 - 0.1.20 热点口令固定 88888888 - 0.1.21 风扇:板温 >=40 ℃ 上电、<38 ℃ 断电,40~65 ℃ 线性调速;fan 命令加可选 sec(下限到点自动交回) - mqtt 命令加 keep_pass;net 回应加 ap;status 的 fan 加 left - 新增 test_prov(13 例),test_t_thermal +3;29 ctest 全绿 Co-Authored-By: Claude Opus 5.5 --- firmware/HANDOFF.md | 612 ++++++++++++++++++--------------- firmware/host/CMakeLists.txt | 1 + firmware/main/CMakeLists.txt | 9 +- firmware/main/cmd.h | 11 +- firmware/main/cmd_exec.c | 15 +- firmware/main/cmd_net.c | 34 +- firmware/main/cmd_report.c | 5 +- firmware/main/mlink_esp.c | 69 +++- firmware/main/mlink_esp.h | 4 +- firmware/main/netcfg.h | 11 +- firmware/main/phonerail.h | 2 +- firmware/main/prov.c | 206 +++++++++++ firmware/main/prov.h | 104 ++++++ firmware/main/prov.html | 92 +++++ firmware/main/prov_esp.c | 416 ++++++++++++++++++++++ firmware/main/prov_esp.h | 22 ++ firmware/main/t_thermal.c | 62 +++- firmware/main/t_thermal.h | 32 +- firmware/sdkconfig.defaults | 3 + firmware/test/test_cmd.c | 14 + firmware/test/test_prov.c | 350 +++++++++++++++++++ firmware/test/test_t_thermal.c | 154 ++++++++- 固件开发计划.md | 2 +- 需求文档.md | 19 +- 24 files changed, 1925 insertions(+), 324 deletions(-) create mode 100644 firmware/main/prov.c create mode 100644 firmware/main/prov.h create mode 100644 firmware/main/prov.html create mode 100644 firmware/main/prov_esp.c create mode 100644 firmware/main/prov_esp.h create mode 100644 firmware/test/test_prov.c diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index a19b63d..a2cecb9 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -1,283 +1,329 @@ -# 交接 -更新:2026-10-06 固件 **0.1.18-c5**(CH224A 档位进遥测);板 B 经 OTA 推上;rack 已部署 - -## 0.1.17 / 0.1.18:CH224A 档位实时进遥测(2026-10-06) -- 起因:rack 要显示 PD 供电信息,而档位只在 `bringup.c` 启动时读一次;板 B 历史上读到过 5 A → 3.25 A → 3 A。 -- 新模块 `pdmon.{h,c}`:每秒读 `0x09`(协议状态)+ `0x50`(档位电流),**只读**。先试上次应答的支路(主干 / mux 通道 5)。 - 档位变了(连续两拍一致、非 0、和日志里最后那条不同)记 `LOG_EV_PD_LIMIT`,**arg 2 = 运行中换档**(0 / 1 仍是启动那条)。 - 只报不动:`pd_limit_mw` 仍是启动时定的,降载(§8.3)照旧没人做。 -- status 新增 `"pd":{"st":136,"ma":3000}`,这一拍没读到是 `null`(满配 1164 / 1536 字节,`test_cmd` 的余量断言改成「至少留 256」)。 -- ⚠️ **0.1.17 放在热管理任务里读,板 B 实测 `safety.skipped` 每秒涨 1**(持锁那一下正好撞上巡检的下一拍,那一圈五路都没读)。 - 0.1.18 挪进巡检任务(`t_safety_step()` 之后、每 50 圈一次),`skipped` 停在启动时的 1 不再涨。别再挪出去。 -- 板 B 实测:`st` = 0x88(bit3 = PD 握上;bit7 手册没写,rack 只认已知位)、`ma` = 3000。两次 OTA 都是热重启,五路 en 全程保持。 -- 测试:新增 `test_pdmon`(6 例),`test_cmd` 加 `pd` 两条断言;**28 ctest 全绿**。rack 侧见 `rack/frontend/src/pages/Board.vue` 的「PD 供电」格。 - -## 0.1.15 / 0.1.16:开机长按 3 s 试验失败,所有开机操作统一 5 s(2026-09-30) -- 0.1.15 把 `ACT_KEY_MS_ON` 5000 → 3000:板 B 第 2 路 `power_cycle` 三轮全是「按住 330~390 mA、松手掉回 0」⇒ `no-boot`(事件 19); - 同一台手工 `key pwr 5000` 一次开起来(松手后稳在 ~250 mA)。⇒ 小米8 开机键 2 s、3 s 都不够,**别再往下调**。 -- 0.1.16 改回 5000;rack 单路「更多 → 开机」原来发的是 `ms:2000`(多半从来就开不了机),改成 5000、文案「长按电源 5 秒」。 -- 上板:第 3 路 `power_cycle` → hr-boot 5 s → 松键后 ~210 mA 平台 → `res ok`。 - -## 0.1.14:Hub 按需启停(2026-09-29,用户定「方案一:GPIO8 按住复位」) -- 起因:五路 VBUS 全关时,上行连着电脑 Hub 仍是活动态(第 6 口的 ESP32 USB 让电脑挂不起它),约多耗 1 W。 -- **推翻 R2 D13 代价 2 / R-11「运行期不许动 `HUB_RST#`」**,理由与退路见 `刀片式方案规划-R2.md` §8.2a、`main/hubctl.h` 文件头。 -- 新模块 `hubctl.{h,c}`:执行器任务每拍 `hubctl_tick()`(空闲时 ≤100 ms 一拍)。开的理由(按优先级) - `mode` 常开 > `vbus` 任一路 VBUS > `flash` 刷机模式 > `act` 执行器忙 > `hold` 保持窗口 > `link` MQTT 断满 60 s; - 都没有时再给 `idle` 10 分钟(`HUB_IDLE_MS`),从最后一次「需要」算起 —— 启动、`UP_VBUS_DET` 上升沿、USB 上来一条命令都算。 - 关 = GPIO8 配输出拉低;开 = 配成输入靠 R716 拉高,**从不驱高**。 -- **命令 15 `hub`**(两条链路都收,会动东西 ⇒ 必须带 seq): - `{"cmd":"hub","mode":"auto"|"on"}` 改模式(NVS 键 `hub_mode`,默认 auto); - `{"cmd":"hub","min":30}` 保持 1~240 分钟;`{"cmd":"hub","min":0}` 取消保持并结束 idle 窗口(没别的理由下一拍就关,量功耗用)。 - 两个键可同时给。回应带 `"hub":{...}`。 -- status 新增 `"hub":{"on":true,"mode":"auto","why":"vbus","hold":秒,"idle":秒}`(满配 1140 / 1536 字节)。 -- **事件 22 `LOG_EV_HUB`**(rail 0):arg 0 = 关、1 = 开、0x10|模式 = 改模式;bus_mv = 开的理由(`hub_why_t`,冻结: - 0 off / 1 mode / 2 vbus / 3 flash / 4 act / 5 hold / 6 link / 7 idle)。 -- `bringup.c` 的上电脉冲不变(仍只在 POWERON);脉冲后 `hubctl_init()` 把脚放成输入。 -- 测试:新增 `test_hubctl`(9 例:启动窗口、理由优先级、断网 60 s、电脑插上 / USB 命令刷新、保持与 min=0、 - 时钟回绕不复活、引脚从不写 1 且每次翻转只记一条、模式存盘、NVS 垃圾值回 auto),`test_cmd` +1(hub 命令)、 - `test_evlog` / 冻结编号同步;**27 ctest 全绿**;IDF 构建通过(0xf5960,余 68%)。 -- ✅ **板 B 实测(2026-09-29,0.1.14-c5,三路手机开着、VBUS 全关、上行插着电脑)**:Hub 开 / 关交替各 60 s, - 看「底板损耗 = 输入 − 三路主板合计」(手机电流 ±100 mA 的抖动会淹没输入电流):开 **1.77 W**,关 0.69 / 0.72 / 0.80 W ⇒ - **Hub 空转 ≈ 1.03 W**(12 V 输入电流 377 → 288 mA)。原始数据 `10.5.5.13:/root/phonerail-src/bench-hub-20260929.jsonl`, - 脚本 `bench-hub.sh` 同目录。`hub min:0` 下一拍就关、`hub min:3` 立刻开(why = hold),事件 22 各记一条。 -- ✅ **rack**:白名单放行 `hub`(mode auto/on、min 0~240);事件 22 命名(唤醒理由 / 休眠 / 模式改为…,mV 列置空); - 单板页「Hub」一行:状态 + 按需 / 常开 + 「唤醒 30 分」+「马上休眠」;模拟板同步。pytest 50 例,已部署。 -- ⏳ **没验证**:上行电脑不是本机也不是服务器,电脑那一侧 Hub 消失 / 重现、手机 VBUS 开后 adb 1~2 s 回来,都还没亲眼看过。 -- ⚠️ 现在起板 B 没人用 USB 满 10 分钟 COM 口就消失 —— 要 USB 烧录 / 看日志,先在 rack 点「唤醒 30 分」(或 MQTT 发 `hub min`)。 - -## 0.1.13:这一路断了电 ⇒ 刷机模式一并退出(2026-09-28) -- 起因:第 2 槽刀片带电拔走后,刷机模式还挂了约 48 分钟、占着「最多 2 路」的名额。`check_present` / SR-5 / 刀片不应答 / - mux 失联全断都是 t_safety 直接关 eFuse,不经过 `power` 命令,而只有 `power off` 会顺带退刷机模式。 -- 修:`cmd_chgmode_tick()`(执行器任务,chgmode 的唯一写者)每拍看一遍:刷机模式开着 + eFuse 关着 + 执行器不忙 ⇒ - 退出,记事件 21 arg = **254 `CHG_EV_DROPPED`**。执行器忙(硬重启 HR_OFF 那几秒 eFuse 本来就关)不算。 -- ⚠️ 254 带 0x80 位:rack `api.py` 事件命名里「断电自动退出」必须排在「加钟」前面判(已改 + 测试)。 -- 测试:`test_cmd` +1(硬重启中不退 / 关电后下一拍退、只记一次、名额让出);反向验证:去掉判断 ⇒ 红。26 ctest 全绿;rack 48 例全绿并已部署。 -- ⏳ 真板未专门验证(需要带电拔一次开着刷机模式的刀片)。 - -## 0.1.11 / 0.1.12:命令随机 busy / io;硬重启带着 VBUS(2026-09-28 刷机时发现) -- **总线锁误判(0.1.11)**:`i2cbus_xact()` 拿锁前查全局 `s_in_xact` 防嵌套 —— 别的任务在事务里时它也是 true。 - 执行器撞上巡检那 ~2.3 ms(每 20 ms,≈12%)就被判成「嵌套」立刻回 STATE ⇒ `key` 随机回 **busy**、`vbus` 随机回 **io** - (反过来巡检撞上执行器也会白跑一圈)。改成 `hal_lock_held(HAL_LOCK_I2C)`(**本任务**持锁才算嵌套)。 - 真板:第 3 路连开关 VBUS 40 次,0 失败。测试 `test_i2cbus` +1(另一任务持锁 ⇒ TIMEOUT 而不是 STATE;旧代码红)。 -- **硬重启先断 VBUS(0.1.11)+ 延后接回(0.1.12)**:VBUS 通着做 `power_cycle` 的三个后果现场全见过 —— - 手机靠 USB 撑着没断电(假重启);直供电流 ≈0 ⇒ 开机校验判「没开起来」再按两下;断电那刻带着 USB ⇒ 进关机充电,一拔 USB 就关机。 - 现在 `t_actuator_hard_reset()` 先断 VBUS,**开机校验通过后再等 `ACT_HR_VBUS_BACK_MS` = 60 s** 才接回 - (0.1.11 是校验一过就接回 ⇒ 手机还在 bootloader、关充电补丁没生效 ⇒ 13 s 顶高 4394 mV、SW-8 断 VBUS)。 - 等待期间执行器空闲;`vbus` / `power` 命令、再次硬重启、EDL、abort、这一路被关电 ⇒ 作废。没开起来 / 出错 ⇒ 不接回。 - 真板:第 3 路 VBUS 通 → 硬重启:hr-off 时 vb=false、14 s 校验通过、72 s 接回,无顶高事件。 - 测试 `test_t_actuator` +3(先断后接回、没开机不接回 / abort 不接回 / 本来没通不打开、等待期间被取消的三种)。26 ctest 全绿。 -- ~~没修的:第 2 槽刀片被拔走时刷机模式不退出~~ ⇒ 0.1.13 已修。 - -## 0.1.10:热插拔进来的刀片重新初始化(2026-09-28) -- 现象(板 B,新主板 + 没刷补丁):刀片**带电插**进槽 ⇒ C67(VBUS)= 5 V,换 U5 也一样;`vb:false`。 - 没刷补丁的手机被充到 4.4 V ⇒ SR-5 过压关路(反复 7 次)。**插好再给底板上电就正常**。 -- 根因:`slot_init()` / `ina226_init()` 只在开机 bringup 做。热插拔时 PCA9555 上电复位不干净(SDA/SCL 经上拉寄生供电), - `VBUS_EN` 被配成输出高、影子却是 off ⇒ SW-8 不武装;INA226 CALIB = 0 ⇒ **电流恒读 0**(第 2 路那段数据正是整 0)。 -- 修:`t_safety` 认 `PRSNT#` 下降沿,在位连续 `T_SAFETY_INSERT_N` = 5 圈(100 ms)后在本路锁内 `slot_init_locked()` + `ina226_init()`; - 失败隔 5 圈重试;拔走作废;开机在位的不重做。status `safety.inits` = 成功次数。 -- 测试 +2(`test_t_safety`:乱寄存器插入 ⇒ 第 5 圈恢复、VBUS 断、键全松、CALIB 写回、只做一次;NAK 重试 + 插一半拔走不碰空槽); - 反向验证:不置 need_init ⇒ 两例红。26 ctest 全绿。 -- ✅ 真板验证(用户现场):带电插刀片后 C67 = 0 V、`safety.inits` 0 → 1。 - -## 0.1.9:多组 WiFi(2026-09-28,用户要求) -- 起因:板 B 只存一组 WiFi(CANBOX-5G),热点一关就离线;而改配置只收 USB。 -- `netcfg_t.wifi[NETCFG_WIFI_N=4]`。`wifi` 命令 = **同名改口令、否则追加**(回 `"n":组数`;满了 `state`); - `{"cmd":"wifi","ssid":"X","del":true}` 删(没有这组回 `range`)。仍然只收 USB。 -- `net` 回应:`ssid` / `pass_set` = 正在连的那一组(`net_status_t.wifi_idx`),新增 `"ssids":[…]` 整张表。 -- 轮换(mlink_esp.c):没连上 ⇒ 换下一组;连上过又掉 ⇒ 先重试同一组;退避按「轮完一圈」算(`attempt / n`)。 - 改配置时正连着的那一组没被删、口令没变、broker 没变 ⇒ **WiFi / MQTT 都不断**(加备用热点不掉线)。 -- NVS:新键 `net2`(ver 2);读不到就读旧键 `net`(ver 1)迁移。**写时两个键都写**,`net` 放表里第一组 —— - OTA 回滚到 ≤0.1.8 仍连得上网。 -- 测试 +3(`test_net` 10 → 13 例:加 / 改同名、删 + 满表、NVS 空位压实);26 ctest 全绿。 -- 真板:烧录后 `ssids:["CANBOX-5G"]`(迁移成功)→ 加 `AAAAAABBBCCC`(用户给的名字,周围扫不到)→ - 加 `AAABBBCCC`(本机扫到的实际热点,同口令)⇒ 连上 10.5.5.58、MQTT up、服务器显示在线。 -- ⚠️ USB esptool 烧录 = 非计划重启 ⇒ 五路全断(第 1 路手机掉电)。第一次连接报 `ClearCommError`,COM5 重新枚举后重试即成。 - -## 0.1.7 / 0.1.8:反灌断 VBUS 阈值 −50 → −150 → −300 mA(2026-09-28,用户要求) -- 起因:刷机时重启过渡的瞬间反灌误断 VBUS,fastboot 断连。0.1.6 下实测 −59 mA;0.1.7(−150 mA)下 `adb reboot bootloader` 实测 **−208.5 mA** 仍断。 -- `T_SAFETY_REV_UA` = −300000;判据本身(一圈就断、不做确认窗口、不碰 eFuse、刷机模式不豁免)不变。 -- 测试:注入 −60 → −310 mA,「小电流不算反灌」−40 → −250 mA;26 ctest 全绿。0.1.8 下 `adb reboot bootloader` 过渡没再断 USB。 - -## 0.1.6:刷机模式默认 30 → 60 分钟 + 加钟(2026-09-28,用户要求) -- `CHG_MODE_DEFAULT_MIN` = 60。 -- 加钟:`{"cmd":"flash_mode","rail":1,"add":30}`(1~120,与 on / min 互斥)—— `chgmode_extend()` 只给**开着的**窗口加 - (没开 / 宽限期里回 `state`:那是重新开,不是加钟),加完剩余截断到 120 分钟;回 `"left":秒`。 - 事件 21 arg = `CHG_EV_EXTEND`(0x80)| 分钟。rack:「更多 → 刷机模式加钟 30 分钟」(只在开着时出现)。 -- 测试 +2(`test_cmd` 加钟全路径、`test_t_safety_rules` 截断与宽限期拒绝),rack 48 例。真板:没开加钟被拒 / 开 3600 / 加钟后 5399 / 关。 - -## 0.1.5:刷机模式(2026-09-28,用户定「做,最多同时 2 路」) -- **起因**:fastboot / bootloader 阶段内核补丁还没起来,手机充电器把直供节点顶到浮充 ~4.4 V(板 B 实测 4395 mV), - SW-8 立刻断 VBUS ⇒ fastboot 刷到一半断连。历史 5 次顶高全是只断 VBUS、**0 次**关路。 -- **依据**:每路 eFuse TPS25940A 自带反向阻断(V(IN)−V(OUT) < −9.3 mV 断开),顶高时能量进不了 Buck / 12V 母线; - 板 B 五次顶高实测电流 0 ~ −0.5 mA。 -- **新模块 `chgmode.{h,c}`**:每路一个截止时刻。**只有执行器任务写**(命令 + `cmd_chgmode_tick()` 到期清理), - 巡检只读;tick 必须清标志,否则 32 位毫秒时钟 ~24.8 天后过期窗口会「复活」。 -- **判据(`t_safety.c judge_locked`)**:窗口内「顶高断 VBUS」与 SR-5 过压都按绝对值 `CHG_MODE_MAX_MV` = 4450 判; - 窗口后 1 s 宽限期里 SW-8 回到按设定值(立刻断 VBUS),SR-5 仍按绝对值(节点往回落的几 ms 不许误关路)。 - 反灌(< −50 mA)与欠压判据**不变**。 -- **命令 14 `flash_mode`**:`{"rail":1,"on":true,"min":60}`(默认 60、1~120 分钟;0.1.6 由 30 改 60),回 `"left":秒`; - **最多同时 2 路**(第 3 路回 `state`);关着电不许开(`state`);`power` 关电时一并退出。status 每路多 `"chg":秒`。 -- **事件 21 `LOG_EV_CHG_MODE`**:arg = 分钟(开)/ 0(手动关)/ 255(到期)。 -- rack:白名单 + 事件名 + 单板页「更多 → 刷机模式(1 小时)」(带确认),状态标签显示剩余分钟;模拟板同步。 -- 测试:`test_t_safety_rules` +10(绝对上限、反灌 / 欠压不变、按路、到期断 VBUS 不关路、宽限期后恢复、2 路上限、时钟回绕), - `test_cmd` +3;26 ctest 全绿;rack 46 例。反向验证:去掉窗口内豁免 → 4 例红;tick 不清标志 → 回绕用例红。 -- ✅ **台架实测(2026-09-28,板 B 第 1 路,0.1.6-c5)**,原始数据 `10.5.5.13:/root/phonerail-src/bench-flashmode-20260928.tsv`: - · 开模式 + VBUS 通 → `adb reboot bootloader`,6 s 进 fastboot,**fastboot 下确实在充电**:节点 3864~4395 mV 锯齿 - (充到浮充即停、手机耗下来再充,周期 3~4 s),**VBUS 全程没断**、fastboot 一直在线(`getvar` 正常); - · 该路电流 −1.0 ~ +1.5 mA(eFuse 反向阻断干净),12V 母线 12.029~12.031 V 不动,输入电流 160 → 230 mA(+0.8 W,5V 负担很小); - · **FLT 在反向阻断期间一直为 true**(与 RCB 同进同退,节点跌到 3864 仍为 true = 要 +100 mV 才重新导通), - 固件对 FLT 只上报不动作 ⇒ 无事件。是 TPS25940A 的 RCB 指示,不是故障。rack 已改:chg>0 时这一路显示蓝色「刷机模式 · 充电中」、FLT 标签变中性「反向阻断」(模式外 FLT 仍标红); - · 对照:充电中关模式 → 当圈 PUSHED_UP 断 VBUS(读数 4034 mV),**不关路**(宽限期),Buck 立刻接手 3917 mV / 240 mA;FLT 同时消失; - · 重开模式 + VBUS → 2 s 回 fastboot。`fastboot getvar unlocked: yes`(BL 确已解锁); - · ⚠️ **`fastboot reboot` 回 MIUI 的开机过程中出现 −59 mA 反灌 ⇒ SW-8 断 VBUS**(事件 290)。机理:开机大负载时 Buck 在连续导通, - 节点只比设定高几 mV、没到 eFuse 的 −9.3 mV 阻断门限,充电器灌进来的电被 Buck 反向吸收。12V 母线没动(12.029~12.031)。 - **反灌判据保留是对的**;实际影响只是「重启过渡时 USB 可能被断,进系统后再开 VBUS」。 - -## 0.1.4:整片刀片不应答 ⇒ 关这一路(2026-09-28,用户拍板) -- `t_safety` 每路锁内:eFuse 开着、本路 INA226 **和** PCA9555 同时 NAK,连续 `T_SAFETY_BLADE_MUTE_N` = 10 圈(200 ms) - ⇒ 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)。 - 只进遥测、不参与判据。rack 单板页显示「上游:已连电脑 / 未连电脑」。 -- 起因:面板「损耗」在 ~0.5 W / ~1.6 W 两档间跳,与刀片、手机无关;高档时 NTC1(U31 旁)高 6~10 °C ⇒ USB2517I 工作 - (+ AMS1117 线性降压)约 1.1 W。⚠️ `host` 是 VBUS 在不在,不是 Hub 是否挂起:电脑休眠时多半仍是 true。 -- ⚠️ 热重启后 DAC 设定点回默认(实测 set 4010 → 3900 mV,输出 4.04 → 3.93 V)—— 供电保住了,设定没保住。 -- **板 B 第 2 槽 `PRSNT2#` 卡低**(09-28 02:09 第一次插刀片起,拔走后仍报在位,跨多次重启):硬件问题,待量 PRSNT2#(R607 / U20.9)对地、 - 看插座 B31 弹片。副作用:每圈 `i2c_err` +2;02:13~06:25 空槽 eFuse 开着约 4 h(见 0.1.4 的判据)。 - -## 0.1.2:硬重启按完电源键之后看电流,没开就重按(2026-09-28) -- **起因**:板 B 第 1 路 7 次 power_cycle 只开起来 1 次,失败时电流 1~2 mA、屏不亮;PCA9555 按键输出确实置位(命令都 ok), - **镊子短接 J603 能开** ⇒ R2 电源键是主板弹片压刀片铜盘(决策 14),接触时好时坏。这是机械问题,固件只做兜底。 -- **做法**(`t_actuator`,新状态 `hr-verify`):按满 5 s → 松键 → **松键后 3~5 s** 看电流,连续 5 个样本(0.5 s)≥ 100 mA 算开了; - 否则重按,**一共最多 3 次**;3 次都不行结果 `no-boot`,**电继续开着**。读不到电流不重按(对正在开机的手机再长按会进关机菜单)。 - 为什么 3 s 后才看:「按 2 s」那种 PMIC 醒了、SoC 没接住的情况,松手后 415 mA 撑约 3 s 才掉(宿主机测试把判定延迟改回 1 s 会被它骗)。 -- 电流与 PG 一样从巡检快照喂进来(`t_actuator_set_boot_check()`,app_main 接);**不接就是 0.1.1 的行为**,老测试一行没改。 -- 新事件 **19 `LOG_EV_BOOT_CHECK`**「开机重按」:arg 低 4 位 = 按了几次,bit7 = 最终没开起来;一次就开的不记。 - rack 已同步(名称「按了 N 次,开起来了 / 没开起来」,只有没开起来标红),已部署。 -- 手动 `key` 命令不校验、不重按(人的意图)。宿主机 26 ctest 全绿(`test_t_actuator` 30 → 37 例),rack 43 例全绿。 -- ⚠️ 实机只验到「第一下就开」(接触已恢复),重按路径靠宿主机测试。频繁出现事件 19 = 那一台弹片要处理。 - -## c4:两处修复(2026-09-28,第一片刀片 + 小米8 上板时发现) -- **硬重启后手机不开机**:电池端上电的小米8 停在关机态(只漏 ~2 mA)。现在 `power_cycle` 等到 PG 后 - **长按电源键 5 s**(新状态 `hr-boot`,`ACT_HR_BOOT_KEY_MS`);NO_PG 时不按。`ACT_KEY_MS_ON` 2000 → **5000**: - 实测按 2 s 时 PMIC 起来了(415 mA),一松手就掉回 1 mA。需求 SW-5 已同步。rack 的二级恢复(power_cycle)因此真的能拉起手机了。 - 上板:power_cycle → 4.1 s 上电 → hr-boot 5 s → res ok,手机进入开机电流曲线。 -- **热点一断整板失联(死锁)**:执行器「持 `s_mtx` → `esp_mqtt_client_enqueue` 等 `api_lock`」, - esp-mqtt 任务「持 `api_lock` 分发 DISCONNECTED → `on_mqtt` 的 `ST_EDIT` 等 `s_mtx`」。 - 现象:USB 命令无应答、WiFi 不重连、遥测停,只有 t_safety / t_thermal 的日志还在 —— **按到一半的键也会松不开**。 - 修:执行器只往 `s_outbox`(深度 4)拷一份,`t_link` 里 `outbox_drain()` 交给 esp-mqtt;持 `s_mtx` 时不调 esp-mqtt。 - ⚠️ 死锁状态下 OTA 进不去,只能 USB 烧(esptool `--before usb_reset`,第一次常报 ClearCommError,再跑一次就过)。 -- **c5:带载欠压误关**。小米8 插屏幕后开机峰值 ~2.1 A,轨 1 读回 3785 mV(设定 3900,−115 mV),SR-5 的 −100 mV 直接关路。 - 用户拍板:**欠压一侧放宽到 −250 mV**(`T_SAFETY_UNDER_MV`),过压仍 +100 mV;需求 SR-5 / SW-2 / 验收表同步。 - 压降 ≈ 55 mΩ × 2 A 的来源(Buck 负载调整 / eFuse / 采样电阻 / 走线)还没拆开查。 - -## 一句话现状 -OTA 换固件 / 超时回滚现在走 `warm_restart()`(`main/warm_esp.c`):目标固件认接管 ⇒ 手机不断电; -崩溃 / 看门狗 / 上电 / 兜底回滚仍全关(SR-6)。**26 个 ctest、448 个用例全绿**。板 B 跑 **0.1.2-c5**(经 OTA 推上去的)。 -定稿写在需求 §10.9「重启不断电」。C2 OTA 的说明在下面。 - -## 0.1.1:I²C 不应答被报成 STATE(真板独有,sim 测不出) -- 根因:IDF 5.4 的 `i2c_master_transmit/receive/transmit_receive` 在**从机 NACK / SCL 超时**时返回 `ESP_ERR_INVALID_STATE`, - HAL 照翻成 `HAL_ERR_STATE`(= 没 init)。于是:I2C_LOST 事件的错误码是 5;命令层 `from_hal` 又把 STATE 当成「执行器忙」⇒ - PCA9555 不应答时 `power off` 回 `busy`(eFuse 其实已经关了、意图也清了),上位机会以为没执行。 -- 修:`hal_i2c_esp.c` 传输函数用 `map_xfer_err`(INVALID_STATE → NAK);`cmd_exec.c` 新增 `from_bus`, - I²C / GPIO 路径(set_voltage / vbus / power)失败一律 `io`,`from_hal` 只留给执行器。 -- 测试:sim 加 `sim_i2c_inject_err_ch()`(指定注入的错误码);`test_cmd` 加一段 STATE 注入 ⇒ 必须是 io 不是 busy - (把关电那行改回 from_hal 时这段确实红,回应就是台架上看到的 `{"err":"busy"}`)。 -- 上板(台架镜像,CH1 强制在位):`power off` 回 `io`、CH1 关掉;I2C_LOST 的错误码 5 → **2**。 - -## 重启不断电(c3) -- 三道闸:RTC 标记(软件复位 + 魔数 / 反码,一次性)· 目标镜像能力标记(第 288 字节 `PRCAPS1`,老固件不认就不锁)· 起来后读 EN 真电平核对。 -- `rail_en_init_keep(adopt)` **五根一律放开 hold**:崩溃时上一条命的锁可能还在。 -- `pca9555_init_keep()`:方向寄存器是我们写的才留 VBUS;键 / EDL 清零。 -- 实测:台架镜像(空槽强制 CH1 在位,**没提交**)开 CH1 → OTA 带重启 → 事件 `18/7 mv=1`(新固件读到 IO9 仍为高、接管)。 - ⚠️ 没测到的:示波器上看 EN 在重启期间有没有毛刺(`warm_take` 读到高只能说明「起来时还是高」); - 「锁着 + 非计划复位」这条路径只有宿主机测试(真板上造不出来)。插上刀片 + 手机后要看一次手机有没有掉电重启。 -- **第一次从 c2 升到 c3 仍会断电一次**:锁 EN 的是**正在跑**的固件,c2 不会锁。 - -## 下一步(没人定,候选) -- 示波器验证:插一片刀片,CH 开着,OTA 带重启,抓 EN / 输出电压(`scope_*` 工具)。 -- 在手机上看一眼 rack 新界面(390 px 左右):无头 Chrome 最窄只到 ~500 px,窄屏只验到那里。 -- 下面「其它挂着的事」里 A15 / A16 留下的几条(I²C 总线挂死恢复、PD 降载、风扇阈值实测)照旧。 - -## rack 侧(服务器前后端,详见 `rack/README.md`) -- **固件页**(顶栏「固件」):上传 `build/phonerail.bin` → 选板推送;上传时核镜像头(非本工程 / 非 app 镜像 / >3 MB 直接拒), - 显示每个镜像「重启不断电:支持 / 不支持」,勾「立刻重启」时按它给不同提示。通用 `/cmd` 不放行 `ota`(URL / 哈希只能由固件库生成)。 -- **下载目录自动清理**(每小时):库里登记的不删;`ota_push.py` 直传的放满 7 天删;半截 `.tmp` 1 小时删。 -- **界面改版**:仪器前面板风格 + 暗色模式(跟随系统);总览每个槽带最近两分钟电流小曲线(平线 = 疑似卡死)。 -- **底板温度曲线**(单板页):两路 NTC + 风扇占空比,10 分钟 ~ 7 天;后端 `board_tele_min` 分钟聚合留 90 天, - 接口 `GET /api/boards/{id}/history`。NTC1 贴 Hub 稳压 U31,NTC2 贴输入降压 U37。 - -## 踩到的坑(c3) -- `.rodata_custom_desc` 没人引用会被 `--gc-sections` 丢掉 ⇒ `main/CMakeLists.txt` 加 `-u g_phonerail_caps`。 -- `PROJECT_VER` 在 CMake 配置时读 ⇒ 改 `phonerail.h` 不会重配,镜像头版本会是旧的 ⇒ 顶层加了 `CMAKE_CONFIGURE_DEPENDS`。 -- 撤掉台架补丁后要**重新编译**再推:`build/phonerail.bin` 还是台架版(这次推错了一次,已换回)。 -- Git Bash heredoc 里的 Python 写 `\0` 又变成真 NUL 了(这次在 rack/firmware.py)—— 含反斜杠的一律写 .py 文件。 - -## 怎么推固件 -``` -python tools/ota_push.py b81f3fc9c8bc # 推 build/phonerail.bin:只下载 + 校验 + 切分区,下次复位生效 -python tools/ota_push.py b81f3fc9c8bc --reboot # 校验过就立刻重启(板上 ≥c3 且目标镜像认接管 ⇒ 手机不断电,否则五路全断一次) -python tools/ota_push.py b81f3fc9c8bc --status -``` -它把镜像传到服务器 `/root/phonerail/fw/.bin`(nginx 7765,只放行这种文件名,不过门户; -**不进固件库,7 天后 rack 后端自动删** —— 要长期留着就从界面上传), -再在服务器上用后端账号经 1884 发命令、盯遥测。 -**rack 管理界面「固件」页也能推**(2026-09-27 加,上传 → 选板 → 可选立刻重启并二次确认;见 `rack/README.md`「推固件」)。 - - -## C2 改了什么 -- `partitions.csv` 重排(16 MB):**nvs 0x9000 没动**;ota_0 / ota_1 各 3 MB;otadata 0x610000;evlog 挪到 0x612000(历史事件清空了一次)。 -- `sdkconfig.defaults` + `sdkconfig`:flash 16 MB(之前误写 8 MB);`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`(bootloader 配置,只有 USB 整片烧才生效)。 -- `main/ota.{h,c}` **新**(宿主机可测):URL / 哈希校验、判定(busy / 待验证拒 / 同镜像不重下)、健康判据。 -- `main/ota_esp.{h,c}` **新**:`t_ota` 任务(esp_http_client + mbedtls sha256 + `OTA_WITH_SEQUENTIAL_WRITES`,每块让一个 tick)、 - 核 project_name、切分区;`t_link` 里判健康 / 到点重启(先等 2 s 让回应发出去、事件落盘);esp_timer 150 s 兜底回滚。 -- `cmd` 新命令 `ota`=13(`cmd_net.c` 的 `do_ota`);status 多 `ota` 一块;`evlog` 新事件 `LOG_EV_OTA`=18。 -- 顶层 `CMakeLists.txt`:`PROJECT_VER` 取 `PHONERAIL_FW_VERSION`(否则镜像头版本是 git describe,回滚后 `ota.bad` 报 `79c9f8b-dirty`)。 -- `test/test_ota.c` **新**(13 例);`test_cmd` / `test_evlog` 的冻结数字表各加一行。 -- `tools/ota_push.py` **新**;`rack/deploy/nginx-phonerail-fw.conf` **新**(已部署);`rack/backend/prrack/protocol.py` 加事件 18 / 命令 13(已部署)。 - -## 上板实测(板 B,五槽空) -``` -正常:c2 → c2-t1 经 MQTT --reboot:下载 ≈8 s,推送到自证健康 19 s;事件 18/1 → 18/2 → 18/6 → BOOT → 18/4 -哈希错:err "hash"、got 996064、固件不变;下载 20 s 里 t_safety skipped 0 -回滚:服务器删掉板子 broker 账号 → USB 发同镜像 reboot:true → 起在 c2-rb(pend true)→ 120 075 ms 记 18/5 → 回到 c2-t1、bad 报版本 - (账号已原样恢复,备份已删) -收尾:c2-t1 → 正式 0.1.0-c2 经 MQTT --reboot 成功 -``` - -## 踩到的坑(只写会再犯的) -- **路由器不是整机 DMZ**:新开的 7765 公网 TCP 都连不上,用户手动加了转发才通(本机测公网要 `curl --noproxy '*'`, - 否则 7890 代理回的 502 会被当成服务器的)。 -- 本机 PowerShell `Set-Content -Encoding ascii` 改 `phonerail.h` 把中文注释写成 `??`(已 `git checkout` 还原)—— 改它一律用 sed。 -- Git Bash heredoc 里的 Python 写 `'\0'` 会变成真 NUL 字节进源码;含反斜杠的替换写成 .py 文件再跑。 -- 宿主机 `bash host/test.sh` 用 `-j 4` 在 cygwin 上偶发「link: Subprocess aborted」,`cmake --build build -j 1` 就过。 - -## 其它挂着的事 -- **风扇还没下单购买**(2026-09-27):板 B 两路报 `rpm 0` + 停转是预期的。到货插上后看 `status.fan.rpm`;在转却仍是 0 再查 Q9/Q10 与 PCNT。 -- 风扇闭环四个阈值 + 占空比下限 + PPR 都是「待上板实测」(t_thermal.h),手机上架跑满载之后再定。 -- A15 留下的:I²C 总线挂死恢复(HAL 层);PD 上限的降载动作(§8.3);CH224A 读到 100 W 与早上记的 20V/3A 不一致,待核。 - -## 本次新增/修改的文件 -- `main/ntc.{h,c}` **新**(宿主机可测)— mV → Ω → 查 Murata NCP18XH103F03RB 的 R-T 表(−40~125 °C、5 °C 一格、格内线性插值); - 贴 +3V3 / 贴地 / 表外一律「读不到」(⇒ t_thermal 满速) -- `main/thermal_esp.{h,c}` **新**(只进 IDF)— ADC1 oneshot + 曲线拟合校准(8 次平均,引脚反查通道);两个 PCNT 单元数 TACH 上升沿(累计计数、1 µs 毛刺滤波) -- `main/app_main.c` — `task_thermal` 任务体(每 10 s 打一行温度 / mV / 占空比 / 转速);`thermal_esp_init()` + `t_thermal_init()` 放在**上电恢复之前**;`cmd_env.thermal = &s_therm` -- `main/CMakeLists.txt` REQUIRES 加 `esp_adc esp_driver_pcnt`;`host/CMakeLists.txt` 加 `ntc.c` -- `test/test_ntc.c` **新**(4 例:表点、⭐方向「越热电压越低」、⭐开路/短路不是温度、比例式);版本 `0.1.0-a16` - -## 上板实测(板 B,五槽空,风扇状态未知) -``` -status: "temp":[38249,34027] 分压端 1266 / 1384 mV ← 两路 NTC 读数合理(NTC1 在电感区更热) - "fan":{"duty":300,"en":true,"rpm":[0,0],"stall":[true,true]} -events: [31, 14318, 0, 11(FAN_STALL), 0] [32, …, 11, 1] ← 启动宽限 5 s + 一个窗口之后各报一次,不刷屏 -``` - -## 踩到的坑(只写会再犯的) -- 比例式 NTC 的电源误差估算:+3V3 偏 1 % 在 25 °C 附近是 **~0.6 °C**(中点处电阻误差是电源误差的 2 倍、NTC 每度 ~4 %),不是 0.3 °C。 -- PowerShell 5.1 的 `Set-Content -Encoding utf8` 会写 BOM —— 改 `phonerail.h` 这种文件用 sed,别用它。 -- `python` 在 Git Bash 里是 Windows Store 占位程序,会挂住;一律用 `/c/Python314/python`。 +# 交接 +更新:2026-10-06 固件 **0.1.21-c5**(风扇 40 ℃ 以下断电);板 B 已 OTA 推上,新板 b81f3fc85d2c 还是 0.1.20(热重启,旧 WiFi / MQTT 配置仍在,热点 `PhoneRail-c9c8bc` 待机);**热点本身还没实触发验证**;rack 已部署 + +## 0.1.21:风扇 40 ℃ 以下断电 + 带时限的下限(2026-10-06,用户要求) +- **凉了断电**(`t_thermal.{h,c}`):两路 NTC 取高,≥40 ℃(`T_THERMAL_FAN_ON_MC`)给 `FAN_EN`,<38 ℃(`FAN_OFF_MC`)撤; + 中间维持原状(2 ℃ 迟滞,板温就在 40 ℃ 上下晃)。40→65 ℃ 仍是 30 %→100 % 的线性曲线。 + **一律开着的三种情况**:温度读不到、上位机给了下限、启动后读到第一个温度之前那一秒。 + 断电期间不判停转、转速报 0、`duty` 报 0;`stalled` 保持断电前的结论(没插的那只不会每次启停各记一条事件)。重新上电先过 5 s 启动宽限。 +- **`fan` 命令加可选 `sec`**(0~86400):下限保持这么多秒后自动回 0;不给 = 一直保持(老行为)。回应带 `sec` 时才回 `"sec"`。 + status 的 `fan` 多一项 `"left"`(下限还剩几秒,0 = 没时限)。 +- 测试:`test_t_thermal` +3(迟滞启停、启停不误报停转、凉着也得转的两种情况 + 时限到期 / 续期 / 回绕),`test_cmd` 加 `sec` 的断言; + 原来拿 25 ℃ 当「凉但在转」的用例改用 40 ℃。**29 ctest 全绿**;IDF 0xfe420。 +- ✅ 板 B(OTA,热重启,五路 en 保持):板温 30 / 32 ℃ ⇒ `en:false, duty:0, rpm:[0,0], stall:[false,false]`; + `fan duty 500 sec 5` ⇒ 立刻 `en:true, duty:500, left:3`,6 s 后自己回到 `en:false`。 +- ⏳ 新板 `b81f3fc85d2c`(风扇真能转的那块)推的时候不在线,**还是 0.1.20** —— 上电后补推,并看一次真风扇在 38~40 ℃ 之间启停。 +- rack:白名单放行 `fan` 的 `sec`(0 / 不给不下发这个键)、单板页风扇断电显示「停」、下限选项加 30 %;59 pytest,已部署。 +- AppTask(`D:\AppTask`):转发口白名单加 `fan`;App「机架」页点「风扇」那一格开面板 + (交回自动 / 不低于 30·50·70·100 % 保持 30 分钟 / 满速一直)。**只编译过、APK 已打出,后端和 App 都还没部署。** +- ⚠️ **风扇不转的根因(10-06 新板上现场量出)**:J26.2 只有 1.7 V、Q7 栅极 11.3 V = 比 12 V 低一个二极管压降 ⇒ + 怀疑 **DZ3 贴反 / 封装极性反**(正向导通把 Vgs 钳在 −0.7 V)。用户处理后新板风扇转了(300 ‰ ≈800 rpm,满速 ≈3850 rpm,+120 mA), + **具体怎么修的没确认**;板 B 同样不转,待同样处理。第 2 路(J27)转速一直 0,是没插还是没转也没确认。 + +## 0.1.20:热点口令固定为 88888888(2026-10-06,用户要求) +- `PROV_PASS`(prov.h)编在固件里,所有板子一样;0.1.19 的随机口令、NVS 键 `ap_pass`(板 B 上还留着,没人读了)、 + `prov_pass_from/ok` 全部删掉。`net` 的 `ap.pass` 照报。代价写在 prov.h「安全」:口令公开,门只剩「没网时才开 + 人在无线范围内」。 +- 29 ctest 全绿;IDF 0xfe250;板 B OTA 后 `ap.pass` = 88888888。 +- ⚠️ 推 0.1.20 的下载途中(重启前约 26 s,当时跑的是 0.1.19)**第 1 路 `PRSNT#` 变高**(pr 1→0,eFuse 随之关),之后一直不在位; + 其余四路没动。是有人拔了刀片还是接触问题,没查清 —— 重启后启动事件 15(刀片不在位)rail 1。 + +## 0.1.19:配网热点 SoftAP + 网页(2026-10-06,用户定「连 WiFi 失败后默认回到配网模式」) +- 起因:改网络配置只有 USB 一条路;路由器换口令 / 板子搬地方就得拆机插线,Hub 休眠后连 COM 口都没有。 +- **判据**(`prov.{h,c}`,宿主机可测):没配 WiFi ⇒ 立刻开;连续 60 s 没地址 ⇒ 开;开着不设超时; + STA 连上 ⇒ 没人立刻关 / 有人留 60 s。热点上有人且 5 分钟内动过网页 ⇒ STA 重连间隔 ≥20 s(只放慢不停)。 +- **胶水**(`prov_esp.c`):`STA ↔ APSTA` 切换;esp_http_server(`/` 网页、`/s` 状态、`/scan`、`/c` 提交,其余 302 回 `/`); + DNS 劫持任务(手机连上自动弹页)。热点 `PhoneRail-`,WPA2,口令随机 10 位存 NVS 键 `ap_pass`。 + 网页是 `main/prov.html`(EMBED_TXTFILES)。只应答本地地址 = 热点地址的连接(linger 那一分钟里路由器一侧进不来)。 +- **配置怎么进**:网页 POST 按行的纯文本 → `prov_build_line()` 拼成 `wifi` / `mqtt` 命令 → 命令队列(新链路号 **3 `CMD_LINK_AP`**)→ + 唯一的命令处理器。`wifi` / `mqtt` 现在是「不收 MQTT」(USB 与热点都收)。seq 用随机数。 +- `mqtt` 新增 `"keep_pass":true`(口令沿用已存的;网页口令留空就发它)。`net` 新增 `"ap":{"on","why","sta","ssid","pass"}`。 +- mlink_esp.c 顺带两处:`esp_wifi_connect()` 当场被拒(网页正在扫描)⇒ 3 s 后自己重排,免得重连链断掉; + 没连着时改配置 ⇒ **刚加 / 刚改口令的那组先试**(原来是接着试 `s_idx`)。 +- sdkconfig:`CONFIG_HTTPD_MAX_REQ_HDR_LEN` 512 → 1024(手机浏览器的请求头超 512 会回 431)。 + ⚠️ `sdkconfig` 不进 git,只改 `sdkconfig.defaults` 对已有的构建目录不生效 —— 本机那份已手改。 +- 测试:新增 `test_prov`(13 例:开 / 关 / 留 / 放慢、回绕、表单拼命令与拒绝、keep_pass、链路口径、net 的 ap);**29 ctest 全绿**; + IDF 构建通过(0xfe490,余 67%)。 +- ⏳ **真板一项都没验**:热点起不起得来、手机弹不弹页、APSTA 下 STA 重连对网页的影响、扫描、提交后连上并自动关热点、 + OTA 后旧配置仍在(NVS 布局没动,只多一个键)。OTA 有 120 s 自证回滚兜底(MQTT 连不上就回 0.1.18)。 +- 没做:~~热点口令不能改~~(0.1.20 改成固定值);rack 界面还没显示 `ap`(要看口令得发一条 `net`);开关热点不记事件日志。 + +## 0.1.17 / 0.1.18:CH224A 档位实时进遥测(2026-10-06) +- 起因:rack 要显示 PD 供电信息,而档位只在 `bringup.c` 启动时读一次;板 B 历史上读到过 5 A → 3.25 A → 3 A。 +- 新模块 `pdmon.{h,c}`:每秒读 `0x09`(协议状态)+ `0x50`(档位电流),**只读**。先试上次应答的支路(主干 / mux 通道 5)。 + 档位变了(连续两拍一致、非 0、和日志里最后那条不同)记 `LOG_EV_PD_LIMIT`,**arg 2 = 运行中换档**(0 / 1 仍是启动那条)。 + 只报不动:`pd_limit_mw` 仍是启动时定的,降载(§8.3)照旧没人做。 +- status 新增 `"pd":{"st":136,"ma":3000}`,这一拍没读到是 `null`(满配 1164 / 1536 字节,`test_cmd` 的余量断言改成「至少留 256」)。 +- ⚠️ **0.1.17 放在热管理任务里读,板 B 实测 `safety.skipped` 每秒涨 1**(持锁那一下正好撞上巡检的下一拍,那一圈五路都没读)。 + 0.1.18 挪进巡检任务(`t_safety_step()` 之后、每 50 圈一次),`skipped` 停在启动时的 1 不再涨。别再挪出去。 +- 板 B 实测:`st` = 0x88(bit3 = PD 握上;bit7 手册没写,rack 只认已知位)、`ma` = 3000。两次 OTA 都是热重启,五路 en 全程保持。 +- 测试:新增 `test_pdmon`(6 例),`test_cmd` 加 `pd` 两条断言;**28 ctest 全绿**。rack 侧见 `rack/frontend/src/pages/Board.vue` 的「PD 供电」格。 + +## 0.1.15 / 0.1.16:开机长按 3 s 试验失败,所有开机操作统一 5 s(2026-09-30) +- 0.1.15 把 `ACT_KEY_MS_ON` 5000 → 3000:板 B 第 2 路 `power_cycle` 三轮全是「按住 330~390 mA、松手掉回 0」⇒ `no-boot`(事件 19); + 同一台手工 `key pwr 5000` 一次开起来(松手后稳在 ~250 mA)。⇒ 小米8 开机键 2 s、3 s 都不够,**别再往下调**。 +- 0.1.16 改回 5000;rack 单路「更多 → 开机」原来发的是 `ms:2000`(多半从来就开不了机),改成 5000、文案「长按电源 5 秒」。 +- 上板:第 3 路 `power_cycle` → hr-boot 5 s → 松键后 ~210 mA 平台 → `res ok`。 + +## 0.1.14:Hub 按需启停(2026-09-29,用户定「方案一:GPIO8 按住复位」) +- 起因:五路 VBUS 全关时,上行连着电脑 Hub 仍是活动态(第 6 口的 ESP32 USB 让电脑挂不起它),约多耗 1 W。 +- **推翻 R2 D13 代价 2 / R-11「运行期不许动 `HUB_RST#`」**,理由与退路见 `刀片式方案规划-R2.md` §8.2a、`main/hubctl.h` 文件头。 +- 新模块 `hubctl.{h,c}`:执行器任务每拍 `hubctl_tick()`(空闲时 ≤100 ms 一拍)。开的理由(按优先级) + `mode` 常开 > `vbus` 任一路 VBUS > `flash` 刷机模式 > `act` 执行器忙 > `hold` 保持窗口 > `link` MQTT 断满 60 s; + 都没有时再给 `idle` 10 分钟(`HUB_IDLE_MS`),从最后一次「需要」算起 —— 启动、`UP_VBUS_DET` 上升沿、USB 上来一条命令都算。 + 关 = GPIO8 配输出拉低;开 = 配成输入靠 R716 拉高,**从不驱高**。 +- **命令 15 `hub`**(两条链路都收,会动东西 ⇒ 必须带 seq): + `{"cmd":"hub","mode":"auto"|"on"}` 改模式(NVS 键 `hub_mode`,默认 auto); + `{"cmd":"hub","min":30}` 保持 1~240 分钟;`{"cmd":"hub","min":0}` 取消保持并结束 idle 窗口(没别的理由下一拍就关,量功耗用)。 + 两个键可同时给。回应带 `"hub":{...}`。 +- status 新增 `"hub":{"on":true,"mode":"auto","why":"vbus","hold":秒,"idle":秒}`(满配 1140 / 1536 字节)。 +- **事件 22 `LOG_EV_HUB`**(rail 0):arg 0 = 关、1 = 开、0x10|模式 = 改模式;bus_mv = 开的理由(`hub_why_t`,冻结: + 0 off / 1 mode / 2 vbus / 3 flash / 4 act / 5 hold / 6 link / 7 idle)。 +- `bringup.c` 的上电脉冲不变(仍只在 POWERON);脉冲后 `hubctl_init()` 把脚放成输入。 +- 测试:新增 `test_hubctl`(9 例:启动窗口、理由优先级、断网 60 s、电脑插上 / USB 命令刷新、保持与 min=0、 + 时钟回绕不复活、引脚从不写 1 且每次翻转只记一条、模式存盘、NVS 垃圾值回 auto),`test_cmd` +1(hub 命令)、 + `test_evlog` / 冻结编号同步;**27 ctest 全绿**;IDF 构建通过(0xf5960,余 68%)。 +- ✅ **板 B 实测(2026-09-29,0.1.14-c5,三路手机开着、VBUS 全关、上行插着电脑)**:Hub 开 / 关交替各 60 s, + 看「底板损耗 = 输入 − 三路主板合计」(手机电流 ±100 mA 的抖动会淹没输入电流):开 **1.77 W**,关 0.69 / 0.72 / 0.80 W ⇒ + **Hub 空转 ≈ 1.03 W**(12 V 输入电流 377 → 288 mA)。原始数据 `10.5.5.13:/root/phonerail-src/bench-hub-20260929.jsonl`, + 脚本 `bench-hub.sh` 同目录。`hub min:0` 下一拍就关、`hub min:3` 立刻开(why = hold),事件 22 各记一条。 +- ✅ **rack**:白名单放行 `hub`(mode auto/on、min 0~240);事件 22 命名(唤醒理由 / 休眠 / 模式改为…,mV 列置空); + 单板页「Hub」一行:状态 + 按需 / 常开 + 「唤醒 30 分」+「马上休眠」;模拟板同步。pytest 50 例,已部署。 +- ⏳ **没验证**:上行电脑不是本机也不是服务器,电脑那一侧 Hub 消失 / 重现、手机 VBUS 开后 adb 1~2 s 回来,都还没亲眼看过。 +- ⚠️ 现在起板 B 没人用 USB 满 10 分钟 COM 口就消失 —— 要 USB 烧录 / 看日志,先在 rack 点「唤醒 30 分」(或 MQTT 发 `hub min`)。 + +## 0.1.13:这一路断了电 ⇒ 刷机模式一并退出(2026-09-28) +- 起因:第 2 槽刀片带电拔走后,刷机模式还挂了约 48 分钟、占着「最多 2 路」的名额。`check_present` / SR-5 / 刀片不应答 / + mux 失联全断都是 t_safety 直接关 eFuse,不经过 `power` 命令,而只有 `power off` 会顺带退刷机模式。 +- 修:`cmd_chgmode_tick()`(执行器任务,chgmode 的唯一写者)每拍看一遍:刷机模式开着 + eFuse 关着 + 执行器不忙 ⇒ + 退出,记事件 21 arg = **254 `CHG_EV_DROPPED`**。执行器忙(硬重启 HR_OFF 那几秒 eFuse 本来就关)不算。 +- ⚠️ 254 带 0x80 位:rack `api.py` 事件命名里「断电自动退出」必须排在「加钟」前面判(已改 + 测试)。 +- 测试:`test_cmd` +1(硬重启中不退 / 关电后下一拍退、只记一次、名额让出);反向验证:去掉判断 ⇒ 红。26 ctest 全绿;rack 48 例全绿并已部署。 +- ⏳ 真板未专门验证(需要带电拔一次开着刷机模式的刀片)。 + +## 0.1.11 / 0.1.12:命令随机 busy / io;硬重启带着 VBUS(2026-09-28 刷机时发现) +- **总线锁误判(0.1.11)**:`i2cbus_xact()` 拿锁前查全局 `s_in_xact` 防嵌套 —— 别的任务在事务里时它也是 true。 + 执行器撞上巡检那 ~2.3 ms(每 20 ms,≈12%)就被判成「嵌套」立刻回 STATE ⇒ `key` 随机回 **busy**、`vbus` 随机回 **io** + (反过来巡检撞上执行器也会白跑一圈)。改成 `hal_lock_held(HAL_LOCK_I2C)`(**本任务**持锁才算嵌套)。 + 真板:第 3 路连开关 VBUS 40 次,0 失败。测试 `test_i2cbus` +1(另一任务持锁 ⇒ TIMEOUT 而不是 STATE;旧代码红)。 +- **硬重启先断 VBUS(0.1.11)+ 延后接回(0.1.12)**:VBUS 通着做 `power_cycle` 的三个后果现场全见过 —— + 手机靠 USB 撑着没断电(假重启);直供电流 ≈0 ⇒ 开机校验判「没开起来」再按两下;断电那刻带着 USB ⇒ 进关机充电,一拔 USB 就关机。 + 现在 `t_actuator_hard_reset()` 先断 VBUS,**开机校验通过后再等 `ACT_HR_VBUS_BACK_MS` = 60 s** 才接回 + (0.1.11 是校验一过就接回 ⇒ 手机还在 bootloader、关充电补丁没生效 ⇒ 13 s 顶高 4394 mV、SW-8 断 VBUS)。 + 等待期间执行器空闲;`vbus` / `power` 命令、再次硬重启、EDL、abort、这一路被关电 ⇒ 作废。没开起来 / 出错 ⇒ 不接回。 + 真板:第 3 路 VBUS 通 → 硬重启:hr-off 时 vb=false、14 s 校验通过、72 s 接回,无顶高事件。 + 测试 `test_t_actuator` +3(先断后接回、没开机不接回 / abort 不接回 / 本来没通不打开、等待期间被取消的三种)。26 ctest 全绿。 +- ~~没修的:第 2 槽刀片被拔走时刷机模式不退出~~ ⇒ 0.1.13 已修。 + +## 0.1.10:热插拔进来的刀片重新初始化(2026-09-28) +- 现象(板 B,新主板 + 没刷补丁):刀片**带电插**进槽 ⇒ C67(VBUS)= 5 V,换 U5 也一样;`vb:false`。 + 没刷补丁的手机被充到 4.4 V ⇒ SR-5 过压关路(反复 7 次)。**插好再给底板上电就正常**。 +- 根因:`slot_init()` / `ina226_init()` 只在开机 bringup 做。热插拔时 PCA9555 上电复位不干净(SDA/SCL 经上拉寄生供电), + `VBUS_EN` 被配成输出高、影子却是 off ⇒ SW-8 不武装;INA226 CALIB = 0 ⇒ **电流恒读 0**(第 2 路那段数据正是整 0)。 +- 修:`t_safety` 认 `PRSNT#` 下降沿,在位连续 `T_SAFETY_INSERT_N` = 5 圈(100 ms)后在本路锁内 `slot_init_locked()` + `ina226_init()`; + 失败隔 5 圈重试;拔走作废;开机在位的不重做。status `safety.inits` = 成功次数。 +- 测试 +2(`test_t_safety`:乱寄存器插入 ⇒ 第 5 圈恢复、VBUS 断、键全松、CALIB 写回、只做一次;NAK 重试 + 插一半拔走不碰空槽); + 反向验证:不置 need_init ⇒ 两例红。26 ctest 全绿。 +- ✅ 真板验证(用户现场):带电插刀片后 C67 = 0 V、`safety.inits` 0 → 1。 + +## 0.1.9:多组 WiFi(2026-09-28,用户要求) +- 起因:板 B 只存一组 WiFi(CANBOX-5G),热点一关就离线;而改配置只收 USB。 +- `netcfg_t.wifi[NETCFG_WIFI_N=4]`。`wifi` 命令 = **同名改口令、否则追加**(回 `"n":组数`;满了 `state`); + `{"cmd":"wifi","ssid":"X","del":true}` 删(没有这组回 `range`)。仍然只收 USB。 +- `net` 回应:`ssid` / `pass_set` = 正在连的那一组(`net_status_t.wifi_idx`),新增 `"ssids":[…]` 整张表。 +- 轮换(mlink_esp.c):没连上 ⇒ 换下一组;连上过又掉 ⇒ 先重试同一组;退避按「轮完一圈」算(`attempt / n`)。 + 改配置时正连着的那一组没被删、口令没变、broker 没变 ⇒ **WiFi / MQTT 都不断**(加备用热点不掉线)。 +- NVS:新键 `net2`(ver 2);读不到就读旧键 `net`(ver 1)迁移。**写时两个键都写**,`net` 放表里第一组 —— + OTA 回滚到 ≤0.1.8 仍连得上网。 +- 测试 +3(`test_net` 10 → 13 例:加 / 改同名、删 + 满表、NVS 空位压实);26 ctest 全绿。 +- 真板:烧录后 `ssids:["CANBOX-5G"]`(迁移成功)→ 加 `AAAAAABBBCCC`(用户给的名字,周围扫不到)→ + 加 `AAABBBCCC`(本机扫到的实际热点,同口令)⇒ 连上 10.5.5.58、MQTT up、服务器显示在线。 +- ⚠️ USB esptool 烧录 = 非计划重启 ⇒ 五路全断(第 1 路手机掉电)。第一次连接报 `ClearCommError`,COM5 重新枚举后重试即成。 + +## 0.1.7 / 0.1.8:反灌断 VBUS 阈值 −50 → −150 → −300 mA(2026-09-28,用户要求) +- 起因:刷机时重启过渡的瞬间反灌误断 VBUS,fastboot 断连。0.1.6 下实测 −59 mA;0.1.7(−150 mA)下 `adb reboot bootloader` 实测 **−208.5 mA** 仍断。 +- `T_SAFETY_REV_UA` = −300000;判据本身(一圈就断、不做确认窗口、不碰 eFuse、刷机模式不豁免)不变。 +- 测试:注入 −60 → −310 mA,「小电流不算反灌」−40 → −250 mA;26 ctest 全绿。0.1.8 下 `adb reboot bootloader` 过渡没再断 USB。 + +## 0.1.6:刷机模式默认 30 → 60 分钟 + 加钟(2026-09-28,用户要求) +- `CHG_MODE_DEFAULT_MIN` = 60。 +- 加钟:`{"cmd":"flash_mode","rail":1,"add":30}`(1~120,与 on / min 互斥)—— `chgmode_extend()` 只给**开着的**窗口加 + (没开 / 宽限期里回 `state`:那是重新开,不是加钟),加完剩余截断到 120 分钟;回 `"left":秒`。 + 事件 21 arg = `CHG_EV_EXTEND`(0x80)| 分钟。rack:「更多 → 刷机模式加钟 30 分钟」(只在开着时出现)。 +- 测试 +2(`test_cmd` 加钟全路径、`test_t_safety_rules` 截断与宽限期拒绝),rack 48 例。真板:没开加钟被拒 / 开 3600 / 加钟后 5399 / 关。 + +## 0.1.5:刷机模式(2026-09-28,用户定「做,最多同时 2 路」) +- **起因**:fastboot / bootloader 阶段内核补丁还没起来,手机充电器把直供节点顶到浮充 ~4.4 V(板 B 实测 4395 mV), + SW-8 立刻断 VBUS ⇒ fastboot 刷到一半断连。历史 5 次顶高全是只断 VBUS、**0 次**关路。 +- **依据**:每路 eFuse TPS25940A 自带反向阻断(V(IN)−V(OUT) < −9.3 mV 断开),顶高时能量进不了 Buck / 12V 母线; + 板 B 五次顶高实测电流 0 ~ −0.5 mA。 +- **新模块 `chgmode.{h,c}`**:每路一个截止时刻。**只有执行器任务写**(命令 + `cmd_chgmode_tick()` 到期清理), + 巡检只读;tick 必须清标志,否则 32 位毫秒时钟 ~24.8 天后过期窗口会「复活」。 +- **判据(`t_safety.c judge_locked`)**:窗口内「顶高断 VBUS」与 SR-5 过压都按绝对值 `CHG_MODE_MAX_MV` = 4450 判; + 窗口后 1 s 宽限期里 SW-8 回到按设定值(立刻断 VBUS),SR-5 仍按绝对值(节点往回落的几 ms 不许误关路)。 + 反灌(< −50 mA)与欠压判据**不变**。 +- **命令 14 `flash_mode`**:`{"rail":1,"on":true,"min":60}`(默认 60、1~120 分钟;0.1.6 由 30 改 60),回 `"left":秒`; + **最多同时 2 路**(第 3 路回 `state`);关着电不许开(`state`);`power` 关电时一并退出。status 每路多 `"chg":秒`。 +- **事件 21 `LOG_EV_CHG_MODE`**:arg = 分钟(开)/ 0(手动关)/ 255(到期)。 +- rack:白名单 + 事件名 + 单板页「更多 → 刷机模式(1 小时)」(带确认),状态标签显示剩余分钟;模拟板同步。 +- 测试:`test_t_safety_rules` +10(绝对上限、反灌 / 欠压不变、按路、到期断 VBUS 不关路、宽限期后恢复、2 路上限、时钟回绕), + `test_cmd` +3;26 ctest 全绿;rack 46 例。反向验证:去掉窗口内豁免 → 4 例红;tick 不清标志 → 回绕用例红。 +- ✅ **台架实测(2026-09-28,板 B 第 1 路,0.1.6-c5)**,原始数据 `10.5.5.13:/root/phonerail-src/bench-flashmode-20260928.tsv`: + · 开模式 + VBUS 通 → `adb reboot bootloader`,6 s 进 fastboot,**fastboot 下确实在充电**:节点 3864~4395 mV 锯齿 + (充到浮充即停、手机耗下来再充,周期 3~4 s),**VBUS 全程没断**、fastboot 一直在线(`getvar` 正常); + · 该路电流 −1.0 ~ +1.5 mA(eFuse 反向阻断干净),12V 母线 12.029~12.031 V 不动,输入电流 160 → 230 mA(+0.8 W,5V 负担很小); + · **FLT 在反向阻断期间一直为 true**(与 RCB 同进同退,节点跌到 3864 仍为 true = 要 +100 mV 才重新导通), + 固件对 FLT 只上报不动作 ⇒ 无事件。是 TPS25940A 的 RCB 指示,不是故障。rack 已改:chg>0 时这一路显示蓝色「刷机模式 · 充电中」、FLT 标签变中性「反向阻断」(模式外 FLT 仍标红); + · 对照:充电中关模式 → 当圈 PUSHED_UP 断 VBUS(读数 4034 mV),**不关路**(宽限期),Buck 立刻接手 3917 mV / 240 mA;FLT 同时消失; + · 重开模式 + VBUS → 2 s 回 fastboot。`fastboot getvar unlocked: yes`(BL 确已解锁); + · ⚠️ **`fastboot reboot` 回 MIUI 的开机过程中出现 −59 mA 反灌 ⇒ SW-8 断 VBUS**(事件 290)。机理:开机大负载时 Buck 在连续导通, + 节点只比设定高几 mV、没到 eFuse 的 −9.3 mV 阻断门限,充电器灌进来的电被 Buck 反向吸收。12V 母线没动(12.029~12.031)。 + **反灌判据保留是对的**;实际影响只是「重启过渡时 USB 可能被断,进系统后再开 VBUS」。 + +## 0.1.4:整片刀片不应答 ⇒ 关这一路(2026-09-28,用户拍板) +- `t_safety` 每路锁内:eFuse 开着、本路 INA226 **和** PCA9555 同时 NAK,连续 `T_SAFETY_BLADE_MUTE_N` = 10 圈(200 ms) + ⇒ 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)。 + 只进遥测、不参与判据。rack 单板页显示「上游:已连电脑 / 未连电脑」。 +- 起因:面板「损耗」在 ~0.5 W / ~1.6 W 两档间跳,与刀片、手机无关;高档时 NTC1(U31 旁)高 6~10 °C ⇒ USB2517I 工作 + (+ AMS1117 线性降压)约 1.1 W。⚠️ `host` 是 VBUS 在不在,不是 Hub 是否挂起:电脑休眠时多半仍是 true。 +- ⚠️ 热重启后 DAC 设定点回默认(实测 set 4010 → 3900 mV,输出 4.04 → 3.93 V)—— 供电保住了,设定没保住。 +- **板 B 第 2 槽 `PRSNT2#` 卡低**(09-28 02:09 第一次插刀片起,拔走后仍报在位,跨多次重启):硬件问题,待量 PRSNT2#(R607 / U20.9)对地、 + 看插座 B31 弹片。副作用:每圈 `i2c_err` +2;02:13~06:25 空槽 eFuse 开着约 4 h(见 0.1.4 的判据)。 + +## 0.1.2:硬重启按完电源键之后看电流,没开就重按(2026-09-28) +- **起因**:板 B 第 1 路 7 次 power_cycle 只开起来 1 次,失败时电流 1~2 mA、屏不亮;PCA9555 按键输出确实置位(命令都 ok), + **镊子短接 J603 能开** ⇒ R2 电源键是主板弹片压刀片铜盘(决策 14),接触时好时坏。这是机械问题,固件只做兜底。 +- **做法**(`t_actuator`,新状态 `hr-verify`):按满 5 s → 松键 → **松键后 3~5 s** 看电流,连续 5 个样本(0.5 s)≥ 100 mA 算开了; + 否则重按,**一共最多 3 次**;3 次都不行结果 `no-boot`,**电继续开着**。读不到电流不重按(对正在开机的手机再长按会进关机菜单)。 + 为什么 3 s 后才看:「按 2 s」那种 PMIC 醒了、SoC 没接住的情况,松手后 415 mA 撑约 3 s 才掉(宿主机测试把判定延迟改回 1 s 会被它骗)。 +- 电流与 PG 一样从巡检快照喂进来(`t_actuator_set_boot_check()`,app_main 接);**不接就是 0.1.1 的行为**,老测试一行没改。 +- 新事件 **19 `LOG_EV_BOOT_CHECK`**「开机重按」:arg 低 4 位 = 按了几次,bit7 = 最终没开起来;一次就开的不记。 + rack 已同步(名称「按了 N 次,开起来了 / 没开起来」,只有没开起来标红),已部署。 +- 手动 `key` 命令不校验、不重按(人的意图)。宿主机 26 ctest 全绿(`test_t_actuator` 30 → 37 例),rack 43 例全绿。 +- ⚠️ 实机只验到「第一下就开」(接触已恢复),重按路径靠宿主机测试。频繁出现事件 19 = 那一台弹片要处理。 + +## c4:两处修复(2026-09-28,第一片刀片 + 小米8 上板时发现) +- **硬重启后手机不开机**:电池端上电的小米8 停在关机态(只漏 ~2 mA)。现在 `power_cycle` 等到 PG 后 + **长按电源键 5 s**(新状态 `hr-boot`,`ACT_HR_BOOT_KEY_MS`);NO_PG 时不按。`ACT_KEY_MS_ON` 2000 → **5000**: + 实测按 2 s 时 PMIC 起来了(415 mA),一松手就掉回 1 mA。需求 SW-5 已同步。rack 的二级恢复(power_cycle)因此真的能拉起手机了。 + 上板:power_cycle → 4.1 s 上电 → hr-boot 5 s → res ok,手机进入开机电流曲线。 +- **热点一断整板失联(死锁)**:执行器「持 `s_mtx` → `esp_mqtt_client_enqueue` 等 `api_lock`」, + esp-mqtt 任务「持 `api_lock` 分发 DISCONNECTED → `on_mqtt` 的 `ST_EDIT` 等 `s_mtx`」。 + 现象:USB 命令无应答、WiFi 不重连、遥测停,只有 t_safety / t_thermal 的日志还在 —— **按到一半的键也会松不开**。 + 修:执行器只往 `s_outbox`(深度 4)拷一份,`t_link` 里 `outbox_drain()` 交给 esp-mqtt;持 `s_mtx` 时不调 esp-mqtt。 + ⚠️ 死锁状态下 OTA 进不去,只能 USB 烧(esptool `--before usb_reset`,第一次常报 ClearCommError,再跑一次就过)。 +- **c5:带载欠压误关**。小米8 插屏幕后开机峰值 ~2.1 A,轨 1 读回 3785 mV(设定 3900,−115 mV),SR-5 的 −100 mV 直接关路。 + 用户拍板:**欠压一侧放宽到 −250 mV**(`T_SAFETY_UNDER_MV`),过压仍 +100 mV;需求 SR-5 / SW-2 / 验收表同步。 + 压降 ≈ 55 mΩ × 2 A 的来源(Buck 负载调整 / eFuse / 采样电阻 / 走线)还没拆开查。 + +## 一句话现状 +OTA 换固件 / 超时回滚现在走 `warm_restart()`(`main/warm_esp.c`):目标固件认接管 ⇒ 手机不断电; +崩溃 / 看门狗 / 上电 / 兜底回滚仍全关(SR-6)。**26 个 ctest、448 个用例全绿**。板 B 跑 **0.1.2-c5**(经 OTA 推上去的)。 +定稿写在需求 §10.9「重启不断电」。C2 OTA 的说明在下面。 + +## 0.1.1:I²C 不应答被报成 STATE(真板独有,sim 测不出) +- 根因:IDF 5.4 的 `i2c_master_transmit/receive/transmit_receive` 在**从机 NACK / SCL 超时**时返回 `ESP_ERR_INVALID_STATE`, + HAL 照翻成 `HAL_ERR_STATE`(= 没 init)。于是:I2C_LOST 事件的错误码是 5;命令层 `from_hal` 又把 STATE 当成「执行器忙」⇒ + PCA9555 不应答时 `power off` 回 `busy`(eFuse 其实已经关了、意图也清了),上位机会以为没执行。 +- 修:`hal_i2c_esp.c` 传输函数用 `map_xfer_err`(INVALID_STATE → NAK);`cmd_exec.c` 新增 `from_bus`, + I²C / GPIO 路径(set_voltage / vbus / power)失败一律 `io`,`from_hal` 只留给执行器。 +- 测试:sim 加 `sim_i2c_inject_err_ch()`(指定注入的错误码);`test_cmd` 加一段 STATE 注入 ⇒ 必须是 io 不是 busy + (把关电那行改回 from_hal 时这段确实红,回应就是台架上看到的 `{"err":"busy"}`)。 +- 上板(台架镜像,CH1 强制在位):`power off` 回 `io`、CH1 关掉;I2C_LOST 的错误码 5 → **2**。 + +## 重启不断电(c3) +- 三道闸:RTC 标记(软件复位 + 魔数 / 反码,一次性)· 目标镜像能力标记(第 288 字节 `PRCAPS1`,老固件不认就不锁)· 起来后读 EN 真电平核对。 +- `rail_en_init_keep(adopt)` **五根一律放开 hold**:崩溃时上一条命的锁可能还在。 +- `pca9555_init_keep()`:方向寄存器是我们写的才留 VBUS;键 / EDL 清零。 +- 实测:台架镜像(空槽强制 CH1 在位,**没提交**)开 CH1 → OTA 带重启 → 事件 `18/7 mv=1`(新固件读到 IO9 仍为高、接管)。 + ⚠️ 没测到的:示波器上看 EN 在重启期间有没有毛刺(`warm_take` 读到高只能说明「起来时还是高」); + 「锁着 + 非计划复位」这条路径只有宿主机测试(真板上造不出来)。插上刀片 + 手机后要看一次手机有没有掉电重启。 +- **第一次从 c2 升到 c3 仍会断电一次**:锁 EN 的是**正在跑**的固件,c2 不会锁。 + +## 下一步(没人定,候选) +- 示波器验证:插一片刀片,CH 开着,OTA 带重启,抓 EN / 输出电压(`scope_*` 工具)。 +- 在手机上看一眼 rack 新界面(390 px 左右):无头 Chrome 最窄只到 ~500 px,窄屏只验到那里。 +- 下面「其它挂着的事」里 A15 / A16 留下的几条(I²C 总线挂死恢复、PD 降载、风扇阈值实测)照旧。 + +## rack 侧(服务器前后端,详见 `rack/README.md`) +- **固件页**(顶栏「固件」):上传 `build/phonerail.bin` → 选板推送;上传时核镜像头(非本工程 / 非 app 镜像 / >3 MB 直接拒), + 显示每个镜像「重启不断电:支持 / 不支持」,勾「立刻重启」时按它给不同提示。通用 `/cmd` 不放行 `ota`(URL / 哈希只能由固件库生成)。 +- **下载目录自动清理**(每小时):库里登记的不删;`ota_push.py` 直传的放满 7 天删;半截 `.tmp` 1 小时删。 +- **界面改版**:仪器前面板风格 + 暗色模式(跟随系统);总览每个槽带最近两分钟电流小曲线(平线 = 疑似卡死)。 +- **底板温度曲线**(单板页):两路 NTC + 风扇占空比,10 分钟 ~ 7 天;后端 `board_tele_min` 分钟聚合留 90 天, + 接口 `GET /api/boards/{id}/history`。NTC1 贴 Hub 稳压 U31,NTC2 贴输入降压 U37。 + +## 踩到的坑(c3) +- `.rodata_custom_desc` 没人引用会被 `--gc-sections` 丢掉 ⇒ `main/CMakeLists.txt` 加 `-u g_phonerail_caps`。 +- `PROJECT_VER` 在 CMake 配置时读 ⇒ 改 `phonerail.h` 不会重配,镜像头版本会是旧的 ⇒ 顶层加了 `CMAKE_CONFIGURE_DEPENDS`。 +- 撤掉台架补丁后要**重新编译**再推:`build/phonerail.bin` 还是台架版(这次推错了一次,已换回)。 +- Git Bash heredoc 里的 Python 写 `\0` 又变成真 NUL 了(这次在 rack/firmware.py)—— 含反斜杠的一律写 .py 文件。 + +## 怎么推固件 +``` +python tools/ota_push.py b81f3fc9c8bc # 推 build/phonerail.bin:只下载 + 校验 + 切分区,下次复位生效 +python tools/ota_push.py b81f3fc9c8bc --reboot # 校验过就立刻重启(板上 ≥c3 且目标镜像认接管 ⇒ 手机不断电,否则五路全断一次) +python tools/ota_push.py b81f3fc9c8bc --status +``` +它把镜像传到服务器 `/root/phonerail/fw/.bin`(nginx 7765,只放行这种文件名,不过门户; +**不进固件库,7 天后 rack 后端自动删** —— 要长期留着就从界面上传), +再在服务器上用后端账号经 1884 发命令、盯遥测。 +**rack 管理界面「固件」页也能推**(2026-09-27 加,上传 → 选板 → 可选立刻重启并二次确认;见 `rack/README.md`「推固件」)。 + + +## C2 改了什么 +- `partitions.csv` 重排(16 MB):**nvs 0x9000 没动**;ota_0 / ota_1 各 3 MB;otadata 0x610000;evlog 挪到 0x612000(历史事件清空了一次)。 +- `sdkconfig.defaults` + `sdkconfig`:flash 16 MB(之前误写 8 MB);`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`(bootloader 配置,只有 USB 整片烧才生效)。 +- `main/ota.{h,c}` **新**(宿主机可测):URL / 哈希校验、判定(busy / 待验证拒 / 同镜像不重下)、健康判据。 +- `main/ota_esp.{h,c}` **新**:`t_ota` 任务(esp_http_client + mbedtls sha256 + `OTA_WITH_SEQUENTIAL_WRITES`,每块让一个 tick)、 + 核 project_name、切分区;`t_link` 里判健康 / 到点重启(先等 2 s 让回应发出去、事件落盘);esp_timer 150 s 兜底回滚。 +- `cmd` 新命令 `ota`=13(`cmd_net.c` 的 `do_ota`);status 多 `ota` 一块;`evlog` 新事件 `LOG_EV_OTA`=18。 +- 顶层 `CMakeLists.txt`:`PROJECT_VER` 取 `PHONERAIL_FW_VERSION`(否则镜像头版本是 git describe,回滚后 `ota.bad` 报 `79c9f8b-dirty`)。 +- `test/test_ota.c` **新**(13 例);`test_cmd` / `test_evlog` 的冻结数字表各加一行。 +- `tools/ota_push.py` **新**;`rack/deploy/nginx-phonerail-fw.conf` **新**(已部署);`rack/backend/prrack/protocol.py` 加事件 18 / 命令 13(已部署)。 + +## 上板实测(板 B,五槽空) +``` +正常:c2 → c2-t1 经 MQTT --reboot:下载 ≈8 s,推送到自证健康 19 s;事件 18/1 → 18/2 → 18/6 → BOOT → 18/4 +哈希错:err "hash"、got 996064、固件不变;下载 20 s 里 t_safety skipped 0 +回滚:服务器删掉板子 broker 账号 → USB 发同镜像 reboot:true → 起在 c2-rb(pend true)→ 120 075 ms 记 18/5 → 回到 c2-t1、bad 报版本 + (账号已原样恢复,备份已删) +收尾:c2-t1 → 正式 0.1.0-c2 经 MQTT --reboot 成功 +``` + +## 踩到的坑(只写会再犯的) +- **路由器不是整机 DMZ**:新开的 7765 公网 TCP 都连不上,用户手动加了转发才通(本机测公网要 `curl --noproxy '*'`, + 否则 7890 代理回的 502 会被当成服务器的)。 +- 本机 PowerShell `Set-Content -Encoding ascii` 改 `phonerail.h` 把中文注释写成 `??`(已 `git checkout` 还原)—— 改它一律用 sed。 +- Git Bash heredoc 里的 Python 写 `'\0'` 会变成真 NUL 字节进源码;含反斜杠的替换写成 .py 文件再跑。 +- 宿主机 `bash host/test.sh` 用 `-j 4` 在 cygwin 上偶发「link: Subprocess aborted」,`cmake --build build -j 1` 就过。 + +## 其它挂着的事 +- **风扇还没下单购买**(2026-09-27):板 B 两路报 `rpm 0` + 停转是预期的。到货插上后看 `status.fan.rpm`;在转却仍是 0 再查 Q9/Q10 与 PCNT。 +- 风扇闭环四个阈值 + 占空比下限 + PPR 都是「待上板实测」(t_thermal.h),手机上架跑满载之后再定。 +- A15 留下的:I²C 总线挂死恢复(HAL 层);PD 上限的降载动作(§8.3);CH224A 读到 100 W 与早上记的 20V/3A 不一致,待核。 + +## 本次新增/修改的文件 +- `main/ntc.{h,c}` **新**(宿主机可测)— mV → Ω → 查 Murata NCP18XH103F03RB 的 R-T 表(−40~125 °C、5 °C 一格、格内线性插值); + 贴 +3V3 / 贴地 / 表外一律「读不到」(⇒ t_thermal 满速) +- `main/thermal_esp.{h,c}` **新**(只进 IDF)— ADC1 oneshot + 曲线拟合校准(8 次平均,引脚反查通道);两个 PCNT 单元数 TACH 上升沿(累计计数、1 µs 毛刺滤波) +- `main/app_main.c` — `task_thermal` 任务体(每 10 s 打一行温度 / mV / 占空比 / 转速);`thermal_esp_init()` + `t_thermal_init()` 放在**上电恢复之前**;`cmd_env.thermal = &s_therm` +- `main/CMakeLists.txt` REQUIRES 加 `esp_adc esp_driver_pcnt`;`host/CMakeLists.txt` 加 `ntc.c` +- `test/test_ntc.c` **新**(4 例:表点、⭐方向「越热电压越低」、⭐开路/短路不是温度、比例式);版本 `0.1.0-a16` + +## 上板实测(板 B,五槽空,风扇状态未知) +``` +status: "temp":[38249,34027] 分压端 1266 / 1384 mV ← 两路 NTC 读数合理(NTC1 在电感区更热) + "fan":{"duty":300,"en":true,"rpm":[0,0],"stall":[true,true]} +events: [31, 14318, 0, 11(FAN_STALL), 0] [32, …, 11, 1] ← 启动宽限 5 s + 一个窗口之后各报一次,不刷屏 +``` + +## 踩到的坑(只写会再犯的) +- 比例式 NTC 的电源误差估算:+3V3 偏 1 % 在 25 °C 附近是 **~0.6 °C**(中点处电阻误差是电源误差的 2 倍、NTC 每度 ~4 %),不是 0.3 °C。 +- PowerShell 5.1 的 `Set-Content -Encoding utf8` 会写 BOM —— 改 `phonerail.h` 这种文件用 sed,别用它。 +- `python` 在 Git Bash 里是 Windows Store 占位程序,会挂住;一律用 `/c/Python314/python`。 diff --git a/firmware/host/CMakeLists.txt b/firmware/host/CMakeLists.txt index d4e70cd..dd0c2f0 100644 --- a/firmware/host/CMakeLists.txt +++ b/firmware/host/CMakeLists.txt @@ -43,6 +43,7 @@ set(PHONERAIL_CORE_SRCS ${FW_ROOT}/main/cmd_net.c ${FW_ROOT}/main/netcfg.c ${FW_ROOT}/main/mlink.c + ${FW_ROOT}/main/prov.c ${FW_ROOT}/main/bringup.c ${FW_ROOT}/main/ntc.c ${FW_ROOT}/main/ota.c diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 9e04dac..5d08c23 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,5 +1,5 @@ # ESP-IDF 侧的 main 组件。 -# app_main.c / ulink_esp.c / mlink_esp.c / thermal_esp.c / ota_esp.c / warm_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过 +# app_main.c / ulink_esp.c / mlink_esp.c / prov_esp.c / thermal_esp.c / ota_esp.c / warm_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过 # (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。 idf_component_register( SRCS "app_main.c" @@ -27,6 +27,8 @@ idf_component_register( "cmd_net.c" "netcfg.c" "mlink.c" + "prov.c" + "prov_esp.c" "bringup.c" "ntc.c" "thermal_esp.c" @@ -34,11 +36,12 @@ idf_component_register( "ota_esp.c" "warm_esp.c" INCLUDE_DIRS "." - EMBED_TXTFILES "certs/evse_ca.pem" + EMBED_TXTFILES "certs/evse_ca.pem" "prov.html" REQUIRES phonerail_hal drv esp_driver_usb_serial_jtag esp_wifi esp_netif esp_event esp_timer nvs_flash mqtt esp_adc esp_driver_pcnt - app_update esp_http_client mbedtls) + app_update esp_http_client mbedtls + esp_http_server lwip) # warm_esp.c 的能力标记(.rodata_custom_desc,镜像第 288 字节)没有代码引用它, # 不加 -u 会被 --gc-sections 丢掉 —— 那样每个新固件都会被当成「不认接管」,EN 永远不锁(安全,但功能白做)。 diff --git a/firmware/main/cmd.h b/firmware/main/cmd.h index 5778d6b..32956c3 100644 --- a/firmware/main/cmd.h +++ b/firmware/main/cmd.h @@ -76,17 +76,20 @@ typedef enum { CMD_ERR_STATE = 8, /* 当前状态下不许(例:eFuse 关着时开 VBUS) */ CMD_ERR_IO = 9, /* 下发到器件失败(I²C) */ CMD_ERR_OVERFLOW = 10, /* 回应装不进缓冲 —— 本模块的 bug,不该出现 */ - CMD_ERR_LINK = 11, /* 这条命令不许从这条链路来(经 WiFi 改 WiFi 配置) */ + CMD_ERR_LINK = 11, /* 这条命令不许从这条链路来(经 MQTT 改 WiFi 配置) */ CMD_ERR_COUNT, } cmd_err_t; /* 哪条链路来的。进 `LOG_EV_REBOOT` 的 arg 高 4 位。§10.8:两条链路的命令语义完全一致, - **唯一的例外**是 `wifi` / `mqtt` 两条配置命令只收 USB(netcfg.h:经 WiFi 改 WiFi 配置, - 一次手误就把唯一的远程通道弄断了)。 */ + **唯一的例外**是 `wifi` / `mqtt` 两条配置命令不收 MQTT(netcfg.h:经 WiFi 改 WiFi 配置, + 一次手误就把唯一的远程通道弄断了)。 + `CMD_LINK_AP`(0.1.19)= 配网热点的网页(prov.h):人就在板子旁边,和 USB 一样收配置命令; + 这条链路上只会出现 `wifi` / `mqtt` / `net` 三条(prov_esp.c 只拼这三条)。 */ typedef enum { CMD_LINK_TEST = 0, CMD_LINK_USB = 1, - CMD_LINK_WIFI = 2, + CMD_LINK_WIFI = 2, /* MQTT */ + CMD_LINK_AP = 3, } cmd_link_t; /* `LOG_EV_REBOOT` 的 arg:bit0 = 1 硬(断电)/ 0 软(长按电源键),bit4~7 = 链路。 */ diff --git a/firmware/main/cmd_exec.c b/firmware/main/cmd_exec.c index c383667..a4d74f3 100644 --- a/firmware/main/cmd_exec.c +++ b/firmware/main/cmd_exec.c @@ -401,8 +401,21 @@ cmd_err_t do_fan(ctx_t *c) if (duty < 0 || duty > 1000) { return reject(c, CMD_ERR_RANGE, duty); } + /* `sec`(0.1.21,可选):这个下限保持多少秒,到期自动回 0。不给 / 0 = 一直保持(老行为)。 + 定时续期的上位机(AppTask)用它:它挂了,风扇不会永远停在最后那个转速上。 */ + int64_t sec = 0; + const int gs = cj_get_int(c->o, "sec", &sec); + if (gs < 0) { + return CMD_ERR_ARG; + } + if (sec < 0 || sec > T_THERMAL_FLOOR_TTL_MAX_S) { + return reject(c, CMD_ERR_RANGE, sec); + } /* 手动值只是**下限**:温度曲线要得更高时照曲线来(t_thermal.h)。0 = 交回自动。 */ - t_thermal_set_floor((uint16_t)duty); + t_thermal_set_floor_for((uint16_t)duty, (uint32_t)sec * 1000u); cj_w(c->w, ",\"floor\":%u", (unsigned)t_thermal_floor()); + if (gs == 1) { + cj_w(c->w, ",\"sec\":%lu", (unsigned long)((t_thermal_floor_left_ms() + 999u) / 1000u)); + } return CMD_OK; } diff --git a/firmware/main/cmd_net.c b/firmware/main/cmd_net.c index 2e2837b..6a3d7be 100644 --- a/firmware/main/cmd_net.c +++ b/firmware/main/cmd_net.c @@ -1,4 +1,4 @@ -/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB**),`net` 读状态; +/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB 与配网热点**),`net` 读状态; 外加 C2 的 `ota`(下载固件,两条链路都收)。 `net` 的回应里**永远没有密码**,只有 `pass_set` / `mpass_set`(netcfg.h「密码永不外泄」)。 */ @@ -7,9 +7,11 @@ #include "cmd_priv.h" #include "netcfg.h" #include "ota.h" +#include "prov.h" -/* 配置命令只收 USB(cmd.h 链路那一段)。宿主机测试用的 CMD_LINK_TEST 视同 USB。 */ -static bool usb_only_ok(const ctx_t *c) { return c->link != CMD_LINK_WIFI; } +/* 配置命令不收 MQTT(cmd.h 链路那一段):USB 与配网热点都是「人在板子旁边」。 + 宿主机测试用的 CMD_LINK_TEST 视同 USB。 */ +static bool local_ok(const ctx_t *c) { return c->link != CMD_LINK_WIFI; } static cmd_err_t get_str(const ctx_t *c, const char *key, bool required, const char **out) { @@ -27,7 +29,7 @@ static cmd_err_t get_str(const ctx_t *c, const char *key, bool required, const c cmd_err_t do_wifi(ctx_t *c) { - if (!usb_only_ok(c)) { + if (!local_ok(c)) { c->bad = CMD_WIFI; return CMD_ERR_LINK; } @@ -69,7 +71,7 @@ cmd_err_t do_wifi(ctx_t *c) cmd_err_t do_mqtt(ctx_t *c) { - if (!usb_only_ok(c)) { + if (!local_ok(c)) { c->bad = CMD_MQTT; return CMD_ERR_LINK; } @@ -77,11 +79,23 @@ cmd_err_t do_mqtt(ctx_t *c) const char *user = NULL; const char *mpass = NULL; int64_t tele = 0; + bool keep = false; const int gt = cj_get_int(c->o, "tele_ms", &tele); if (get_str(c, "uri", true, &uri) != CMD_OK || get_str(c, "user", false, &user) != CMD_OK || - get_str(c, "pass", false, &mpass) != CMD_OK || gt < 0) { + get_str(c, "pass", false, &mpass) != CMD_OK || gt < 0 || + cj_get_bool(c->o, "keep_pass", &keep) < 0) { return CMD_ERR_ARG; } + /* `"keep_pass":true`(0.1.19)= 口令沿用已存的那份:配网网页看不到旧口令(永不回显), + 只想改地址时不该逼人重输。同时又给了 `pass` 是自相矛盾,拒。 */ + static netcfg_t cur; /* ≈560 B,不放栈上(只有执行器任务进来) */ + if (keep) { + if (mpass[0] != '\0') { + return CMD_ERR_ARG; + } + netcfg_get(&cur); + mpass = cur.mpass; + } if (tele < 0 || tele > NETCFG_TELE_MAX_MS || !netcfg_mqtt_ok(uri, user, mpass, (uint32_t)tele)) { c->bad = tele; @@ -139,6 +153,14 @@ cmd_err_t do_net(ctx_t *c) cfg.mpass[0] ? "true" : "false", (unsigned long)cfg.tele_ms, (unsigned long)st.wifi_drops, (unsigned long)st.mqtt_drops, (unsigned long)st.tele_sent, (unsigned long)st.tele_skipped); + /* 配网热点(prov.h)。`pass` 是**热点自己的**口令,不是上面那两个「永不外泄」的: + 人得先知道它才连得上热点,而读到它的前提是已经在 USB / MQTT 上了。 */ + cj_w(w, ",\"ap\":{\"on\":%s,\"why\":\"%s\",\"sta\":%u,\"ssid\":", st.ap_on ? "true" : "false", + prov_why_str((prov_why_t)st.ap_why), (unsigned)st.ap_sta); + w_str(w, st.ap_ssid); + cj_w(w, ",\"pass\":"); + w_str(w, st.ap_pass); + cj_w(w, "}"); return CMD_OK; } diff --git a/firmware/main/cmd_report.c b/firmware/main/cmd_report.c index a774637..f789ff4 100644 --- a/firmware/main/cmd_report.c +++ b/firmware/main/cmd_report.c @@ -88,8 +88,9 @@ static void thermal_obj(cj_writer_t *w, const thermal_snap_t *t) cj_w(w, "%snull", i ? "," : ""); } } - cj_w(w, "],\"fan\":{\"duty\":%u,\"floor\":%u,\"en\":%s,\"rpm\":[", (unsigned)t->duty, - (unsigned)t_thermal_floor(), b(t->fan_en)); + cj_w(w, "],\"fan\":{\"duty\":%u,\"floor\":%u,\"left\":%lu,\"en\":%s,\"rpm\":[", (unsigned)t->duty, + (unsigned)t_thermal_floor(), (unsigned long)((t_thermal_floor_left_ms() + 999u) / 1000u), + b(t->fan_en)); for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) { if (t->fan[i].rpm_valid) { cj_w(w, "%s%lu", i ? "," : "", (unsigned long)t->fan[i].rpm); diff --git a/firmware/main/mlink_esp.c b/firmware/main/mlink_esp.c index 05dc659..acd99a3 100644 --- a/firmware/main/mlink_esp.c +++ b/firmware/main/mlink_esp.c @@ -18,7 +18,9 @@ ---- 失联不动作(§10.2 / §10.8) ----------------------------------------- WiFi 断、broker 断,本文件只做一件事:**重连**(WiFi 指数退避,MQTT 由 esp-mqtt 每 10 s 重试)。 - 没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。 */ + 没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。 + 0.1.19 起多一件:WiFi 连续 60 s 连不上就**开配网热点**(prov.h),STA 照常在后台重连, + 连上了热点自己关。热点同样不碰输出。 */ #include #include "cmd_queue.h" @@ -39,6 +41,8 @@ #include "nvs.h" #include "nvs_flash.h" #include "phonerail.h" +#include "prov.h" +#include "prov_esp.h" #include "tasks.h" #include "ulink.h" @@ -91,6 +95,9 @@ static uint32_t s_attempt; static mlink_tele_t s_tele; static volatile bool s_tele_inflight; static bool s_up; +static volatile bool s_slow; /* 有人在配网网页上:重连间隔不短于 PROV_SLOW_MS(prov.h) */ +static uint32_t s_ap_try_ms; /* 上次开 / 关热点是什么时候(没成功时别每拍都试) */ +static bool s_ap_tried; /* 发件队列(见文件头):执行器 / esp-mqtt 任务往里拷,t_link 取出来交给 esp-mqtt。 深度 4 × 1.5 KB:遥测 1 s 一条、回应偶尔一条,t_link 至少每 100 ms 来取一次; @@ -343,6 +350,16 @@ static void wifi_set_sta(uint8_t idx) ST_EDIT(s_st.wifi_idx = idx); } +/* 发起一次连接。驱动当场拒了(配网网页正在扫描时会)就不会有断开事件来接着排下一次 —— + 重连链在这里断掉 = 板子永远不回网,所以自己排一次。 */ +static void sta_connect(void) +{ + if (esp_wifi_connect() != ESP_OK) { + esp_timer_stop(s_retry); + esp_timer_start_once(s_retry, 3000u * 1000u); + } +} + static void retry_cb(void *arg) { (void)arg; @@ -350,7 +367,7 @@ static void retry_cb(void *arg) if (s_st.wifi != NET_UP && n > 0) { const uint8_t i = s_idx < n ? s_idx : 0; wifi_set_sta(i); - esp_wifi_connect(); + sta_connect(); } } @@ -374,7 +391,10 @@ static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data) if (!was_up && n > 1) { s_idx = (uint8_t)((s_idx + 1) % n); } - const uint32_t ms = mlink_backoff_ms(s_attempt++ / (uint32_t)n); + uint32_t ms = mlink_backoff_ms(s_attempt++ / (uint32_t)n); + if (s_slow && ms < PROV_SLOW_MS) { + ms = PROV_SLOW_MS; /* 每试一次热点都要离开信道扫一圈,试密了网页就卡(prov.h) */ + } ESP_LOGW(TAG, "WiFi 断开,原因 %u,%u ms 后连「%s」", (unsigned)d->reason, (unsigned)ms, s_cur.wifi[s_idx < n ? s_idx : 0].ssid); esp_timer_stop(s_retry); @@ -407,10 +427,33 @@ static void wifi_apply(void) } wifi_set_sta(s_idx); ST_EDIT(s_st.wifi = NET_CONNECTING); - esp_wifi_connect(); + sta_connect(); ESP_LOGI(TAG, "连 WiFi「%s」(%u/%d)", s_cur.wifi[s_idx].ssid, (unsigned)s_idx + 1, n); /* 只打 SSID,不打口令 */ } +/* ---- 配网热点(t_link 里调):判据在 prov.c,开关在 prov_esp.c ------------- */ + +static void prov_poll(void) +{ + const uint32_t now = hal_time_now_ms(); + const prov_in_t in = { .n_wifi = netcfg_wifi_count(&s_cur), .wifi_up = s_st.wifi == NET_UP, + .ap_sta = prov_esp_sta_count(), .touched = prov_esp_take_touch() }; + const prov_out_t o = prov_step(&in, now); + s_slow = o.hold; + if (o.ap != prov_esp_on() && (!s_ap_tried || (uint32_t)(now - s_ap_try_ms) >= 10000u)) { + s_ap_tried = true; + s_ap_try_ms = now; + prov_esp_set(o.ap); /* 没成功就 10 s 后再来,不是每拍都试 */ + } + const bool on = prov_esp_on(); + if (on == o.ap) { + s_ap_tried = false; + } + if (on != s_st.ap_on || (uint8_t)in.ap_sta != s_st.ap_sta || (uint8_t)o.why != s_st.ap_why) { + ST_EDIT(s_st.ap_on = on; s_st.ap_sta = (uint8_t)in.ap_sta; s_st.ap_why = (uint8_t)o.why); + } +} + /* ---- 对外 ---------------------------------------------------------------- */ hal_err_t mlink_esp_init(void) @@ -444,6 +487,7 @@ hal_err_t mlink_esp_init(void) ESP_LOGE(TAG, "网络栈起不来 —— WiFi 链路不可用"); return HAL_ERR_STATE; } + esp_netif_t *ap_netif = esp_netif_create_default_wifi_ap(); /* 配网热点用;平时模式是 STA,它闲着 */ const wifi_init_config_t wi = WIFI_INIT_CONFIG_DEFAULT(); if (esp_wifi_init(&wi) != ESP_OK) { ESP_LOGE(TAG, "WiFi 驱动起不来 —— WiFi 链路不可用"); @@ -454,10 +498,14 @@ hal_err_t mlink_esp_init(void) esp_wifi_set_storage(WIFI_STORAGE_RAM); /* 凭据只存我们自己的那份,驱动别再存一份 */ esp_wifi_set_mode(WIFI_MODE_STA); esp_wifi_start(); + prov_init(hal_time_now_ms()); + prov_esp_init(ap_netif, mlink_id()); + ST_EDIT(snprintf(s_st.ap_ssid, sizeof(s_st.ap_ssid), "%s", prov_esp_ssid()); + snprintf(s_st.ap_pass, sizeof(s_st.ap_pass), "%s", prov_esp_pass())); s_up = true; ESP_LOGI(TAG, "板子 ID %s,topic %s/…", mlink_id(), mlink_topic(MLINK_T_CMD)); if (!have) { - ESP_LOGW(TAG, "还没配网:经 USB 发 wifi / mqtt 两条命令(tools/prlink.py)"); + ESP_LOGW(TAG, "还没配网:连热点「%s」开网页,或经 USB 发 wifi / mqtt(tools/prlink.py)", prov_esp_ssid()); } wifi_apply(); return HAL_OK; @@ -481,14 +529,25 @@ void mlink_esp_poll(void) if (!(wifi_same && mqtt_same)) { mqtt_stop(); } + /* 没连着的时候:刚加 / 刚改口令的那一组先试 —— 人正等着看它连不连得上(配网网页尤其)。 */ + uint8_t first = s_idx; + for (int i = 0; i < netcfg_wifi_count(&next); i++) { + const int k = netcfg_wifi_find(&s_cur, next.wifi[i].ssid); + if (k < 0 || strcmp(s_cur.wifi[k].pass, next.wifi[i].pass) != 0) { + first = (uint8_t)i; + break; + } + } s_cur = next; if (wifi_same) { s_idx = (uint8_t)j; ST_EDIT(s_st.wifi_idx = s_idx); } else { + s_idx = first; wifi_apply(); } } + prov_poll(); /* MQTT 的生命周期只在这里管:WiFi 有地址、配了 broker、还没建客户端 ⇒ 建。 之后掉线重连由 esp-mqtt 自己做(network.reconnect_timeout_ms,默认 10 s)。 */ if (s_mqtt == NULL && s_st.wifi == NET_UP && s_cur.uri[0] != '\0') { diff --git a/firmware/main/mlink_esp.h b/firmware/main/mlink_esp.h index ceb02b9..8c94e67 100644 --- a/firmware/main/mlink_esp.h +++ b/firmware/main/mlink_esp.h @@ -6,10 +6,10 @@ /* NVS → 配置 → WiFi 驱动 → 按配置连网。要在命令队列建好之后、任务起来之前调。 失败不 abort:WiFi 链路不可用,USB 链路与五路供电照常(§10.2)。 - 没配过网时只起驱动、不连,等 USB 下发 `wifi` / `mqtt`。 */ + 没配过网时只起驱动、不连,开配网热点(prov.h),等网页或 USB 下发 `wifi` / `mqtt`。 */ hal_err_t mlink_esp_init(void); -/* 链路任务每圈调一次(≤100 ms 一次):应用配置变更、按需建 MQTT 客户端、推遥测。 */ +/* 链路任务每圈调一次(≤100 ms 一次):应用配置变更、开关配网热点、按需建 MQTT 客户端、推遥测。 */ void mlink_esp_poll(void); #endif /* MLINK_ESP_H */ diff --git a/firmware/main/netcfg.h b/firmware/main/netcfg.h index b11a6e6..db17e5c 100644 --- a/firmware/main/netcfg.h +++ b/firmware/main/netcfg.h @@ -4,8 +4,9 @@ USB 链路配网」。所以: · 配置由两条命令写入:`wifi`(SSID / 密码,**最多存 NETCFG_WIFI_N 组**,0.1.9)与 `mqtt`(broker 地址 / 账号 / 遥测周期), - **只收 USB 链路来的**(cmd.c 回 `link`)—— 经 WiFi 改 WiFi 配置,一次手误就把 - 唯一的远程通道弄断了,而那时只能拆机插 USB; + **不收 MQTT 来的**(cmd.c 回 `link`)—— 经 WiFi 改 WiFi 配置,一次手误就把 + 唯一的远程通道弄断了。收的是两条「人在板子旁边」的链路:USB,和 0.1.19 起 + 连不上 WiFi 时自己开的配网热点(prov.h —— 所以弄断了也不用再拆机插 USB); · 本模块只管「校验 + 在内存里持有一份 + 调存储后端 + 通知网络那一侧」, NVS 与 WiFi/MQTT 都在 ESP 胶水里(mlink_esp.c),这一半宿主机可测。 @@ -110,6 +111,12 @@ typedef struct { 202 = 认证失败,15 = 四次握手超时 ≈ 口令错),0 = 没断过 */ uint8_t wifi_idx; /* 正在连 / 已连上的是表里第几组(net 回应的 ssid 取它) */ char id[13]; /* 板子 ID = STA MAC 的 12 位小写十六进制,topic 用它 */ + /* 配网热点(prov.h,0.1.19)。ap_pass 是热点自己的口令,`net` 会报(见 cmd_net.c)。 */ + bool ap_on; + uint8_t ap_why; /* prov_why_t */ + uint8_t ap_sta; /* 热点上连着几台 */ + char ap_ssid[20]; + char ap_pass[12]; } net_status_t; void net_status_set(const net_status_t *st); diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h index 526e132..ce152ce 100644 --- a/firmware/main/phonerail.h +++ b/firmware/main/phonerail.h @@ -3,7 +3,7 @@ #define PHONERAIL_H /* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ -#define PHONERAIL_FW_VERSION "0.1.18-c5" +#define PHONERAIL_FW_VERSION "0.1.21-c5" /* 通道数。五路直供,见设计文档 §2。 */ #define PHONERAIL_RAIL_COUNT 5 diff --git a/firmware/main/prov.c b/firmware/main/prov.c new file mode 100644 index 0000000..058ea35 --- /dev/null +++ b/firmware/main/prov.c @@ -0,0 +1,206 @@ +/* 配网热点的平台无关部分。判据表与表单格式在 prov.h。 */ +#include "prov.h" + +#include +#include + +#include "cmd.h" +#include "netcfg.h" + +static bool s_ap; +static bool s_was_up; +static uint32_t s_since; /* STA 上一次「有地址 ↔ 没地址」翻转的时刻(启动算一次) */ +static bool s_aged; /* 翻转之后已经过了门限(过了就锁住:32 位时钟 49.7 天回绕,放着不管会「变年轻」) */ +static bool s_touch; /* hold 窗口还开着(过期即清,同上) */ +static uint32_t s_touch_at; +static int s_prev_sta; + +void prov_init(uint32_t now) +{ + s_ap = false; + s_was_up = false; + s_since = now; + s_aged = false; + s_touch = false; + s_touch_at = now; + s_prev_sta = 0; +} + +prov_out_t prov_step(const prov_in_t *in, uint32_t now) +{ + if (in->wifi_up != s_was_up) { + s_was_up = in->wifi_up; + s_since = now; + s_aged = false; + } + if (!s_aged && (uint32_t)(now - s_since) >= (in->wifi_up ? PROV_LINGER_MS : PROV_FAIL_MS)) { + s_aged = true; + } + if (in->touched || in->ap_sta > s_prev_sta) { /* 有人刚连上热点也算一次 */ + s_touch = true; + s_touch_at = now; + } + s_prev_sta = in->ap_sta; + if (s_touch && (uint32_t)(now - s_touch_at) >= PROV_HOLD_MS) { + s_touch = false; + } + + prov_out_t o = { .ap = false, .hold = false, .why = PROV_WHY_OFF }; + if (in->wifi_up) { + if (s_ap && in->ap_sta > 0 && !s_aged) { + o.ap = true; + o.why = PROV_WHY_LINGER; + } + } else if (in->n_wifi <= 0) { + o.ap = true; + o.why = PROV_WHY_NOCFG; + } else if (s_aged || s_ap) { + /* `|| s_ap`:开着就不因为「刚配了一组、还没满 60 s」而关掉 —— 那正是人在网页上点完保存的那一刻。 */ + o.ap = true; + o.why = PROV_WHY_FAIL; + } + o.hold = o.ap && !in->wifi_up && in->ap_sta > 0 && s_touch; + s_ap = o.ap; + return o; +} + +const char *prov_why_str(prov_why_t w) +{ + switch (w) { + case PROV_WHY_OFF: return "off"; + case PROV_WHY_NOCFG: return "nocfg"; + case PROV_WHY_FAIL: return "fail"; + case PROV_WHY_LINGER: return "linger"; + } + return "?"; +} + +/* ---- 名字与口令 ------------------------------------------------------------ */ + +void prov_ssid(const char *id, char out[PROV_SSID_MAX]) +{ + const size_t n = strlen(id); + snprintf(out, PROV_SSID_MAX, "PhoneRail-%s", n > 6 ? id + n - 6 : id); +} + +/* ---- 表单 → 命令行 --------------------------------------------------------- */ + +#define FIELD_MAX 64 /* = CMD_JSON_STR_MAX − 1:再长解析器也不收 */ +#define FIELDS 5 + +/* 把正文按 \n 切成至多 FIELDS 段(行尾的 \r 去掉),拷进 f[]。返回段数;−1 = 太长 / 段太多,−2 = 有坏字符。 */ +static int split(const char *body, char f[FIELDS][FIELD_MAX + 1]) +{ + int n = 0; + size_t len = 0; + f[0][0] = '\0'; + for (const char *p = body;; p++) { + if (*p == '\n' || *p == '\0') { + if (len > 0 && f[n][len - 1] == '\r') { + len--; + } + f[n][len] = '\0'; + for (size_t i = 0; i < len; i++) { + const unsigned char c = (unsigned char)f[n][i]; + if (c < 0x20 || c == 0x7F || c == '"' || c == '\\') { + return -2; + } + } + n++; + if (*p == '\0') { + return n; + } + if (n >= FIELDS) { + return -1; + } + len = 0; + continue; + } + if (len >= FIELD_MAX) { + return -1; + } + f[n][len++] = *p; + } +} + +size_t prov_build_line(const char *body, uint32_t seq, char *out, size_t cap, const char **err) +{ + static const char *dummy; + if (err == NULL) { + err = &dummy; + } + *err = "bad_form"; + if (body == NULL || out == NULL || cap == 0 || seq == 0) { + return 0; + } + char f[FIELDS][FIELD_MAX + 1]; + const int n = split(body, f); + if (n < 0) { + *err = (n == -2) ? "bad_char" : "too_long"; + return 0; + } + for (int i = n; i < FIELDS; i++) { + f[i][0] = '\0'; + } + int w; + if (strcmp(f[0], "wifi") == 0 && n >= 2 && n <= 3 && f[1][0] != '\0') { + w = snprintf(out, cap, "{\"seq\":%lu,\"cmd\":\"wifi\",\"ssid\":\"%s\",\"pass\":\"%s\"}", + (unsigned long)seq, f[1], f[2]); + } else if (strcmp(f[0], "del") == 0 && n == 2 && f[1][0] != '\0') { + w = snprintf(out, cap, "{\"seq\":%lu,\"cmd\":\"wifi\",\"ssid\":\"%s\",\"del\":true}", + (unsigned long)seq, f[1]); + } else if (strcmp(f[0], "mqtt") == 0 && n >= 2 && f[1][0] != '\0') { + unsigned long tele = 0; + for (const char *p = f[4]; *p != '\0'; p++) { + if (*p < '0' || *p > '9' || tele > NETCFG_TELE_MAX_MS) { + return 0; + } + tele = tele * 10u + (unsigned long)(*p - '0'); + } + /* 口令留空 = 沿用已存的那份:网页上看不到旧口令(永不回显),不该逼人每次重输。 */ + if (f[3][0] == '\0') { + w = snprintf(out, cap, + "{\"seq\":%lu,\"cmd\":\"mqtt\",\"uri\":\"%s\",\"user\":\"%s\",\"keep_pass\":true," + "\"tele_ms\":%lu}", + (unsigned long)seq, f[1], f[2], tele); + } else { + w = snprintf(out, cap, + "{\"seq\":%lu,\"cmd\":\"mqtt\",\"uri\":\"%s\",\"user\":\"%s\",\"pass\":\"%s\"," + "\"tele_ms\":%lu}", + (unsigned long)seq, f[1], f[2], f[3], tele); + } + } else { + return 0; + } + if (w <= 0 || (size_t)w >= cap || w >= CMD_LINE_MAX) { + *err = "too_long"; + out[0] = '\0'; + return 0; + } + *err = NULL; + return (size_t)w; +} + +size_t prov_json_esc(const char *s, char *out, size_t cap) +{ + size_t n = 0; + for (; *s != '\0'; s++) { + const unsigned char c = (unsigned char)*s; + const bool esc = c < 0x20 || c == 0x7F || c == '"' || c == '\\'; + if (n + (esc ? 6u : 1u) >= cap) { + if (cap > 0) { + out[0] = '\0'; + } + return 0; + } + if (esc) { + n += (size_t)snprintf(out + n, cap - n, "\\u%04x", (unsigned)c); + } else { + out[n++] = (char)c; + } + } + if (cap > 0) { + out[n] = '\0'; + } + return n; +} diff --git a/firmware/main/prov.h b/firmware/main/prov.h new file mode 100644 index 0000000..f89ae9b --- /dev/null +++ b/firmware/main/prov.h @@ -0,0 +1,104 @@ +/* 配网热点(SoftAP + 网页,0.1.19,2026-10-06 用户定)的平台无关部分。 + ESP 胶水(热点、HTTP 服务、DNS 劫持、热点口令的 NVS)在 prov_esp.c。 + + ---- 为什么要有 ------------------------------------------------------------ + 0.1.18 之前改网络配置只有 USB 一条路(netcfg.h):路由器换了口令 / 板子搬了地方, + 就得拆机插线,而 Hub 休眠之后连 COM 口都不在。现在:**连不上 WiFi 就自己开一个热点**, + 手机连上去、在 192.168.4.1 的网页里改。 + + ---- 什么时候开、什么时候关(`prov_step`)--------------------------------- + | 情形 | 热点 | + |---|---| + | 一组 WiFi 都没配 | 立刻开(`nocfg`) | + | 配了,但连续 `PROV_FAIL_MS` 没拿到地址(含启动后一直连不上、连上过又掉) | 开(`fail`) | + | 开着、STA 仍没连上 | **一直开着**(不设超时:超时关掉 = 又只剩拆机) | + | STA 连上了,热点上没人 | 立刻关 | + | STA 连上了,热点上还有人 | 再留 `PROV_LINGER_MS` 让网页看到「连上了」,然后关(`linger`) | + + ⚠️ 路由器重启这类短暂断网也会让热点冒出来一会儿,网一恢复它就自己关。这是有意的: + 「等多久才算真的连不上」没有对的数,而热点开着不碰任何输出 —— 五台手机照常供电(§10.2)。 + + ---- 热点开着时 STA 还在重连 ---------------------------------------------- + STA 每试一次都要离开热点的信道去扫一圈(≈2~3 s),试得太密网页就卡。所以 + **热点上有人、且最近 `PROV_HOLD_MS` 内动过网页**时,重连间隔不短于 `PROV_SLOW_MS` + (`hold`,mlink_esp.c 的 on_wifi 里用)。只是放慢、**从不停**:停掉的话, + 一台连着热点忘了断的手机就能让板子永远不回网。 + + ---- 安全 ------------------------------------------------------------------ + · 热点是 WPA2,口令**所有板子都是 `PROV_PASS`(88888888,0.1.20,2026-10-06 用户定)**,编在固件里。 + 0.1.19 是每板一份随机口令存 NVS —— 断网时人手边未必有那张纸条,用户选了好记的。 + `net` 命令的 `ap.pass` 照样报它;它不是 netcfg.h 说的那「两个密码」:知道它只能**写**配置, + 网页与 `net` 一样从不回显 WiFi / MQTT 口令。 + · 网页只做三件事:读 `net`、扫描、提交 `wifi` / `mqtt`。别的命令从这条链路进不来 + (`prov_build_line` 只会拼出这两条)。 + · 能连上热点的人可以把板子指到另一个 broker —— 等同于拿到这块板。口令是公开的固定值, + 所以这道门实际只靠两条:热点只在「板子没网」时才开,而且人必须在无线范围内。 */ +#ifndef PROV_H +#define PROV_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define PROV_FAIL_MS 60000u /* STA 连续这么久没地址 ⇒ 开热点 */ +#define PROV_LINGER_MS 60000u /* STA 连上后,热点上还有人时再留多久 */ +#define PROV_HOLD_MS 300000u /* 最后一次动网页之后,放慢重连持续多久 */ +#define PROV_SLOW_MS 20000u /* 放慢时两次重连之间至少隔多久 */ + +typedef enum { + PROV_WHY_OFF = 0, + PROV_WHY_NOCFG, + PROV_WHY_FAIL, + PROV_WHY_LINGER, +} prov_why_t; + +typedef struct { + int n_wifi; /* 表里有几组 WiFi */ + bool wifi_up; /* STA 有地址 */ + int ap_sta; /* 热点上连着几台 */ + bool touched; /* 上一拍以来网页有过「人的动作」(打开页面 / 扫描 / 提交;状态轮询不算) */ +} prov_in_t; + +typedef struct { + bool ap; /* 热点该开着 */ + bool hold; /* STA 重连要放慢 */ + prov_why_t why; +} prov_out_t; + +void prov_init(uint32_t now); +prov_out_t prov_step(const prov_in_t *in, uint32_t now); +const char *prov_why_str(prov_why_t w); + +/* ---- 热点的名字与口令 ------------------------------------------------------ */ + +#define PROV_SSID_MAX 20 /* "PhoneRail-" + id 末 6 位 + 0 */ +#define PROV_PASS "88888888" /* 热点口令,所有板子一样(见文件头「安全」)。WPA2 要 8~63 位 */ + +/* `PhoneRail-`。 */ +void prov_ssid(const char *id, char out[PROV_SSID_MAX]); + +/* ---- 网页表单 → 一行命令 --------------------------------------------------- + 网页 POST 的正文是按行分的纯文本(免得在固件里做 URL 解码): + + wifi\n\n<口令> 加一组 / 改同名那组的口令 + del\n 删一组 + mqtt\n\n<账号>\n<口令>\n 口令留空 = 不改(`keep_pass`);tele_ms 留空 = 默认 + + 拼出来的就是 USB 上那两条命令,照样进唯一的命令处理器(cmd.h),校验一处都不重写。 + 命令的 JSON 解析器不认转义(cmd_json.h),所以字段里有引号 / 反斜杠 / 控制字符一律拒。 + 返回行长;0 = 拒,`*err` 是原因(`bad_form` / `bad_char` / `too_long`)。 */ +size_t prov_build_line(const char *body, uint32_t seq, char *out, size_t cap, const char **err); + +/* 一段不可信的字符串(扫描到的 SSID)→ JSON 字符串的内容:引号 / 反斜杠 / 控制字符写成 \u00XX, + 其余字节原样(按 UTF-8 理解)。返回写了几个字符;装不下返回 0 且 out 为空串。 */ +size_t prov_json_esc(const char *s, char *out, size_t cap); + +#ifdef __cplusplus +} +#endif + +#endif /* PROV_H */ diff --git a/firmware/main/prov.html b/firmware/main/prov.html new file mode 100644 index 0000000..778ea3c --- /dev/null +++ b/firmware/main/prov.html @@ -0,0 +1,92 @@ + + + + +PhoneRail 配网 + +

PhoneRail 配网

+
读取中…
+

状态

读取中…
+

WiFi

+
+ +
+
+ + +
+ +
+
+
+

服务器(MQTT)

+
+ + + + +
+
+
+

底板连上 WiFi 后约 1 分钟会自动关掉这个热点。口令只能写入,页面上不会显示已存的口令。

+ + diff --git a/firmware/main/prov_esp.c b/firmware/main/prov_esp.c new file mode 100644 index 0000000..30b8a76 --- /dev/null +++ b/firmware/main/prov_esp.c @@ -0,0 +1,416 @@ +/* 配网热点的 ESP 胶水(0.1.19)。**只进 IDF 目标**;什么时候开关、为什么安全在 prov.h。 + + ---- 三样东西,跟着热点一起开关 --------------------------------------------- + | 东西 | 说明 | + |---|---| + | SoftAP | `WIFI_MODE_STA` ↔ `WIFI_MODE_APSTA`。STA 那一半照常由 mlink_esp.c 重连 | + | HTTP 服务(esp_http_server,自己一个任务) | `/` 网页、`/s` 状态、`/scan` 扫描、`/c` 提交;其余一律 302 回 `/` | + | DNS 劫持(自己一个任务,起了就不退) | 所有 A 查询都答热点地址 ⇒ 手机连上热点自动弹出网页(captive portal) | + + ---- 配置怎么写进去 --------------------------------------------------------- + 网页 POST 的正文经 `prov_build_line()` 拼成一行命令,**投进命令队列**(`CMD_LINK_AP`), + 由执行器任务里那个唯一的命令处理器执行(cmd.h)—— 校验、存 NVS、通知 mlink_esp 重连, + 走的是和 USB 完全相同的路。HTTP 任务只是等回应(最多 3 s)再原样转给浏览器。 + + ---- 只认热点那一侧 --------------------------------------------------------- + HTTP 服务绑的是所有接口,而热点在 STA 连上之后还会留一会儿(`linger`)—— + 那段时间路由器那一侧也能访问到 80 端口。所以每个处理函数先看**这条连接的本地地址** + 是不是热点地址,不是就 403;DNS 那边看来源网段。 */ +#include "prov_esp.h" + +#include + +#include "cmd_queue.h" +#include "esp_event.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_random.h" /* 表单命令的 seq */ +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "lwip/sockets.h" +#include "phonerail.h" +#include "prov.h" +#include "tasks.h" + +static const char *TAG = "prov"; + +#define AP_CHANNEL 1 /* STA 一旦连上别的信道,热点会跟过去(单射频) */ +#define AP_MAX_STA 2 +#define SCAN_MAX 16 +#define CMD_WAIT_MS 3000 + +extern const char prov_html_start[] asm("_binary_prov_html_start"); +extern const char prov_html_end[] asm("_binary_prov_html_end"); + +static esp_netif_t *s_netif; +static char s_ssid[PROV_SSID_MAX]; +static httpd_handle_t s_httpd; +static volatile bool s_on; +static volatile int s_sta; +static volatile bool s_touch; +static uint32_t s_ip; /* 热点地址,网络字节序 */ +static bool s_dns_started; + +/* 等命令回应用的。回应是执行器任务送来的(cmd_queue.h),HTTP 任务在等: + `s_rgen` 对不上 = 这条回应来晚了(HTTP 那边已经超时走人),丢掉。 */ +static SemaphoreHandle_t s_rmtx; +static SemaphoreHandle_t s_rdone; +static char s_rbuf[CMD_OUT_MAX]; +static size_t s_rlen; +static uint32_t s_rgen; + +/* ---- 命令队列 ---------------------------------------------------------------- */ + +static void on_reply(void *ctx, const char *line, size_t len) +{ + xSemaphoreTake(s_rmtx, portMAX_DELAY); /* 持锁期间只有一次 memcpy:不会把执行器拖住 */ + if ((uint32_t)(uintptr_t)ctx == s_rgen && len < sizeof(s_rbuf)) { + memcpy(s_rbuf, line, len); + s_rbuf[len] = '\0'; + s_rlen = len; + xSemaphoreGive(s_rdone); + } + xSemaphoreGive(s_rmtx); +} + +/* 投一行命令、等回应。回应在 s_rbuf(只有 HTTP 任务读)。返回长度,0 = 队列满 / 超时。 */ +static size_t run_cmd(const char *line) +{ + xSemaphoreTake(s_rmtx, portMAX_DELAY); + const uint32_t gen = ++s_rgen; + s_rlen = 0; + xSemaphoreGive(s_rmtx); + (void)xSemaphoreTake(s_rdone, 0); + bool ok = cmd_queue_submit(CMD_LINK_AP, line, on_reply, (void *)(uintptr_t)gen); + ok = ok && xSemaphoreTake(s_rdone, pdMS_TO_TICKS(CMD_WAIT_MS)) == pdTRUE; + xSemaphoreTake(s_rmtx, portMAX_DELAY); + s_rgen++; /* 从这里起,迟到的回应不许再碰 s_rbuf */ + const size_t n = ok ? s_rlen : 0; + xSemaphoreGive(s_rmtx); + return n; +} + +/* ---- HTTP ------------------------------------------------------------------ */ + +/* 这条连接是不是从热点那一侧进来的(文件头「只认热点那一侧」)。 */ +static bool from_ap(httpd_req_t *r) +{ + struct sockaddr_storage a; + socklen_t n = sizeof(a); + if (getsockname(httpd_req_to_sockfd(r), (struct sockaddr *)&a, &n) != 0) { + return false; + } + uint32_t ip = 0; + if (a.ss_family == AF_INET) { + ip = ((const struct sockaddr_in *)&a)->sin_addr.s_addr; +#if CONFIG_LWIP_IPV6 + } else if (a.ss_family == AF_INET6) { /* 监听口是 v6 的,v4 连接以 ::ffff:a.b.c.d 出现 */ + const struct sockaddr_in6 *a6 = (const struct sockaddr_in6 *)&a; + static const uint8_t mapped[12] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF }; + if (memcmp(a6->sin6_addr.s6_addr, mapped, sizeof(mapped)) != 0) { + return false; + } + memcpy(&ip, &a6->sin6_addr.s6_addr[12], sizeof(ip)); +#endif + } + return s_on && ip != 0 && ip == s_ip; +} + +static esp_err_t deny(httpd_req_t *r) +{ + httpd_resp_set_status(r, "403 Forbidden"); + return httpd_resp_send(r, "403", HTTPD_RESP_USE_STRLEN); +} + +static void json_hdr(httpd_req_t *r) +{ + httpd_resp_set_type(r, "application/json"); + httpd_resp_set_hdr(r, "Cache-Control", "no-store"); +} + +static esp_err_t h_root(httpd_req_t *r) +{ + if (!from_ap(r)) { + return deny(r); + } + s_touch = true; + httpd_resp_set_type(r, "text/html; charset=utf-8"); + httpd_resp_set_hdr(r, "Cache-Control", "no-store"); + /* EMBED_TXTFILES 结尾补了一个 0,不发出去。 */ + return httpd_resp_send(r, prov_html_start, (ssize_t)(prov_html_end - prov_html_start - 1)); +} + +/* 状态轮询(3 s 一次):不算「人的动作」,否则页面开着就永远在放慢重连。 */ +static esp_err_t h_status(httpd_req_t *r) +{ + if (!from_ap(r)) { + return deny(r); + } + json_hdr(r); + const size_t n = run_cmd("{\"cmd\":\"net\"}"); + if (n == 0) { + httpd_resp_set_status(r, "503 Service Unavailable"); + return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"timeout\"}"); + } + httpd_resp_sendstr_chunk(r, "{\"fw\":\"" PHONERAIL_FW_VERSION "\",\"net\":"); + httpd_resp_send_chunk(r, s_rbuf, (ssize_t)n); + httpd_resp_sendstr_chunk(r, "}"); + return httpd_resp_sendstr_chunk(r, NULL); +} + +static esp_err_t h_scan(httpd_req_t *r) +{ + if (!from_ap(r)) { + return deny(r); + } + s_touch = true; + json_hdr(r); + /* 阻塞扫描 ≈2~3 s,卡的是 HTTP 任务自己。STA 正在连的那一两秒里驱动会拒(WIFI_STATE)—— + 如实告诉网页「过几秒再点」,不在这里重试。 */ + if (esp_wifi_scan_start(NULL, true) != ESP_OK) { + return httpd_resp_sendstr(r, "{\"err\":\"busy\"}"); + } + static wifi_ap_record_t rec[SCAN_MAX]; /* 只有 HTTP 任务进来 */ + uint16_t n = SCAN_MAX; + if (esp_wifi_scan_get_ap_records(&n, rec) != ESP_OK) { /* 必须取:不取驱动不放那块内存 */ + n = 0; + } + httpd_resp_sendstr_chunk(r, "{\"aps\":["); + bool first = true; + for (int i = 0; i < n; i++) { /* 驱动已按信号强弱排好 */ + const char *ssid = (const char *)rec[i].ssid; + bool dup = ssid[0] == '\0'; /* 隐藏的不列 */ + for (int k = 0; k < i && !dup; k++) { + dup = strcmp((const char *)rec[k].ssid, ssid) == 0; /* 同名的多个 AP 只列最强的 */ + } + char esc[32 * 6 + 1]; + if (dup || prov_json_esc(ssid, esc, sizeof(esc)) == 0) { + continue; + } + char item[sizeof(esc) + 48]; + snprintf(item, sizeof(item), "%s{\"ssid\":\"%s\",\"rssi\":%d,\"open\":%s}", first ? "" : ",", esc, + (int)rec[i].rssi, rec[i].authmode == WIFI_AUTH_OPEN ? "true" : "false"); + httpd_resp_sendstr_chunk(r, item); + first = false; + } + httpd_resp_sendstr_chunk(r, "]}"); + return httpd_resp_sendstr_chunk(r, NULL); +} + +static esp_err_t h_cfg(httpd_req_t *r) +{ + if (!from_ap(r)) { + return deny(r); + } + s_touch = true; + json_hdr(r); + char body[CMD_LINE_MAX]; + if (r->content_len == 0 || r->content_len >= sizeof(body)) { + return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"too_long\"}"); + } + size_t got = 0; + while (got < r->content_len) { + const int k = httpd_req_recv(r, body + got, r->content_len - got); + if (k <= 0) { + return ESP_FAIL; /* 连接断了 / 超时:关掉这条连接 */ + } + got += (size_t)k; + } + body[got] = '\0'; + if (strlen(body) != got) { + return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"bad_char\"}"); /* 正文里有 NUL */ + } + /* 会动东西的命令必须带 seq,而判重窗口是全局按值比的(cmd.h):用随机数, + 和服务器那边的序号撞上的机会是 32 条 / 2^32。 */ + uint32_t seq = esp_random(); + if (seq == 0) { + seq = 1; + } + char line[CMD_LINE_MAX]; + const char *err = NULL; + if (prov_build_line(body, seq, line, sizeof(line), &err) == 0) { + char out[48]; + snprintf(out, sizeof(out), "{\"ok\":false,\"err\":\"%s\"}", err); + return httpd_resp_sendstr(r, out); + } + const size_t n = run_cmd(line); + memset(body, 0, sizeof(body)); /* 口令别留在栈上 */ + memset(line, 0, sizeof(line)); + if (n == 0) { + return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"timeout\"}"); + } + return httpd_resp_send(r, s_rbuf, (ssize_t)n); +} + +/* 别的路径(手机的联网探测 generate_204 / hotspot-detect.html 之类)一律引回网页。 */ +static esp_err_t h_404(httpd_req_t *r, httpd_err_code_t e) +{ + (void)e; + httpd_resp_set_status(r, "302 Found"); + httpd_resp_set_hdr(r, "Location", "http://192.168.4.1/"); + httpd_resp_set_hdr(r, "Cache-Control", "no-store"); + return httpd_resp_send(r, "PhoneRail", HTTPD_RESP_USE_STRLEN); +} + +static void http_start(void) +{ + httpd_config_t hc = HTTPD_DEFAULT_CONFIG(); + hc.task_priority = task_desc(TASK_LINK)->prio; /* §10.3:链路最低 */ + hc.stack_size = 6144; + hc.max_open_sockets = 3; /* lwIP 一共 10 个 socket,MQTT / OTA / DNS 也要用 */ + hc.lru_purge_enable = true; + hc.max_uri_handlers = 4; + if (httpd_start(&s_httpd, &hc) != ESP_OK) { + s_httpd = NULL; + ESP_LOGE(TAG, "HTTP 服务起不来 —— 热点开着但没有网页,只能走 USB"); + return; + } + static const httpd_uri_t k_uris[] = { + { .uri = "/", .method = HTTP_GET, .handler = h_root }, + { .uri = "/s", .method = HTTP_GET, .handler = h_status }, + { .uri = "/scan", .method = HTTP_GET, .handler = h_scan }, + { .uri = "/c", .method = HTTP_POST, .handler = h_cfg }, + }; + for (size_t i = 0; i < sizeof(k_uris) / sizeof(k_uris[0]); i++) { + httpd_register_uri_handler(s_httpd, &k_uris[i]); + } + httpd_register_err_handler(s_httpd, HTTPD_404_NOT_FOUND, h_404); +} + +/* ---- DNS 劫持 --------------------------------------------------------------- */ + +/* 任务起了就不退(热点关着时收到的包直接丢):省掉「关热点时怎么把它从 recvfrom 里叫出来」。 */ +static void dns_task(void *arg) +{ + (void)arg; + int fd = -1; + for (;;) { + if (fd < 0) { + fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + struct sockaddr_in me = { .sin_family = AF_INET, .sin_port = htons(53), + .sin_addr.s_addr = htonl(INADDR_ANY) }; + const struct timeval tv = { .tv_sec = 1, .tv_usec = 0 }; + if (fd < 0 || bind(fd, (struct sockaddr *)&me, sizeof(me)) != 0 || + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) { + if (fd >= 0) { + close(fd); + fd = -1; + } + vTaskDelay(pdMS_TO_TICKS(5000)); /* socket 用光了:没有自动弹窗,网页照样能手输地址打开 */ + continue; + } + } + uint8_t q[256 + 16]; + struct sockaddr_in from; + socklen_t fl = sizeof(from); + const int n = recvfrom(fd, q, 256, 0, (struct sockaddr *)&from, &fl); + if (n < 0) { + continue; /* 1 s 超时 */ + } + /* 只答热点网段来的、热点开着时的、单问题的标准查询。 */ + if (!s_on || n < 17 || ((from.sin_addr.s_addr ^ s_ip) & htonl(0xFFFFFF00u)) != 0 || + (q[2] & 0xF8) != 0 || q[4] != 0 || q[5] != 1) { + continue; + } + int p = 12; + while (p < n && q[p] != 0) { + p += q[p] + 1; + } + p += 5; /* 结尾的 0 + QTYPE + QCLASS */ + if (p > n) { + continue; + } + const bool is_a = q[p - 4] == 0 && q[p - 3] == 1; + q[2] = 0x81; /* 回应 + 递归可用 */ + q[3] = 0x80; + q[6] = 0; + q[7] = is_a ? 1 : 0; /* 别的类型(AAAA / HTTPS):没有记录 */ + q[8] = q[9] = q[10] = q[11] = 0; + if (is_a) { + static const uint8_t ans[12] = { 0xC0, 0x0C, 0, 1, 0, 1, 0, 0, 0, 10, 0, 4 }; /* TTL 10 s */ + memcpy(q + p, ans, sizeof(ans)); + memcpy(q + p + sizeof(ans), &s_ip, 4); + p += (int)sizeof(ans) + 4; + } + (void)sendto(fd, q, (size_t)p, 0, (struct sockaddr *)&from, fl); + } +} + +/* ---- 热点 ------------------------------------------------------------------ */ + +static void on_ap(void *arg, esp_event_base_t base, int32_t id, void *data) +{ + (void)arg; + (void)base; + (void)data; + if (id == WIFI_EVENT_AP_STACONNECTED) { + s_sta = s_sta + 1; + } else if (id == WIFI_EVENT_AP_STADISCONNECTED && s_sta > 0) { + s_sta = s_sta - 1; + } +} + +void prov_esp_init(esp_netif_t *ap, const char *id) +{ + s_netif = ap; + s_rmtx = xSemaphoreCreateMutex(); + s_rdone = xSemaphoreCreateBinary(); + prov_ssid(id, s_ssid); + esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_AP_STACONNECTED, on_ap, NULL); + esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_AP_STADISCONNECTED, on_ap, NULL); +} + +void prov_esp_set(bool on) +{ + if (on == s_on || s_netif == NULL || s_rmtx == NULL || s_rdone == NULL) { + return; + } + if (!on) { + s_on = false; /* 先置:正在处理的请求下一步就 403 */ + if (s_httpd != NULL) { + httpd_stop(s_httpd); /* 等 HTTP 任务退出(最多等完一次扫描 / 一条命令) */ + s_httpd = NULL; + } + esp_wifi_set_mode(WIFI_MODE_STA); + s_sta = 0; + ESP_LOGI(TAG, "配网热点已关"); + return; + } + wifi_config_t ac = { 0 }; + memcpy(ac.ap.ssid, s_ssid, strlen(s_ssid)); + ac.ap.ssid_len = (uint8_t)strlen(s_ssid); + memcpy(ac.ap.password, PROV_PASS, strlen(PROV_PASS)); + ac.ap.channel = AP_CHANNEL; + ac.ap.authmode = WIFI_AUTH_WPA2_PSK; + ac.ap.max_connection = AP_MAX_STA; + if (esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK || esp_wifi_set_config(WIFI_IF_AP, &ac) != ESP_OK) { + ESP_LOGE(TAG, "配网热点开不起来 —— 只能走 USB"); + esp_wifi_set_mode(WIFI_MODE_STA); + return; /* s_on 仍是 false:t_link 过 10 s 再试(mlink_esp.c 的 prov_poll) */ + } + esp_netif_ip_info_t ip; + s_ip = (esp_netif_get_ip_info(s_netif, &ip) == ESP_OK) ? ip.ip.addr : 0; + s_sta = 0; + s_touch = false; + s_on = true; + http_start(); + if (!s_dns_started) { + s_dns_started = xTaskCreate(dns_task, "prov_dns", 3072, NULL, task_desc(TASK_LINK)->prio, NULL) == pdPASS; + } + ESP_LOGW(TAG, "配网热点已开:「%s」,连上后开 http://192.168.4.1/", s_ssid); /* 只打名字,不打口令 */ +} + +bool prov_esp_on(void) { return s_on; } +int prov_esp_sta_count(void) { return s_sta; } + +bool prov_esp_take_touch(void) +{ + const bool t = s_touch; + s_touch = false; + return t; +} + +const char *prov_esp_ssid(void) { return s_ssid; } +const char *prov_esp_pass(void) { return PROV_PASS; } diff --git a/firmware/main/prov_esp.h b/firmware/main/prov_esp.h new file mode 100644 index 0000000..19e8edd --- /dev/null +++ b/firmware/main/prov_esp.h @@ -0,0 +1,22 @@ +/* 配网热点的 ESP 胶水(0.1.19)。只在 IDF 目标里有;判据与安全说明在 prov.h,实现说明在 prov_esp.c 文件头。 */ +#ifndef PROV_ESP_H +#define PROV_ESP_H + +#include + +#include "esp_netif.h" + +/* 定热点名字、挂热点事件。要在 `esp_wifi_start()` 之后、链路任务起来之前调一次。 + `ap` 是 `esp_netif_create_default_wifi_ap()` 的返回值,`id` 是板子 ID(热点名取它的末 6 位)。 */ +void prov_esp_init(esp_netif_t *ap, const char *id); + +/* 开 / 关热点(连同网页与 DNS 劫持)。**只在 t_link 里调**(关的时候要等 HTTP 任务退出)。幂等。 */ +void prov_esp_set(bool on); + +bool prov_esp_on(void); /* 热点现在真的开着吗(开失败时是 false) */ +int prov_esp_sta_count(void); /* 热点上连着几台 */ +bool prov_esp_take_touch(void); /* 上次问过之后网页有没有「人的动作」(读了就清) */ +const char *prov_esp_ssid(void); +const char *prov_esp_pass(void); + +#endif /* PROV_ESP_H */ diff --git a/firmware/main/t_thermal.c b/firmware/main/t_thermal.c index d41abf8..1690217 100644 --- a/firmware/main/t_thermal.c +++ b/firmware/main/t_thermal.c @@ -25,6 +25,10 @@ static bool s_inited; /* 上位机 `fan` 命令给的下限(A12)。**跨任务**:命令处理器在执行器任务里写, 本模块在热管理任务里读。一个对齐的 16 位量,Xtensa 上读写是原子的,不加锁。 */ static volatile uint16_t s_floor; +/* 下限的时限(`t_thermal_set_floor_for`)。写的顺序是「先 until、再 timed、最后 floor」, + 读的一侧最坏只会晚一圈(1 s)才认到新值。 */ +static volatile uint32_t s_floor_until; +static volatile bool s_floor_timed; /* 风扇侧占空比 → 真正写进 `hal_pwm` 的值。Q6 是低边管,栅极高 = 把风扇的 PWM 线拉低 ⇒ 反相(`t_thermal.h` 硬件那三条的第 2 条)。 */ @@ -107,6 +111,7 @@ hal_err_t t_thermal_init(thermal_snap_t *s, therm_temp_fn temp, therm_tach_fn ta return e; } s->fan_en = true; + s->fan_starts = 1; s->en_ms = hal_time_now_ms(); s->last_ms = s->en_ms; @@ -133,6 +138,17 @@ static void tach_step(thermal_snap_t *s, int idx, uint32_t dt_ms, uint16_t duty) const uint32_t delta = now_edges - t->last_edges; /* uint32 相减,回绕安全 */ t->last_edges = now_edges; + /* 风扇断着电:不累加、不判(惯性滑行那几圈的脉冲也不算数),转速如实报 0。 + `stalled` 不动 —— 见头文件「断电期间不判停转」。 */ + if (!s->fan_en) { + t->win_edges = 0; + t->win_on_ms = 0; + t->win_ms = 0; + t->rpm = 0; + t->rpm_valid = true; + return; + } + /* 启动宽限期内一直重置窗口:这时候风扇还在起转,脉冲本来就没有或很稀。 */ if (s->fan_en && hal_time_elapsed_ms(s->en_ms) < T_THERMAL_SPINUP_MS) { t->win_edges = 0; @@ -216,11 +232,37 @@ hal_err_t t_thermal_step(thermal_snap_t *s) s->temp_errors++; } uint16_t duty = t_thermal_duty_for(s->temp_max_mc, s->temp_valid); + if (s_floor_timed && (int32_t)(now_ms - s_floor_until) >= 0) { + s_floor = 0; /* 上位机给的时限到了:交回自动 */ + s_floor_timed = false; + } const uint16_t floor_ = s_floor; if (floor_ > duty) { duty = floor_; /* 手动值只抬不压:曲线要得更高时照曲线来 */ } - (void)apply_duty(s, duty); + + /* ---- 开还是关(`T_THERMAL_FAN_OFF_MC` 上的说明)---- + 读不到温度、上位机要风:一律开。否则按迟滞:开着的掉到 OFF 以下才关,关着的到 ON 才开。 */ + bool want_on = true; + if (s->temp_valid && floor_ == 0) { + want_on = s->fan_en ? (s->temp_max_mc >= T_THERMAL_FAN_OFF_MC) + : (s->temp_max_mc >= T_THERMAL_FAN_ON_MC); + } + if (want_on != s->fan_en) { + /* 开:先把占空比写好再给 12V(同 init 的顺序);关:先断 12V。写失败就维持原状,下一圈再来。 */ + if (want_on) { + (void)apply_duty(s, duty); + } + if (hal_gpio_write(BOARD_PIN_FAN_EN, want_on ? 1 : 0) == HAL_OK) { + s->fan_en = want_on; + if (want_on) { + s->en_ms = now_ms; /* 启动宽限从这里重新算 */ + s->fan_starts++; + } + } + } + /* 断着电时占空比报 0:遥测里「duty 300、en false」会让人以为它在转。 */ + (void)apply_duty(s, s->fan_en ? duty : 0); /* ---- 过温降载:本模块只**报**,关路是限功率那一包的事 ---- */ if (s->temp_valid) { @@ -241,9 +283,25 @@ hal_err_t t_thermal_step(thermal_snap_t *s) return HAL_OK; } -void t_thermal_set_floor(uint16_t permille) +void t_thermal_set_floor(uint16_t permille) { t_thermal_set_floor_for(permille, 0); } + +void t_thermal_set_floor_for(uint16_t permille, uint32_t ttl_ms) { + const bool timed = ttl_ms > 0 && permille > 0; + if (timed) { + s_floor_until = hal_time_now_ms() + ttl_ms; + } + s_floor_timed = timed; s_floor = (permille > 1000u) ? 1000u : permille; } uint16_t t_thermal_floor(void) { return s_floor; } + +uint32_t t_thermal_floor_left_ms(void) +{ + if (!s_floor_timed || s_floor == 0) { + return 0; + } + const int32_t left = (int32_t)(s_floor_until - hal_time_now_ms()); + return left > 0 ? (uint32_t)left : 0; +} diff --git a/firmware/main/t_thermal.h b/firmware/main/t_thermal.h index aec38c0..a1740a4 100644 --- a/firmware/main/t_thermal.h +++ b/firmware/main/t_thermal.h @@ -62,6 +62,9 @@ ⚠️ 与之配套的是**占空比下限** `T_THERMAL_DUTY_MIN`:占空比真掉到 0, 导通时间永远攒不满,这条判据就变成了「永远不判」。所以闭环的下限不是 0; 真要停风扇只能撤 `FAN_EN`(§22.6:0 % PWM 时四线风扇仍转 3300 RPM)。 + 0.1.21 起凉的时候就是这么停的(`T_THERMAL_FAN_OFF_MC`):断电期间**不判停转**、窗口清零、 + 转速报 0;`stalled` 保持断电前的结论(一只没插的风扇不会因为每次启停各记一条事件)。 + 重新上电后照样先过启动宽限。 ---- 事件:入队,不落盘 -------------------------------------------------- `evlog_put()` 是几条指令的入队,任何任务都能调;`evlog_flush()` 是唯一碰 flash @@ -103,8 +106,18 @@ extern "C" { 而新位置的温升曲线只有上板才有。这里给的是**形状**(分段线性 + 迟滞), 数值先按常识填、单列在这里,上板实测之后只改这四行。 */ -/* 低于它就按下限转(安静优先)。 */ +/* 曲线起点:这个温度按下限转,往上线性加到满速。 */ #define T_THERMAL_TEMP_LO_MC 40000 /* 40.000 °C —— 待实测 */ + +/* **40 °C 以下风扇断电**(0.1.21,2026-10-06 用户定):撤 `FAN_EN`,不是把占空比压到 0 —— + 四线风扇 0 % PWM 仍转 3300 RPM(§22.6),要它停只能断 12V。 + 到 `FAN_ON_MC` 开、掉到 `FAN_OFF_MC` 以下才关:两个数之间维持原状。 + ⚠️ 这 2 °C 的迟滞不能省:板温就在 40 °C 上下晃(板 B 空闲实测 38~41 °C), + 没有它风扇每隔几秒启停一次,每次起转都是一声响,还各带 5 s 的停转判据盲区。 + **不受它管的三种情况,风扇一律开着**:两路温度都读不到(§10.10)、上位机给了下限(`fan` 命令)、 + 以及启动后读到第一个温度之前的那一秒。 */ +#define T_THERMAL_FAN_ON_MC T_THERMAL_TEMP_LO_MC +#define T_THERMAL_FAN_OFF_MC 38000 /* 高于它满速。 */ #define T_THERMAL_TEMP_HI_MC 65000 /* 65.000 °C —— 待实测 */ /* 过温降载门限(FR-13「超阈值先降流再关路」)。本模块只**报**,不动电源 —— @@ -204,10 +217,11 @@ typedef struct { int32_t temp_max_mc; /* 两路里**高的那一个**:一点凉不代表另一点不烫 */ bool temp_valid; /* 至少有一路读到了 */ - uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径 */ + uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径;风扇断电时是 0 */ uint16_t gpio_duty; /* 真写进 hal_pwm 的值(Q6 反相 ⇒ 1000 − duty) */ - bool fan_en; /* `FAN_EN` 的命令值 */ + bool fan_en; /* `FAN_EN` 的命令值(凉了会撤,见 `T_THERMAL_FAN_OFF_MC`) */ + uint32_t fan_starts; /* 上过几次电(含启动那一次):启停太勤 = 迟滞不够 */ uint32_t en_ms; /* 拉高的时刻,启动宽限从这里算 */ bool derate; /* 正在过温降载 */ @@ -239,9 +253,19 @@ uint16_t t_thermal_duty_for(int32_t milli_c, bool valid); /* 上位机手动给的占空比**下限**(风扇侧千分比,A12 的 `fan` 命令)。0 = 全自动。 ⚠️ 只抬不压:实际占空比 = max(温度曲线, 下限)。「手动把风扇调小」这件事 不提供 —— 那等于允许远程关掉散热,而温度读不到时满速(§10.10)的兜底也会被它盖掉。 - 下一圈 `t_thermal_step()` 生效;不随 `t_thermal_init()` 清零(重启自然清零)。 */ + 下限 > 0 时风扇一定开着(哪怕板温低于 40 °C):板上的 NTC 看不见手机 SoC 有多热, + 上位机(AppTask 按手机自报的温度)要风的时候就得有风。 + 下一圈 `t_thermal_step()` 生效;不随 `t_thermal_init()` 清零(重启自然清零)。 + + `ttl_ms`(0.1.21):下限保持多久,0 = 一直保持。到期自动回 0(全自动)—— + 给「程序定时续期」的上位机用:它挂了 / 断网了,风扇不会永远停在它最后要的那个转速上。 + `t_thermal_floor_left_ms()`:还剩多久,没设时限或已经没有下限时是 0。 */ void t_thermal_set_floor(uint16_t permille); +void t_thermal_set_floor_for(uint16_t permille, uint32_t ttl_ms); uint16_t t_thermal_floor(void); +uint32_t t_thermal_floor_left_ms(void); + +#define T_THERMAL_FLOOR_TTL_MAX_S 86400 /* `fan` 命令的 `sec` 上限:一天 */ #ifdef __cplusplus } diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index 95bf4e6..44da53c 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -14,6 +14,9 @@ CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y CONFIG_ESP_CONSOLE_SECONDARY_NONE=y CONFIG_LOG_DEFAULT_LEVEL_INFO=y +# 配网热点的网页(prov_esp.c):手机浏览器 / 联网探测的请求头常常超过默认的 512 字节(回 431)。 +CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024 + # 自定义分区表:默认那张里没有事件日志的地方(需求 §10.7)。见 partitions.csv。 CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" diff --git a/firmware/test/test_cmd.c b/firmware/test/test_cmd.c index e6e7f27..e4d5881 100644 --- a/firmware/test/test_cmd.c +++ b/firmware/test/test_cmd.c @@ -592,6 +592,20 @@ static void test_fan_floor(void) TEST_ASSERT_EQUAL_UINT16(700, t_thermal_floor()); ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"fan\",\"duty\":0}"), "\"floor\":0"); TEST_ASSERT_EQUAL_UINT16(0, t_thermal_floor()); + + /* 0.1.21 `sec`:带时限的下限(到期回落在 test_t_thermal 里测)。没给 sec 的回应里不带它。 */ + ASSERT_LACKS(r, "\"sec\""); + ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"fan\",\"duty\":600,\"sec\":90}"), "\"floor\":600,\"sec\":90"); + TEST_ASSERT_EQUAL_UINT32(90000, t_thermal_floor_left_ms()); + ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"fan\",\"duty\":600}"), "\"floor\":600"); /* 不带 sec = 改成一直保持 */ + TEST_ASSERT_EQUAL_UINT32(0, t_thermal_floor_left_ms()); + ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"fan\",\"duty\":600,\"sec\":86401}"), "\"err\":\"range\""); + ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"fan\",\"duty\":600,\"sec\":-1}"), "\"err\":\"range\""); + ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"fan\",\"duty\":600,\"sec\":\"90\"}"), "\"err\":\"arg\""); + TEST_ASSERT_EQUAL_UINT16(600, t_thermal_floor()); + ASSERT_HAS(send("{\"seq\":8,\"cmd\":\"fan\",\"duty\":0,\"sec\":90}"), "\"floor\":0"); /* 0 就是交回自动,时限无意义 */ + TEST_ASSERT_EQUAL_UINT32(0, t_thermal_floor_left_ms()); + t_thermal_set_floor(0); } /* 0.1.14 hub:改模式 / 保持窗口,两条链路都收;越界记事件、缺参数不记; diff --git a/firmware/test/test_prov.c b/firmware/test/test_prov.c new file mode 100644 index 0000000..191bf22 --- /dev/null +++ b/firmware/test/test_prov.c @@ -0,0 +1,350 @@ +/* 0.1.19:配网热点(prov.h)的平台无关部分,加上「配置命令从哪条链路收」的新口径。 + + 测的是上板不好查、写错了又很疼的几件事: + · 热点**什么时候开、什么时候关** —— 开早了白白露一个入口,关早了人正填着表单就断; + · STA 连不上时热点**不许自己消失**(消失 = 又只剩拆机插 USB); + · 网页表单拼出来的命令行**拼不出别的命令**,带引号的字段进不来; + · 配置命令:热点链路收、MQTT 照旧拒;`keep_pass` 不丢旧口令;`net` 报热点状态。 */ +#include +#include + +#include "cmd.h" +#include "evlog.h" +#include "netcfg.h" +#include "prov.h" +#include "sim_hal.h" +#include "unity.h" + +static char s_out[CMD_OUT_MAX]; +static netcfg_t s_saved; +static int s_saves; + +static hal_err_t fake_save(void *ctx, const netcfg_t *cfg) +{ + (void)ctx; + s_saved = *cfg; + s_saves++; + return HAL_OK; +} + +void setUp(void) +{ + sim_hal_reset(); + memset(&s_saved, 0, sizeof(s_saved)); + s_saves = 0; + netcfg_init(NULL, fake_save, NULL, NULL); + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + cmd_init(NULL); + prov_init(0); +} + +void tearDown(void) {} + +static const char *send_on(cmd_link_t link, const char *line) +{ + cmd_handle_line(link, line, s_out, sizeof(s_out)); + return s_out; +} + +#define ASSERT_HAS(r, n) UNITY_TEST_ASSERT_NOT_NULL(strstr((r), (n)), __LINE__, (r)) +#define ASSERT_LACKS(r, n) UNITY_TEST_ASSERT_NULL(strstr((r), (n)), __LINE__, (r)) + +static prov_out_t step(int n_wifi, bool up, int sta, bool touched, uint32_t now) +{ + const prov_in_t in = { .n_wifi = n_wifi, .wifi_up = up, .ap_sta = sta, .touched = touched }; + return prov_step(&in, now); +} + +/* ---- 判据 ------------------------------------------------------------------ */ + +/* ⭐ 一组都没配:第一拍就开,不等 60 s。 */ +static void test_no_config_opens_at_once(void) +{ + const prov_out_t o = step(0, false, 0, false, 0); + TEST_ASSERT_TRUE(o.ap); + TEST_ASSERT_EQUAL_INT(PROV_WHY_NOCFG, o.why); + TEST_ASSERT_FALSE(o.hold); +} + +/* ⭐ 配了但连不上:满 60 s 才开;之后一直开着,哪怕再过几天。 */ +static void test_failure_opens_after_a_minute_and_stays(void) +{ + TEST_ASSERT_FALSE(step(1, false, 0, false, 0).ap); + TEST_ASSERT_FALSE(step(1, false, 0, false, PROV_FAIL_MS - 1).ap); + prov_out_t o = step(1, false, 0, false, PROV_FAIL_MS); + TEST_ASSERT_TRUE(o.ap); + TEST_ASSERT_EQUAL_INT(PROV_WHY_FAIL, o.why); + /* 时钟回绕(49.7 天)之后「断了多久」会变小 —— 不许因此关掉。 */ + TEST_ASSERT_TRUE(step(1, false, 0, false, 0xFFFFFF00u).ap); + TEST_ASSERT_TRUE(step(1, false, 0, false, 0x00000100u).ap); +} + +/* 连上过又掉:从掉的那一刻重新数 60 s(路由器重启一下不该立刻冒热点)。 */ +static void test_a_drop_restarts_the_minute(void) +{ + TEST_ASSERT_FALSE(step(1, true, 0, false, 1000).ap); + TEST_ASSERT_FALSE(step(1, true, 0, false, 500000).ap); + TEST_ASSERT_FALSE(step(1, false, 0, false, 500100).ap); + TEST_ASSERT_FALSE(step(1, false, 0, false, 500100 + PROV_FAIL_MS - 1).ap); + TEST_ASSERT_TRUE(step(1, false, 0, false, 500100 + PROV_FAIL_MS).ap); + /* 没满 60 s 就连回来了:计时作废。 */ + prov_init(0); + (void)step(1, false, 0, false, 0); + (void)step(1, true, 0, false, 30000); + (void)step(1, false, 0, false, 40000); + TEST_ASSERT_FALSE(step(1, false, 0, false, 70000).ap); /* 离启动 70 s,但离这次掉线才 30 s */ +} + +/* ⭐ STA 连上了:热点上没人立刻关;有人再留 60 s(让网页看到「连上了」),到点照关。 */ +static void test_closes_when_sta_comes_up(void) +{ + TEST_ASSERT_TRUE(step(1, false, 0, false, PROV_FAIL_MS).ap); + TEST_ASSERT_FALSE(step(1, true, 0, false, PROV_FAIL_MS + 100).ap); + + prov_init(0); + TEST_ASSERT_TRUE(step(1, false, 1, false, PROV_FAIL_MS).ap); + prov_out_t o = step(1, true, 1, false, 100000); + TEST_ASSERT_TRUE(o.ap); + TEST_ASSERT_EQUAL_INT(PROV_WHY_LINGER, o.why); + TEST_ASSERT_FALSE(o.hold); /* 已经连上了,没有重连可放慢 */ + TEST_ASSERT_TRUE(step(1, true, 1, false, 100000 + PROV_LINGER_MS - 1).ap); + TEST_ASSERT_FALSE(step(1, true, 1, false, 100000 + PROV_LINGER_MS).ap); + /* 留的那一分钟里人走了:马上关,不等到点。 */ + prov_init(0); + (void)step(1, false, 1, false, PROV_FAIL_MS); + TEST_ASSERT_TRUE(step(1, true, 1, false, 100000).ap); + TEST_ASSERT_FALSE(step(1, true, 0, false, 101000).ap); + /* 关了就是关了:连着的时候不会再自己开。 */ + TEST_ASSERT_FALSE(step(1, true, 1, false, 102000).ap); +} + +/* ⭐ 没配网时开的热点:人在网页上刚存了第一组 WiFi(n 0 → 1,离启动还不到 60 s)—— + 不许在这一刻把热点关掉,要等 STA 真的连上。 */ +static void test_first_config_does_not_close_the_ap(void) +{ + TEST_ASSERT_TRUE(step(0, false, 1, true, 5000).ap); + prov_out_t o = step(1, false, 1, true, 6000); + TEST_ASSERT_TRUE(o.ap); + TEST_ASSERT_EQUAL_INT(PROV_WHY_FAIL, o.why); + TEST_ASSERT_TRUE(o.hold); + TEST_ASSERT_TRUE(step(1, true, 1, false, 9000).ap); /* 连上了:linger */ +} + +/* 放慢重连:热点上有人 + 最近 5 分钟动过网页。连上热点本身算一次动作;状态轮询不算。 */ +static void test_hold_follows_activity(void) +{ + TEST_ASSERT_FALSE(step(1, false, 0, false, PROV_FAIL_MS).hold); /* 没人 */ + TEST_ASSERT_TRUE(step(1, false, 1, false, 70000).hold); /* 刚连上 */ + TEST_ASSERT_TRUE(step(1, false, 1, false, 70000 + PROV_HOLD_MS - 1).hold); + TEST_ASSERT_FALSE(step(1, false, 1, false, 70000 + PROV_HOLD_MS).hold); /* 连着但 5 分钟没动:恢复正常节奏 */ + TEST_ASSERT_TRUE(step(1, false, 1, true, 900000).hold); /* 又点了一下 */ + TEST_ASSERT_FALSE(step(1, false, 1, false, 900000 + PROV_HOLD_MS).hold); + /* 过期的窗口不许在时钟回绕后复活(人一直连着没走)。 */ + TEST_ASSERT_FALSE(step(1, false, 1, false, 900000u + 0xFFFFFF00u).hold); + TEST_ASSERT_TRUE(step(1, false, 1, true, 5000).hold); + TEST_ASSERT_FALSE(step(1, false, 0, false, 6000).hold); /* 人走了 */ + TEST_ASSERT_TRUE(step(1, false, 0, false, 7000).ap); /* 热点还在 */ + TEST_ASSERT_GREATER_OR_EQUAL_UINT32(10000, PROV_SLOW_MS); /* 一次连接尝试 ≈3 s,间隔得明显比它长 */ +} + +/* ---- 名字与口令 ------------------------------------------------------------ */ + +static void test_ssid_and_password(void) +{ + char ssid[PROV_SSID_MAX]; + prov_ssid("b81f3fc9c8bc", ssid); + TEST_ASSERT_EQUAL_STRING("PhoneRail-c9c8bc", ssid); + prov_ssid("", ssid); + TEST_ASSERT_EQUAL_STRING("PhoneRail-", ssid); + + /* 口令是固定值:WPA2 收 8~63 位;装得进 net_status_t,也不需要 JSON 转义。 */ + TEST_ASSERT_EQUAL_STRING("88888888", PROV_PASS); + TEST_ASSERT_TRUE(strlen(PROV_PASS) >= 8 && strlen(PROV_PASS) <= 63); + TEST_ASSERT_NULL(strpbrk(PROV_PASS, "\"\\")); +} + +/* ---- 表单 → 命令行 --------------------------------------------------------- */ + +static const char *build(const char *body, const char **err) +{ + static char line[CMD_LINE_MAX]; + line[0] = '\0'; + const size_t n = prov_build_line(body, 77, line, sizeof(line), err); + TEST_ASSERT_EQUAL_UINT(n, strlen(line)); + return line; +} + +/* ⭐ 拼出来的行能被命令处理器原样吃下去、存进后端。 */ +static void test_form_becomes_the_usb_commands(void) +{ + const char *err = "x"; + const char *l = build("wifi\nfarm 2g\ns3cret pass", &err); + TEST_ASSERT_NULL(err); + TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"wifi\",\"ssid\":\"farm 2g\",\"pass\":\"s3cret pass\"}", l); + ASSERT_HAS(send_on(CMD_LINK_AP, l), "\"ok\":true,\"n\":1"); + TEST_ASSERT_EQUAL_STRING("s3cret pass", s_saved.wifi[0].pass); + + /* 开放网络:口令那一行可以是空的,也可以整行没有;行尾的 \r 不算内容。 */ + TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"wifi\",\"ssid\":\"open\",\"pass\":\"\"}", build("wifi\nopen\n", &err)); + TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"wifi\",\"ssid\":\"open\",\"pass\":\"\"}", build("wifi\r\nopen\r", &err)); + /* 中文名字(UTF-8)原样过。 */ + ASSERT_HAS(build("wifi\n\xE5\xAE\xB6\n12345678", &err), "\"ssid\":\"\xE5\xAE\xB6\""); + + TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"wifi\",\"ssid\":\"farm 2g\",\"del\":true}", build("del\nfarm 2g", &err)); + cmd_init(NULL); /* 清判重窗口:这里每条都用 seq 77 */ + ASSERT_HAS(send_on(CMD_LINK_AP, build("del\nfarm 2g", &err)), "\"ok\":true,\"n\":0"); + + l = build("mqtt\nmqtts://home.example:8883\nboard-b\nBrokerPw77\n2000", &err); + TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"mqtt\",\"uri\":\"mqtts://home.example:8883\",\"user\":\"board-b\"," + "\"pass\":\"BrokerPw77\",\"tele_ms\":2000}", l); + cmd_init(NULL); /* 清判重窗口:这里每条都用 seq 77 */ + ASSERT_HAS(send_on(CMD_LINK_AP, l), "\"ok\":true"); + TEST_ASSERT_EQUAL_UINT32(2000, s_saved.tele_ms); +} + +/* ⭐ MQTT 口令留空 = 不改:只换地址时旧口令还在;给了 keep_pass 又给 pass 是自相矛盾。 */ +static void test_keep_pass_keeps_the_stored_password(void) +{ + send_on(CMD_LINK_USB, "{\"seq\":1,\"cmd\":\"mqtt\",\"uri\":\"mqtt://old:1883\",\"user\":\"u\",\"pass\":\"BrokerPw77\"}"); + const char *err = NULL; + const char *l = build("mqtt\nmqtt://new:1883\nu2\n\n", &err); + TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"mqtt\",\"uri\":\"mqtt://new:1883\",\"user\":\"u2\"," + "\"keep_pass\":true,\"tele_ms\":0}", l); + const char *r = send_on(CMD_LINK_AP, l); + ASSERT_HAS(r, "\"ok\":true"); + ASSERT_LACKS(r, "BrokerPw77"); + TEST_ASSERT_EQUAL_STRING("mqtt://new:1883", s_saved.uri); + TEST_ASSERT_EQUAL_STRING("u2", s_saved.user); + TEST_ASSERT_EQUAL_STRING("BrokerPw77", s_saved.mpass); + + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":3,\"cmd\":\"mqtt\",\"uri\":\"mqtt://x:1\",\"pass\":\"other\"," + "\"keep_pass\":true}"), "\"err\":\"arg\""); + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":4,\"cmd\":\"mqtt\",\"uri\":\"mqtt://x:1\",\"keep_pass\":1}"), + "\"err\":\"arg\""); + TEST_ASSERT_EQUAL_STRING("BrokerPw77", s_saved.mpass); + /* keep_pass:false 就是老行为:没给 pass = 清空。 */ + ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":5,\"cmd\":\"mqtt\",\"uri\":\"mqtt://x:1\",\"keep_pass\":false}"), + "\"ok\":true"); + TEST_ASSERT_EQUAL_STRING("", s_saved.mpass); +} + +/* ⭐ 拼不出别的命令;能把 JSON 撑破的字符进不来。 */ +static void test_form_rejects_what_it_should(void) +{ + static const struct { + const char *body; + const char *err; + } k[] = { + { "", "bad_form" }, + { "wifi", "bad_form" }, + { "wifi\n\n12345678", "bad_form" }, /* 没名字 */ + { "wifi\na\nb\nc", "bad_form" }, /* 多了一行 */ + { "del\na\nb", "bad_form" }, + { "mqtt\n", "bad_form" }, + { "mqtt\nmqtt://h\nu\np\n12x", "bad_form" }, /* tele 不是数字 */ + { "mqtt\nmqtt://h\nu\np\n99999999999999", "bad_form" }, + { "power\n1", "bad_form" }, /* 别的命令 */ + { "status", "bad_form" }, + { "wifi\na\"b\n12345678", "bad_char" }, + { "wifi\na\n1234\\5678", "bad_char" }, + { "wifi\na\tb\n12345678", "bad_char" }, + { "wifi\na\n12345678\",\"cmd\":\"power", "bad_char" }, /* 想往行里塞第二个键 */ + { "wifi\na\nb\nc\nd\ne", "too_long" }, /* 行数超了 */ + { "wifi\n0123456789012345678901234567890123456789012345678901234567890123456789\nx", "too_long" }, + }; + for (size_t i = 0; i < sizeof(k) / sizeof(k[0]); i++) { + const char *err = NULL; + char line[CMD_LINE_MAX] = "junk"; + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, prov_build_line(k[i].body, 1, line, sizeof(line), &err), k[i].body); + TEST_ASSERT_EQUAL_STRING_MESSAGE(k[i].err, err, k[i].body); + } + /* seq 0 不是合法序号;缓冲不够不越界。 */ + char small[16]; + TEST_ASSERT_EQUAL_UINT(0, prov_build_line("wifi\na\n12345678", 0, small, sizeof(small), NULL)); + TEST_ASSERT_EQUAL_UINT(0, prov_build_line("wifi\na\n12345678", 1, small, sizeof(small), NULL)); + TEST_ASSERT_EQUAL_STRING("", small); + + /* 三个字段都顶到头(64 + 32 + 64)的 mqtt 行也装得进一行命令。 */ + char body[256]; + char f64[65]; + memset(f64, 'p', 64); + f64[64] = '\0'; + snprintf(body, sizeof(body), "mqtt\nmqtts://%.*s\n%.32s\n%s\n60000", 56, f64, f64, f64); + char line[CMD_LINE_MAX]; + const char *err = "x"; + TEST_ASSERT_GREATER_THAN_UINT(0, prov_build_line(body, 0xFFFFFFFFu, line, sizeof(line), &err)); + ASSERT_HAS(send_on(CMD_LINK_AP, line), "\"ok\":true"); + TEST_ASSERT_EQUAL_STRING(f64, s_saved.mpass); +} + +static void test_json_escape(void) +{ + char out[64]; + TEST_ASSERT_EQUAL_UINT(4, prov_json_esc("farm", out, sizeof(out))); + TEST_ASSERT_EQUAL_STRING("farm", out); + prov_json_esc("a\"b\\c\n", out, sizeof(out)); + TEST_ASSERT_EQUAL_STRING("a\\u0022b\\u005cc\\u000a", out); + prov_json_esc("\xE5\xAE\xB6", out, sizeof(out)); + TEST_ASSERT_EQUAL_STRING("\xE5\xAE\xB6", out); + char tiny[8]; + TEST_ASSERT_EQUAL_UINT(0, prov_json_esc("12345678", tiny, sizeof(tiny))); + TEST_ASSERT_EQUAL_STRING("", tiny); + TEST_ASSERT_EQUAL_UINT(0, prov_json_esc("ab\"", tiny, sizeof(tiny))); /* 转义写到一半不留半截 */ + TEST_ASSERT_EQUAL_STRING("", tiny); +} + +/* ---- 链路口径 -------------------------------------------------------------- */ + +/* ⭐ 配置命令:热点链路和 USB 一样收;MQTT 照旧拒(0.1.19 没有放开这一条)。 */ +static void test_ap_link_may_configure_but_mqtt_still_may_not(void) +{ + ASSERT_HAS(send_on(CMD_LINK_AP, "{\"seq\":1,\"cmd\":\"wifi\",\"ssid\":\"farm\",\"pass\":\"12345678\"}"), + "\"ok\":true"); + ASSERT_HAS(send_on(CMD_LINK_AP, "{\"seq\":2,\"cmd\":\"mqtt\",\"uri\":\"mqtt://h:1883\"}"), "\"ok\":true"); + TEST_ASSERT_EQUAL_INT(2, s_saves); + ASSERT_HAS(send_on(CMD_LINK_WIFI, "{\"seq\":3,\"cmd\":\"wifi\",\"ssid\":\"evil\",\"pass\":\"12345678\"}"), + "\"err\":\"link\""); + ASSERT_HAS(send_on(CMD_LINK_WIFI, "{\"seq\":4,\"cmd\":\"mqtt\",\"uri\":\"mqtt://1.2.3.4\",\"keep_pass\":true}"), + "\"err\":\"link\""); + TEST_ASSERT_EQUAL_INT(2, s_saves); + TEST_ASSERT_EQUAL_INT(3, CMD_LINK_AP); /* 进事件日志的编号(CMD_REBOOT_ARG),只许追加 */ +} + +/* `net` 报热点状态;热点口令能读到,WiFi / MQTT 口令照旧读不到。 */ +static void test_net_reports_the_ap(void) +{ + send_on(CMD_LINK_USB, "{\"seq\":1,\"cmd\":\"wifi\",\"ssid\":\"farm\",\"pass\":\"TopSecret99\"}"); + ASSERT_HAS(send_on(CMD_LINK_AP, "{\"cmd\":\"net\"}"), + "\"ap\":{\"on\":false,\"why\":\"off\",\"sta\":0,\"ssid\":\"\",\"pass\":\"\"}"); + net_status_t st; + net_status_get(&st); + st.ap_on = true; + st.ap_why = PROV_WHY_FAIL; + st.ap_sta = 1; + snprintf(st.ap_ssid, sizeof(st.ap_ssid), "PhoneRail-c9c8bc"); + snprintf(st.ap_pass, sizeof(st.ap_pass), "%s", PROV_PASS); + net_status_set(&st); + const char *r = send_on(CMD_LINK_WIFI, "{\"cmd\":\"net\"}"); + ASSERT_HAS(r, "\"ap\":{\"on\":true,\"why\":\"fail\",\"sta\":1,\"ssid\":\"PhoneRail-c9c8bc\",\"pass\":\"88888888\"}"); + ASSERT_LACKS(r, "TopSecret99"); + TEST_ASSERT_TRUE(sizeof(st.ap_ssid) >= PROV_SSID_MAX && sizeof(st.ap_pass) > strlen(PROV_PASS)); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_no_config_opens_at_once); + RUN_TEST(test_failure_opens_after_a_minute_and_stays); + RUN_TEST(test_a_drop_restarts_the_minute); + RUN_TEST(test_closes_when_sta_comes_up); + RUN_TEST(test_first_config_does_not_close_the_ap); + RUN_TEST(test_hold_follows_activity); + RUN_TEST(test_ssid_and_password); + RUN_TEST(test_form_becomes_the_usb_commands); + RUN_TEST(test_keep_pass_keeps_the_stored_password); + RUN_TEST(test_form_rejects_what_it_should); + RUN_TEST(test_json_escape); + RUN_TEST(test_ap_link_may_configure_but_mqtt_still_may_not); + RUN_TEST(test_net_reports_the_ap); + return UNITY_END(); +} diff --git a/firmware/test/test_t_thermal.c b/firmware/test/test_t_thermal.c index 9423d5e..4371b9a 100644 --- a/firmware/test/test_t_thermal.c +++ b/firmware/test/test_t_thermal.c @@ -83,7 +83,7 @@ void setUp(void) { sim_hal_reset(); fans_up(); - set_temp(25000); /* 25 °C:常温开机 */ + set_temp(T_THERMAL_TEMP_LO_MC); /* 曲线起点:风扇开着、按下限转(再凉就断电了,见最后一组用例) */ t_thermal_set_floor(0); /* 下限是模块级静态量,不随 init 清零(t_thermal.h) */ } void tearDown(void) {} @@ -186,7 +186,7 @@ static void test_unreadable_temperature_means_full_speed(void) { thermal_snap_t s; boot(&s); - set_temp(25000); + set_temp(T_THERMAL_TEMP_LO_MC); run(&s, 1); TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); @@ -205,7 +205,7 @@ static void test_manual_floor_only_raises_the_duty(void) { thermal_snap_t s; boot(&s); - set_temp(25000); + set_temp(T_THERMAL_TEMP_LO_MC); t_thermal_set_floor(800); run(&s, 1); TEST_ASSERT_EQUAL_UINT16(800, s.duty); @@ -215,7 +215,7 @@ static void test_manual_floor_only_raises_the_duty(void) TEST_ASSERT_EQUAL_UINT16(1000, s.duty); t_thermal_set_floor(T_THERMAL_DUTY_MIN / 2); /* 比曲线下限还低:没有效果 */ - set_temp(25000); + set_temp(T_THERMAL_TEMP_LO_MC); run(&s, 1); TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); @@ -253,7 +253,7 @@ static void test_low_duty_windows_are_not_mistaken_for_a_stall(void) { thermal_snap_t s; boot(&s); - set_temp(25000); /* 凉 ⇒ 占空比压到下限 */ + set_temp(T_THERMAL_TEMP_LO_MC); /* 凉 ⇒ 占空比压到下限 */ run(&s, 1); TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); @@ -476,6 +476,147 @@ static void test_step_before_init_is_refused(void) TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_thermal_step(NULL)); } +/* ---- ⑤ 0.1.21:凉了断电 -------------------------------------------------- */ + +/* ⭐ 40 °C 以下风扇断电:撤的是 `FAN_EN`(四线风扇 0 % PWM 仍在转),并且带迟滞 —— + 38 °C 以下才关、回到 40 °C 才开,中间维持原状。 + 写错了会怎样:没有迟滞 ⇒ 板温在 40 °C 上下晃时风扇几秒启停一次;只压占空比不断电 ⇒ 风扇照转。 */ +static void test_cool_board_cuts_fan_power_with_hysteresis(void) +{ + thermal_snap_t s; + boot(&s); + TEST_ASSERT_TRUE(s.fan_en); + TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); + TEST_ASSERT_EQUAL_UINT32(1, s.fan_starts); + + set_temp(T_THERMAL_FAN_OFF_MC); /* 正好在关断点上:还开着(低于才关) */ + run(&s, 3); + TEST_ASSERT_TRUE(s.fan_en); + TEST_ASSERT_TRUE(sim_fan_rpm(0) > 0); + + set_temp(T_THERMAL_FAN_OFF_MC - 1); + run(&s, 1); + TEST_ASSERT_FALSE(s.fan_en); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, sim_gpio_level(BOARD_PIN_FAN_EN), "凉了没撤 FAN_EN"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, sim_fan_rpm(0), "风扇还在转"); + TEST_ASSERT_EQUAL_UINT32(0, sim_fan_rpm(1)); + TEST_ASSERT_EQUAL_UINT16_MESSAGE(0, s.duty, "断着电时遥测里的占空比该是 0"); + + set_temp(T_THERMAL_FAN_ON_MC - 1); /* 回到两个门限之间:不开 */ + run(&s, 5); + TEST_ASSERT_FALSE(s.fan_en); + TEST_ASSERT_EQUAL_UINT32(1, s.fan_starts); + + set_temp(T_THERMAL_FAN_ON_MC); + run(&s, 1); + TEST_ASSERT_TRUE(s.fan_en); + TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(BOARD_PIN_FAN_EN)); + TEST_ASSERT_TRUE(sim_fan_rpm(0) > 0); + TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); + TEST_ASSERT_EQUAL_UINT32(2, s.fan_starts); + + /* 在门限之间来回晃 20 圈:一次都不许再启停。 */ + for (int i = 0; i < 20; i++) { + set_temp((i & 1) ? T_THERMAL_FAN_ON_MC - 100 : T_THERMAL_FAN_OFF_MC + 100); + run(&s, 1); + } + TEST_ASSERT_TRUE(s.fan_en); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, s.fan_starts, "门限之间晃动引起了启停"); +} + +/* ⭐ 断电期间不报停转;重新上电先过启动宽限;转起来之后判据照常工作。 + 一只**本来就没插**的风扇(一直判停转)不因为每次启停再各记一条事件。 */ +static void test_power_cycling_the_fan_does_not_raise_stall_alarms(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + evlog_flush(0); + const uint32_t seq = next_seq(); + + thermal_snap_t s; + sim_fan_set_stalled(1, true); /* 第二只没插 */ + boot(&s); + set_temp(70000); + run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u); + TEST_ASSERT_FALSE(s.fan[0].stalled); + TEST_ASSERT_TRUE(s.fan[1].stalled); + TEST_ASSERT_EQUAL_UINT32(1, s.fan[1].stalls); + + for (int k = 0; k < 3; k++) { + set_temp(30000); /* 凉:断电 */ + run(&s, 40); /* 比任何判据窗口都长 */ + TEST_ASSERT_FALSE(s.fan_en); + TEST_ASSERT_FALSE_MESSAGE(s.fan[0].stalled, "断着电被判成了停转"); + TEST_ASSERT_TRUE(s.fan[0].rpm_valid); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].rpm, "断着电转速没报 0"); + set_temp(70000); /* 热:上电满速 */ + run(&s, T_THERMAL_SPINUP_MS / 1000u + T_THERMAL_TACH_ON_MS / 1000u + 3u); + TEST_ASSERT_TRUE(s.fan_en); + TEST_ASSERT_FALSE(s.fan[0].stalled); + TEST_ASSERT_TRUE_MESSAGE(s.fan[0].rpm > 5000, "重新上电后转速没恢复"); + } + TEST_ASSERT_EQUAL_UINT32(0, s.fan[0].stalls); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, s.fan[1].stalls, "没插的那只每次启停都又记了一次"); + evlog_flush(0); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(seq + 1, next_seq(), "启停期间多记了事件"); + + /* 上着电时真停转照样抓得到(判据没有被「断电不判」顺手关掉)。 */ + sim_fan_set_stalled(0, true); + run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u); + TEST_ASSERT_TRUE(s.fan[0].stalled); +} + +/* ⭐ 凉着也必须有风的两种情况:温度读不到(§10.10)、上位机给了下限。 + 带时限的下限到期自动回落 —— 上位机挂了,风扇不会永远停在它最后要的转速上。 */ +static void test_cool_fan_still_runs_when_asked_or_blind(void) +{ + thermal_snap_t s; + boot(&s); + set_temp(30000); + run(&s, 2); + TEST_ASSERT_FALSE(s.fan_en); + + s_temp_ok[0] = false; + s_temp_ok[1] = false; + run(&s, 1); + TEST_ASSERT_TRUE_MESSAGE(s.fan_en, "温度读不到时风扇没开"); + TEST_ASSERT_EQUAL_UINT16(1000, s.duty); + TEST_ASSERT_TRUE(sim_fan_rpm(0) > 0); + set_temp(30000); + run(&s, 1); + TEST_ASSERT_FALSE(s.fan_en); + + t_thermal_set_floor_for(600, 30000); /* 30 s */ + run(&s, 1); + TEST_ASSERT_TRUE_MESSAGE(s.fan_en, "上位机要风时风扇没开"); + TEST_ASSERT_EQUAL_UINT16(600, s.duty); + TEST_ASSERT_EQUAL_UINT32(29000, t_thermal_floor_left_ms()); + run(&s, 28); + TEST_ASSERT_TRUE(s.fan_en); + TEST_ASSERT_EQUAL_UINT16(600, t_thermal_floor()); + run(&s, 1); /* 第 30 秒:到期 */ + TEST_ASSERT_EQUAL_UINT16_MESSAGE(0, t_thermal_floor(), "下限到期没回落"); + TEST_ASSERT_EQUAL_UINT32(0, t_thermal_floor_left_ms()); + TEST_ASSERT_FALSE(s.fan_en); + + /* 续期 = 再发一次:时限从这一刻重新算。 */ + t_thermal_set_floor_for(500, 10000); + run(&s, 8); + t_thermal_set_floor_for(500, 10000); + run(&s, 8); + TEST_ASSERT_TRUE(s.fan_en); + run(&s, 3); + TEST_ASSERT_FALSE(s.fan_en); + + /* 不带时限的下限一直保持(老行为),时钟回绕也不会让它「到期」。 */ + t_thermal_set_floor(400); + run(&s, 1); + sim_time_advance_ms(0x80000000u); + run(&s, 1); + TEST_ASSERT_EQUAL_UINT16(400, t_thermal_floor()); + TEST_ASSERT_TRUE(s.fan_en); + t_thermal_set_floor(0); +} + int main(void) { UNITY_BEGIN(); @@ -492,5 +633,8 @@ int main(void) RUN_TEST(test_spinup_grace_suppresses_the_first_windows); RUN_TEST(test_derate_is_edge_triggered_with_hysteresis); RUN_TEST(test_thermal_never_touches_flash); + RUN_TEST(test_cool_board_cuts_fan_power_with_hysteresis); + RUN_TEST(test_power_cycling_the_fan_does_not_raise_stall_alarms); + RUN_TEST(test_cool_fan_still_runs_when_asked_or_blind); return UNITY_END(); } diff --git a/固件开发计划.md b/固件开发计划.md index 901786b..29ee8b8 100644 --- a/固件开发计划.md +++ b/固件开发计划.md @@ -516,7 +516,7 @@ R-3 依赖 R-1+R-2;R-4 依赖 R-3;R-5 / R-6 随时可插。 | 包 | 内容 | 触发条件 | |---|---|---| -| **C1** | WiFi 配网流程(USB 下发凭据写 NVS,IF-19) | A14 之后 | +| **C1** | WiFi 配网流程(USB 下发凭据写 NVS,IF-19;0.1.19 加配网热点 + 网页,`prov.h`) | A14 之后 | | **C2** | ✅ OTA:双分区 + 失败回滚(2026-09-27,见需求 §10.8「C2 定稿的 OTA」) | B9 之后 | | **C3** | 出厂标定流程(两点线性标定存 NVS) | B2 之后 | | **C4** | 文档回填:实测值填回 `需求文档.md` §12.2,坑写进 §10 | 随时 | diff --git a/需求文档.md b/需求文档.md index 10148be..85da772 100644 --- a/需求文档.md +++ b/需求文档.md @@ -110,7 +110,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU | **FR-7** | 对外只用**一根 USB 线**,五路下行由板载 Hub 分 | 必须 | §15.1 | | **FR-8** | 每路可实时读取**输出电压与电流**;整板可读取**输入电压与电流** | 必须 | §8.2 | | **FR-9** | 上报每路 eFuse 故障、电源好(PG)等状态 | 必须 | §8.2 | -| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」 | 必须 | §22.6 | +| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」。**固件 0.1.21(2026-10-06 用户定)**:板温(两路 NTC 取高)低于 40 ℃ 风扇断电(≥40 ℃ 开、<38 ℃ 关),40→65 ℃ 按 30 %→100 % 线性调速;温度读不到或上位机给了下限时一律开着。`fan` 命令的下限只抬不压,可带 `sec` 到点自动交回(rack 网页与 AppTask「机架」页都能发) | 必须 | §22.6 | | **FR-11** | 记录过流 / 故障事件(哪一路、何时、多大电流) | 高 | §8.3 | | **FR-12** | 模拟电量曲线,避免系统因「电量永远 100%」触发异常逻辑 | 中(可选) | §8.3 | | **FR-13** | 板级温度监控,超阈值先降流再关路 | 高 | §8.3 | @@ -234,7 +234,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU | **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-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时用**配网热点**(固件 0.1.19,2026-10-06:没配网 / 连续 60 s 连不上 WiFi 时板子自己开 SoftAP `PhoneRail-`,手机连上在 192.168.4.1 网页里填)或 IF-13 的 USB 链路配网 | > ⚠️ **IF-12 与 IF-13 是两个长得一样的 USB-C,插错的现象是「adb 整排不见」**, > 很难联想到是插错口。**丝印必须写清楚**,建议用不同颜色座子或在外壳上做标记。 @@ -765,6 +765,7 @@ ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U > ⚠️ **别把 WiFi 凭据和 MQTT 地址编译进固件。** 放 NVS,用 USB 链路配置 —— > 这正好是兜底链路的第二个用途:新板子上架时先插 USB 配网。 +> (0.1.19 起多一条不用插线的路:配网热点,见下面「配网热点」那一条。) 命令语义与链路无关,两条链路完全一致: @@ -842,8 +843,20 @@ base = `phonerail/`,id = 板子 STA MAC 的 12 位小写十六进制(板 | `/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` 并记事件)。 +- **配网(C1)**:`wifi`(ssid / pass)与 `mqtt`(uri / user / pass / tele_ms)两条命令,存 NVS,**只收 USB 与配网热点**(经 MQTT 来回 `link` 并记事件)。 `net` 查网络状态,**永不回显密码**(只报 `pass_set` / `mpass_set`);含 `wifi_reason`(最近一次断开原因码)。 + `mqtt` 可带 `"keep_pass":true` = 口令沿用已存的(与 `pass` 同时给回 `arg`)。 +- **配网热点(0.1.19,2026-10-06 用户定「SoftAP + 网页,连 WiFi 失败后默认回到配网模式」)**,`firmware/main/prov.h`: + - 开:一组 WiFi 都没配 ⇒ 立刻;配了但**连续 60 s 没拿到地址** ⇒ 开。开着期间 STA 照常在后台重连,**热点不设超时**。 + - 关:STA 连上 ⇒ 热点上没人立刻关,有人再留 60 s(让网页看到结果)。 + - 热点 `PhoneRail-`,WPA2,口令**所有板子固定 `88888888`**(0.1.20,2026-10-06 用户定;0.1.19 曾是每板随机), + `net` 回应的 `"ap":{"on","why","sta","ssid","pass"}` 里也报(两条链路都报)。 + - 网页 `http://192.168.4.1/`(DNS 劫持 ⇒ 手机连上自动弹出):看状态、扫描、加 / 删 WiFi、改 MQTT。 + 提交的内容拼成 `wifi` / `mqtt` 命令走**同一个命令处理器**(链路号 3 `CMD_LINK_AP`),这条链路拼不出别的命令; + 网页同样不回显任何已存口令。只应答热点那一侧来的连接。 + - 有人在网页上操作时 STA 重连间隔不短于 20 s(每次重连要离开热点信道扫一圈);只放慢、不停。 + - **热点不碰任何输出**(同「失联不动作」)。代价:路由器短暂重启也会让热点冒出来约一分钟; + 口令是公开的固定值 ⇒ 在无线范围内的任何人,都能在板子没网时把它指向别的 broker(等同拿到这块板)。 - 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)。