固件 0.1.19~0.1.21-c5:配网热点(SoftAP + 网页)、风扇 40 ℃ 以下断电

- 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 <[email protected]>
这个提交包含在:
zhou yang
2026-10-06 14:35:44 +08:00
共同撰写人 Claude Opus 5.5
父节点 3bf4548c62
当前提交 ece65f7c19
共修改 24 个文件,包含 1925 行新增和 324 行删除
+329 -283
查看文件
@@ -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/<sha 前 16 位>.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-<id 末 6 位>`,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/<sha 前 16 位>.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`。
+1
查看文件
@@ -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
+6 -3
查看文件
@@ -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 永远不锁(安全,但功能白做)。
+7 -4
查看文件
@@ -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 = 链路。 */
+14 -1
查看文件
@@ -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;
}
+28 -6
查看文件
@@ -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;
}
+3 -2
查看文件
@@ -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);
+64 -5
查看文件
@@ -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 <string.h>
#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') {
+2 -2
查看文件
@@ -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 */
+9 -2
查看文件
@@ -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);
+1 -1
查看文件
@@ -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
+206
查看文件
@@ -0,0 +1,206 @@
/* 配网热点的平台无关部分。判据表与表单格式在 prov.h。 */
#include "prov.h"
#include <stdio.h>
#include <string.h>
#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;
}
+104
查看文件
@@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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-<id 末 6 位>`。 */
void prov_ssid(const char *id, char out[PROV_SSID_MAX]);
/* ---- 网页表单 → 一行命令 ---------------------------------------------------
网页 POST 的正文是按行分的纯文本(免得在固件里做 URL 解码):
wifi\n<ssid>\n<口令> 加一组 / 改同名那组的口令
del\n<ssid> 删一组
mqtt\n<uri>\n<账号>\n<口令>\n<tele_ms> 口令留空 = 不改(`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 */
+92
查看文件
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="zh">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PhoneRail 配网</title>
<style>
body{font:16px/1.5 system-ui,sans-serif;margin:0;padding:16px;max-width:520px;background:#f4f5f7;color:#1c1e21}
h1{font-size:20px;margin:0 0 4px}h2{font-size:16px;margin:0 0 8px}
section{background:#fff;border-radius:8px;padding:12px 14px;margin:12px 0}
input[type=text],input[type=password]{width:100%;box-sizing:border-box;font-size:16px;padding:9px;margin:4px 0;border:1px solid #c5c8ce;border-radius:6px}
button{font-size:16px;padding:9px 14px;margin:6px 6px 0 0;border:0;border-radius:6px;background:#1668dc;color:#fff}
button.s{background:#e6e8eb;color:#1c1e21;padding:4px 10px;font-size:14px;margin:0}
.row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #eceef0}
.row:last-child{border:0}.dim{color:#6b7078;font-size:14px}.ok{color:#18794e}.bad{color:#c0362c}
.msg{min-height:1.5em;margin-top:6px}
</style>
<h1>PhoneRail 配网</h1>
<div class="dim" id="hd">读取中…</div>
<section><h2>状态</h2><div id="st">读取中…</div></section>
<section><h2>WiFi</h2>
<div id="saved"></div>
<button class="s" id="scan" type="button" style="margin-top:8px">扫描附近的 WiFi</button>
<div id="aps"></div>
<form id="fw">
<input type="text" id="ssid" placeholder="WiFi 名称" autocapitalize="off" autocomplete="off" required>
<input type="password" id="pass" placeholder="口令(开放网络留空)" autocomplete="off">
<label class="dim"><input type="checkbox" id="show"> 显示口令</label><br>
<button>保存并连接</button>
</form>
<div class="msg" id="mw"></div>
</section>
<section><h2>服务器(MQTT)</h2>
<form id="fm">
<input type="text" id="uri" placeholder="mqtts://主机:8883" autocapitalize="off" autocomplete="off" required>
<input type="text" id="user" placeholder="账号" autocapitalize="off" autocomplete="off">
<input type="password" id="mp" placeholder="口令(留空 = 不改)" autocomplete="off">
<button>保存</button>
</form>
<div class="msg" id="mm"></div>
</section>
<p class="dim">底板连上 WiFi 后约 1 分钟会自动关掉这个热点。口令只能写入,页面上不会显示已存的口令。</p>
<script>
var $=function(i){return document.getElementById(i)},tele='',filled=false;
var ERR={range:'长度不对:WiFi 口令要 8~63 位(开放网络留空),服务器地址以 mqtt:// 或 mqtts:// 开头',
state:'已经存满 4 组,先删掉一组',bad_char:'不支持引号、反斜杠和换行',too_long:'太长了',
bad_form:'没填完整',io:'写入闪存失败',arg:'参数不对',busy:'底板正忙,过几秒再试',timeout:'底板没有回应,再试一次'};
var WHY={201:'找不到这个 WiFi(名字不对 / 太远 / 只有 5G)',202:'口令不对',15:'口令不对(握手超时)',204:'口令不对(握手失败)',2:'认证过期',200:'信号太弱,掉线'};
var ST={off:'未配置',connecting:'连接中',up:'已连上'};
function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!=null)e.textContent=x;return e}
function row(k,v,c){var r=el('div','row');r.appendChild(el('span','dim',k));r.appendChild(el('span',c||'',v));return r}
function post(body){return fetch('/c',{method:'POST',body:body}).then(function(r){return r.json()})}
function say(id,j,okText){var m=$(id);m.className='msg '+(j.ok?'ok':'bad');m.textContent=j.ok?okText:('没保存:'+(ERR[j.err]||j.err))}
function fail(id){return function(){var m=$(id);m.className='msg bad';m.textContent='没连上底板,确认手机还连着 PhoneRail 热点'}}
function draw(d){
var n=d.net;$('hd').textContent='板子 '+n.id+' · 固件 '+d.fw;
var s=$('st');s.textContent='';
var w=ST[n.wifi]||n.wifi;
if(n.wifi=='up')w+='「'+n.ssid+'」 '+n.ip+'('+n.rssi+' dBm)';
else if(n.wifi=='connecting')w+='「'+n.ssid+'」';
s.appendChild(row('WiFi',w,n.wifi=='up'?'ok':''));
if(n.wifi!='up'&&n.wifi_reason)s.appendChild(row('上次失败',WHY[n.wifi_reason]||('原因码 '+n.wifi_reason),'bad'));
s.appendChild(row('服务器',n.uri?(ST[n.mqtt]||n.mqtt):'未配置',n.mqtt=='up'?'ok':''));
var v=$('saved');v.textContent='';
if(!n.ssids.length)v.appendChild(el('div','dim','还没有保存任何 WiFi'));
n.ssids.forEach(function(x){var r=el('div','row');r.appendChild(el('span','',x));
var b=el('button','s','删除');b.type='button';
b.onclick=function(){if(confirm('删除「'+x+'」?'))post('del\n'+x).then(function(j){say('mw',j,'已删除');poll()}).catch(fail('mw'))};
r.appendChild(b);v.appendChild(r)});
tele=n.tele_ms||'';
if(!filled){filled=true;$('uri').value=n.uri||'';$('user').value=n.user||'';
if(n.mpass_set)$('mp').placeholder='口令(已设置,留空 = 不改)'}
}
function poll(){fetch('/s').then(function(r){return r.json()}).then(draw).catch(function(){
$('st').textContent='读不到状态:热点可能已经关了(底板连上 WiFi 后会关)'})}
$('show').onchange=function(){$('pass').type=this.checked?'text':'password'};
$('scan').onclick=function(){var a=$('aps');a.textContent='扫描中,约 3 秒…';
fetch('/scan').then(function(r){return r.json()}).then(function(j){a.textContent='';
if(j.err){a.textContent='底板正在尝试连接,过几秒再点一次';return}
if(!j.aps.length)a.textContent='没扫到(底板只支持 2.4G)';
j.aps.forEach(function(p){var r=el('div','row');r.appendChild(el('span','',p.ssid));
r.appendChild(el('span','dim',p.rssi+' dBm'+(p.open?' · 开放':'')));
r.onclick=function(){$('ssid').value=p.ssid;$('pass').focus()};a.appendChild(r)})
}).catch(function(){a.textContent='扫描失败,再点一次'})};
$('fw').onsubmit=function(e){e.preventDefault();
post('wifi\n'+$('ssid').value+'\n'+$('pass').value).then(function(j){
say('mw',j,'已保存,正在连接 —— 看上面的状态');if(j.ok)$('pass').value='';poll()}).catch(fail('mw'))};
$('fm').onsubmit=function(e){e.preventDefault();
post('mqtt\n'+$('uri').value.trim()+'\n'+$('user').value.trim()+'\n'+$('mp').value+'\n'+tele).then(function(j){
say('mm',j,'已保存');if(j.ok)$('mp').value='';poll()}).catch(fail('mm'))};
poll();setInterval(poll,3000);
</script>
</html>
+416
查看文件
@@ -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 <string.h>
#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; }
+22
查看文件
@@ -0,0 +1,22 @@
/* 配网热点的 ESP 胶水(0.1.19)。只在 IDF 目标里有;判据与安全说明在 prov.h,实现说明在 prov_esp.c 文件头。 */
#ifndef PROV_ESP_H
#define PROV_ESP_H
#include <stdbool.h>
#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 */
+60 -2
查看文件
@@ -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;
}
+28 -4
查看文件
@@ -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
}
+3
查看文件
@@ -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"
+14
查看文件
@@ -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:改模式 / 保持窗口,两条链路都收;越界记事件、缺参数不记;
+350
查看文件
@@ -0,0 +1,350 @@
/* 0.1.19:配网热点(prov.h)的平台无关部分,加上「配置命令从哪条链路收」的新口径。
测的是上板不好查、写错了又很疼的几件事:
· 热点**什么时候开、什么时候关** —— 开早了白白露一个入口,关早了人正填着表单就断;
· STA 连不上时热点**不许自己消失**(消失 = 又只剩拆机插 USB);
· 网页表单拼出来的命令行**拼不出别的命令**,带引号的字段进不来;
· 配置命令:热点链路收、MQTT 照旧拒;`keep_pass` 不丢旧口令;`net` 报热点状态。 */
#include <stdio.h>
#include <string.h>
#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();
}
+149 -5
查看文件
@@ -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();
}