6 次代码提交
作者 SHA1 备注 提交日期
zhou yang和Claude Opus 5.5 f2e0a7363e rack:fan 命令放行保持时间 sec,风扇断电显示「停」
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 14:35:45 +08:00
zhou yang和Claude Opus 5.5 ece65f7c19 固件 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]>
2026-10-06 14:35:44 +08:00
zhou yang和Claude Opus 5.5 3bf4548c62 手机主板温度:代理 0.8.0 上报,rack 拓扑图显示
- 机架代理 0.8.0(Thermal.java):读 /sys/class/thermal 的 quiet_therm(主板热敏)和最热的 CPU 核,
  每次长轮询拼在 URL 后面(&tb=&tc=,m°C),不为它多醒一次。电池那一路不报(电池拆了)。
- rack 后端 prrack/fleet.py:每 5 秒读 AppTask 后端的 /api/fleet/phones,按机架位 B-n 对到第 n 路,
  放进快照 phones[].live(在线 / 在跑活 / 代理版本 / 温度);变了才推 phones_live。只显示,不参与自动恢复。
  读不到就清空,不留旧值。服务单元加 --apptask-env。
- 前端拓扑图每台手机多一行「主板 / CPU」温度(50 °C 橙、60 °C 红,只改颜色)。
- agent_upgrade_safe.sh:升级后 4 分钟没开机就 power_cycle 救一次并补收尾。

五台已升到 0.8.0(两路并行推);第 2、1、5 路 staged 重启后卡死 / 僵住,断电重启后版本都已是新的。
后端测试 58 项全过;已部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 07:06:15 +08:00
zhou yang和Claude Opus 5.5 580468e48e rack:PD 供电信息、每日耗电量与平均功率
PD 供电(板卡页新增一格):
- 优先用遥测里的实时值 status.pd(固件 ≥0.1.17);老固件退回最近一条 PD_LIMIT 事件(快照的 pd)。
  显示 20 V / 档位电流 / 整板功率上限 / 输入功率占上限的比例。
- PD_LIMIT 事件写成「PD 20 V / 3.00 A,上限 60 W」,不再把那两列当电压电流显示;arg 2 = 运行中换档。

耗电量(板卡页新增卡片):
- 新表 energy_day(永久保留):从分钟聚合滚出每日能量与有数据秒数,rail 0 = 底板 12 V 输入,1~5 = 各路主板。
  每分钟重算今天 / 昨天,更早的只补缺不覆盖(分钟数据 90 天后会被清)。按服务器本地零点切日。
- GET /api/boards/{id}/energy;前端 EnergyCard:今日 / 昨日 / 近 7 天 / 近 30 天 / 累计(各带平均功率)、
  每日柱状图、今日各路主板。量的是 12 V 输入,不含 PD 20→12 V 降压损耗。

后端测试 57 项全过;已部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 06:31:04 +08:00
zhou yang和Claude Opus 5.5 489cd6a6f3 固件 0.1.17~0.1.18-c5:CH224A 的 PD 档位实时进遥测(pdmon)
原来只在启动时读一次(bringup.c),而多口充电器重新分配功率时 20 V 档的电流上限当场会变。

- 新模块 pdmon:每秒读 0x09(协议状态)+ 0x50(档位电流),只读;先试上次应答的支路(主干 / mux 通道 5)。
  档位变了(连续两拍一致、非 0)记 LOG_EV_PD_LIMIT,arg 2 = 运行中换档。只报不动,降载仍没人做。
- status 新增 "pd":{"st":..,"ma":..},这一拍没读到是 null(满配 1164 / 1536 字节,余量断言改成至少留 256)。
- 0.1.17 放在热管理任务里读,板 B 实测 safety.skipped 每秒涨 1(持锁撞上巡检的下一拍);
  0.1.18 挪进巡检任务(t_safety_step 之后、每 50 圈一次),skipped 不再涨。
- 测试:新增 test_pdmon(6 例),test_cmd 加 pd 断言;28 ctest 全绿。

板 B 已 OTA 到 0.1.18-c5(两次都是热重启,五路没断电),读到 st 0x88 / 3000 mA。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 06:31:04 +08:00
zhou yang和Claude Opus 5.5 ca7435242f rack:自动恢复的平线判据按实测重定(p95−p05 + 中位数下限)
原判据「窗口内 max − min < 5 mA」抓不到真卡死:10-04 第 1 路平在 520 mA 五个半小时,
偶发尖峰让 590 个窗口的 max − min 都在 17 mA 以上,一个都判不出来。

- 平线改成:p95 − p05 < flat_ua(30 mA)且中位数 ≥ flat_min_ua(100 mA,新增参数)。
  下限是给空闲低功耗留的:睡着的手机平在 ~10 mA 是正常的。
- 过低不变(窗内最大值 < 30 mA):睡着的手机每 25 s 被长轮询叫醒,窗内最大值都在 213 mA 以上。
- 真实遥测回放:两次真卡死(10-04 第 1 路、10-05 09:40 第 5 路平在 382 mA)都判出;
  亮屏常醒 ~26 h × 4 路、空闲睡着 ~6 h × 5 路、试验 / OTA 期间零误判。
- 测试 +4(带尖峰的平线 / 睡着是健康的 / 睡下去再没醒 = 过低 / 低位平线不下结论),恢复页加「中位数不低于」。

依据和局限写在 recovery.py 模块头。已部署;五路的自动恢复开关仍然全关,还没真正触发过一次。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 05:14:22 +08:00
共修改 47 个文件,包含 2919 行新增和 346 行删除
+58 -1
查看文件
@@ -1,5 +1,62 @@
# 交接 # 交接
更新:2026-09-30 固件 **0.1.16-c5**(开机键统一 5 s);板 B 经 OTA 推上;rack 已部署 更新: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 / 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); - 0.1.15 把 `ACT_KEY_MS_ON` 5000 → 3000:板 B 第 2 路 `power_cycle` 三轮全是「按住 330~390 mA、松手掉回 0」⇒ `no-boot`(事件 19);
+2
查看文件
@@ -30,6 +30,7 @@ set(PHONERAIL_CORE_SRCS
${FW_ROOT}/main/tasks.c ${FW_ROOT}/main/tasks.c
${FW_ROOT}/main/chgmode.c ${FW_ROOT}/main/chgmode.c
${FW_ROOT}/main/hubctl.c ${FW_ROOT}/main/hubctl.c
${FW_ROOT}/main/pdmon.c
${FW_ROOT}/main/t_safety.c ${FW_ROOT}/main/t_safety.c
${FW_ROOT}/main/t_actuator.c ${FW_ROOT}/main/t_actuator.c
${FW_ROOT}/main/t_thermal.c ${FW_ROOT}/main/t_thermal.c
@@ -42,6 +43,7 @@ set(PHONERAIL_CORE_SRCS
${FW_ROOT}/main/cmd_net.c ${FW_ROOT}/main/cmd_net.c
${FW_ROOT}/main/netcfg.c ${FW_ROOT}/main/netcfg.c
${FW_ROOT}/main/mlink.c ${FW_ROOT}/main/mlink.c
${FW_ROOT}/main/prov.c
${FW_ROOT}/main/bringup.c ${FW_ROOT}/main/bringup.c
${FW_ROOT}/main/ntc.c ${FW_ROOT}/main/ntc.c
${FW_ROOT}/main/ota.c ${FW_ROOT}/main/ota.c
+7 -3
查看文件
@@ -1,5 +1,5 @@
# ESP-IDF 侧的 main 组件。 # 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)。 # (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。
idf_component_register( idf_component_register(
SRCS "app_main.c" SRCS "app_main.c"
@@ -14,6 +14,7 @@ idf_component_register(
"tasks.c" "tasks.c"
"chgmode.c" "chgmode.c"
"hubctl.c" "hubctl.c"
"pdmon.c"
"t_safety.c" "t_safety.c"
"t_actuator.c" "t_actuator.c"
"t_thermal.c" "t_thermal.c"
@@ -26,6 +27,8 @@ idf_component_register(
"cmd_net.c" "cmd_net.c"
"netcfg.c" "netcfg.c"
"mlink.c" "mlink.c"
"prov.c"
"prov_esp.c"
"bringup.c" "bringup.c"
"ntc.c" "ntc.c"
"thermal_esp.c" "thermal_esp.c"
@@ -33,11 +36,12 @@ idf_component_register(
"ota_esp.c" "ota_esp.c"
"warm_esp.c" "warm_esp.c"
INCLUDE_DIRS "." 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 REQUIRES phonerail_hal drv esp_driver_usb_serial_jtag
esp_wifi esp_netif esp_event esp_timer nvs_flash mqtt esp_wifi esp_netif esp_event esp_timer nvs_flash mqtt
esp_adc esp_driver_pcnt 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 字节)没有代码引用它, # warm_esp.c 的能力标记(.rodata_custom_desc,镜像第 288 字节)没有代码引用它,
# 不加 -u 会被 --gc-sections 丢掉 —— 那样每个新固件都会被当成「不认接管」,EN 永远不锁(安全,但功能白做)。 # 不加 -u 会被 --gc-sections 丢掉 —— 那样每个新固件都会被当成「不认接管」,EN 永远不锁(安全,但功能白做)。
+8
查看文件
@@ -33,6 +33,7 @@
#include "nvs.h" #include "nvs.h"
#include "nvs_flash.h" #include "nvs_flash.h"
#include "ota.h" #include "ota.h"
#include "pdmon.h"
#include "ota_esp.h" #include "ota_esp.h"
#include "warm_esp.h" #include "warm_esp.h"
#include "ulink_esp.h" #include "ulink_esp.h"
@@ -151,6 +152,12 @@ static void task_safety(void *arg)
for (;;) { for (;;) {
(void)t_safety_step(snapp); /* 器件读失败不在这里处理:§10.2 失联 ≠ 断电 */ (void)t_safety_step(snapp); /* 器件读失败不在这里处理:§10.2 失联 ≠ 断电 */
/* 0.1.17:CH224A 的档位进遥测,1 s 一拍(pdmon.h「线程」那一段:为什么非得在这里读)。
错开 250 的倍数,不和下面那行日志挤在同一圈。 */
if ((snapp->cycles % 50u) == 25u) {
pdmon_poll();
}
/* 每 5 s 报一次总线占用。§10.3 手算是「约 1.5 ms / 约 8 %」, /* 每 5 s 报一次总线占用。§10.3 手算是「约 1.5 ms / 约 8 %」,
首板上电时要拿逻辑分析仪复核这个数(§10.3 末尾那句)。 */ 首板上电时要拿逻辑分析仪复核这个数(§10.3 末尾那句)。 */
if ((snapp->cycles % 250u) == 0u) { if ((snapp->cycles % 250u) == 0u) {
@@ -462,6 +469,7 @@ void app_main(void)
abort(); abort();
} }
log_report(&boot); log_report(&boot);
pdmon_init(boot.pd_ma);
if (adopt != 0) { if (adopt != 0) {
evlog_put(0, LOG_EV_OTA, OTA_EV_WARM, (int32_t)boot.adopted, (int32_t)boot.adopt_dropped); evlog_put(0, LOG_EV_OTA, OTA_EV_WARM, (int32_t)boot.adopted, (int32_t)boot.adopt_dropped);
ESP_LOGI(TAG, "接管:一直开着 0x%02x,关掉 0x%02x(刀片不在 / mux 失联)", ESP_LOGI(TAG, "接管:一直开着 0x%02x,关掉 0x%02x(刀片不在 / mux 失联)",
+7 -4
查看文件
@@ -76,17 +76,20 @@ typedef enum {
CMD_ERR_STATE = 8, /* 当前状态下不许(例:eFuse 关着时开 VBUS) */ CMD_ERR_STATE = 8, /* 当前状态下不许(例:eFuse 关着时开 VBUS) */
CMD_ERR_IO = 9, /* 下发到器件失败(I²C) */ CMD_ERR_IO = 9, /* 下发到器件失败(I²C) */
CMD_ERR_OVERFLOW = 10, /* 回应装不进缓冲 —— 本模块的 bug,不该出现 */ CMD_ERR_OVERFLOW = 10, /* 回应装不进缓冲 —— 本模块的 bug,不该出现 */
CMD_ERR_LINK = 11, /* 这条命令不许从这条链路来(经 WiFi 改 WiFi 配置) */ CMD_ERR_LINK = 11, /* 这条命令不许从这条链路来(经 MQTT 改 WiFi 配置) */
CMD_ERR_COUNT, CMD_ERR_COUNT,
} cmd_err_t; } cmd_err_t;
/* 哪条链路来的。进 `LOG_EV_REBOOT` 的 arg 高 4 位。§10.8:两条链路的命令语义完全一致, /* 哪条链路来的。进 `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 { typedef enum {
CMD_LINK_TEST = 0, CMD_LINK_TEST = 0,
CMD_LINK_USB = 1, CMD_LINK_USB = 1,
CMD_LINK_WIFI = 2, CMD_LINK_WIFI = 2, /* MQTT */
CMD_LINK_AP = 3,
} cmd_link_t; } cmd_link_t;
/* `LOG_EV_REBOOT` 的 arg:bit0 = 1 硬(断电)/ 0 软(长按电源键),bit4~7 = 链路。 */ /* `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) { if (duty < 0 || duty > 1000) {
return reject(c, CMD_ERR_RANGE, duty); 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.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()); 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; 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`(下载固件,两条链路都收)。 外加 C2 的 `ota`(下载固件,两条链路都收)。
`net` 的回应里**永远没有密码**,只有 `pass_set` / `mpass_set`(netcfg.h「密码永不外泄」)。 */ `net` 的回应里**永远没有密码**,只有 `pass_set` / `mpass_set`(netcfg.h「密码永不外泄」)。 */
@@ -7,9 +7,11 @@
#include "cmd_priv.h" #include "cmd_priv.h"
#include "netcfg.h" #include "netcfg.h"
#include "ota.h" #include "ota.h"
#include "prov.h"
/* 配置命令只收 USB(cmd.h 链路那一段)。宿主机测试用的 CMD_LINK_TEST 视同 USB。 */ /* 配置命令不收 MQTT(cmd.h 链路那一段):USB 与配网热点都是「人在板子旁边」。
static bool usb_only_ok(const ctx_t *c) { return c->link != CMD_LINK_WIFI; } 宿主机测试用的 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) 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) cmd_err_t do_wifi(ctx_t *c)
{ {
if (!usb_only_ok(c)) { if (!local_ok(c)) {
c->bad = CMD_WIFI; c->bad = CMD_WIFI;
return CMD_ERR_LINK; return CMD_ERR_LINK;
} }
@@ -69,7 +71,7 @@ cmd_err_t do_wifi(ctx_t *c)
cmd_err_t do_mqtt(ctx_t *c) cmd_err_t do_mqtt(ctx_t *c)
{ {
if (!usb_only_ok(c)) { if (!local_ok(c)) {
c->bad = CMD_MQTT; c->bad = CMD_MQTT;
return CMD_ERR_LINK; return CMD_ERR_LINK;
} }
@@ -77,11 +79,23 @@ cmd_err_t do_mqtt(ctx_t *c)
const char *user = NULL; const char *user = NULL;
const char *mpass = NULL; const char *mpass = NULL;
int64_t tele = 0; int64_t tele = 0;
bool keep = false;
const int gt = cj_get_int(c->o, "tele_ms", &tele); 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 || 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; 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 || if (tele < 0 || tele > NETCFG_TELE_MAX_MS ||
!netcfg_mqtt_ok(uri, user, mpass, (uint32_t)tele)) { !netcfg_mqtt_ok(uri, user, mpass, (uint32_t)tele)) {
c->bad = 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, cfg.mpass[0] ? "true" : "false", (unsigned long)cfg.tele_ms,
(unsigned long)st.wifi_drops, (unsigned long)st.mqtt_drops, (unsigned long)st.wifi_drops, (unsigned long)st.mqtt_drops,
(unsigned long)st.tele_sent, (unsigned long)st.tele_skipped); (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; return CMD_OK;
} }
+18 -2
查看文件
@@ -15,6 +15,7 @@
#include "hubctl.h" #include "hubctl.h"
#include "ota.h" #include "ota.h"
#include "pca9555.h" #include "pca9555.h"
#include "pdmon.h"
#include "phonerail.h" #include "phonerail.h"
#include "rail.h" #include "rail.h"
#include "rail_cfg.h" #include "rail_cfg.h"
@@ -87,8 +88,9 @@ static void thermal_obj(cj_writer_t *w, const thermal_snap_t *t)
cj_w(w, "%snull", i ? "," : ""); cj_w(w, "%snull", i ? "," : "");
} }
} }
cj_w(w, "],\"fan\":{\"duty\":%u,\"floor\":%u,\"en\":%s,\"rpm\":[", (unsigned)t->duty, cj_w(w, "],\"fan\":{\"duty\":%u,\"floor\":%u,\"left\":%lu,\"en\":%s,\"rpm\":[", (unsigned)t->duty,
(unsigned)t_thermal_floor(), b(t->fan_en)); (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++) { for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) {
if (t->fan[i].rpm_valid) { if (t->fan[i].rpm_valid) {
cj_w(w, "%s%lu", i ? "," : "", (unsigned long)t->fan[i].rpm); cj_w(w, "%s%lu", i ? "," : "", (unsigned long)t->fan[i].rpm);
@@ -114,6 +116,19 @@ void cmd_report_hub(cj_writer_t *w)
(unsigned long)hubctl_hold_left_s(now), (unsigned long)hubctl_idle_left_s(now)); (unsigned long)hubctl_hold_left_s(now), (unsigned long)hubctl_idle_left_s(now));
} }
/* 0.1.17:`"pd":{"st":8,"ma":3000}` —— CH224A 此刻的协议状态(0x09 原值)与 20 V 档的最大电流(pdmon.h)。
上一拍没读到(或还没读过第一拍)写 null,**不报旧值**:上位机拿它当「现在」。 */
static void pd_obj(cj_writer_t *w)
{
pdmon_snap_t pd;
pdmon_get(&pd);
if (pd.ok) {
cj_w(w, ",\"pd\":{\"st\":%u,\"ma\":%lu}", (unsigned)pd.status, (unsigned long)pd.ma);
} else {
cj_w(w, ",\"pd\":null");
}
}
void cmd_report_status(cj_writer_t *w, const cmd_env_t *env) void cmd_report_status(cj_writer_t *w, const cmd_env_t *env)
{ {
const safety_snap_t *s = env->safety; const safety_snap_t *s = env->safety;
@@ -127,6 +142,7 @@ void cmd_report_status(cj_writer_t *w, const cmd_env_t *env)
cj_w(w, ",\"host\":null"); cj_w(w, ",\"host\":null");
} }
cmd_report_hub(w); cmd_report_hub(w);
pd_obj(w);
if (s != NULL) { if (s != NULL) {
if (s->input_ok) { if (s->input_ok) {
cj_w(w, ",\"in\":{\"mv\":%ld,\"ua\":%ld}", (long)s->input.bus_mv, cj_w(w, ",\"in\":{\"mv\":%ld,\"ua\":%ld}", (long)s->input.bus_mv,
+64 -5
查看文件
@@ -18,7 +18,9 @@
---- 失联不动作(§10.2 / §10.8) ----------------------------------------- ---- 失联不动作(§10.2 / §10.8) -----------------------------------------
WiFi 断、broker 断,本文件只做一件事:**重连**(WiFi 指数退避,MQTT 由 esp-mqtt 每 10 s 重试)。 WiFi 断、broker 断,本文件只做一件事:**重连**(WiFi 指数退避,MQTT 由 esp-mqtt 每 10 s 重试)。
没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。 */ 没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。
0.1.19 起多一件:WiFi 连续 60 s 连不上就**开配网热点**(prov.h),STA 照常在后台重连,
连上了热点自己关。热点同样不碰输出。 */
#include <string.h> #include <string.h>
#include "cmd_queue.h" #include "cmd_queue.h"
@@ -39,6 +41,8 @@
#include "nvs.h" #include "nvs.h"
#include "nvs_flash.h" #include "nvs_flash.h"
#include "phonerail.h" #include "phonerail.h"
#include "prov.h"
#include "prov_esp.h"
#include "tasks.h" #include "tasks.h"
#include "ulink.h" #include "ulink.h"
@@ -91,6 +95,9 @@ static uint32_t s_attempt;
static mlink_tele_t s_tele; static mlink_tele_t s_tele;
static volatile bool s_tele_inflight; static volatile bool s_tele_inflight;
static bool s_up; 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。 /* 发件队列(见文件头):执行器 / esp-mqtt 任务往里拷,t_link 取出来交给 esp-mqtt。
深度 4 × 1.5 KB:遥测 1 s 一条、回应偶尔一条,t_link 至少每 100 ms 来取一次; 深度 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); 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) static void retry_cb(void *arg)
{ {
(void)arg; (void)arg;
@@ -350,7 +367,7 @@ static void retry_cb(void *arg)
if (s_st.wifi != NET_UP && n > 0) { if (s_st.wifi != NET_UP && n > 0) {
const uint8_t i = s_idx < n ? s_idx : 0; const uint8_t i = s_idx < n ? s_idx : 0;
wifi_set_sta(i); 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) { if (!was_up && n > 1) {
s_idx = (uint8_t)((s_idx + 1) % n); 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, ESP_LOGW(TAG, "WiFi 断开,原因 %u,%u ms 后连「%s」", (unsigned)d->reason, (unsigned)ms,
s_cur.wifi[s_idx < n ? s_idx : 0].ssid); s_cur.wifi[s_idx < n ? s_idx : 0].ssid);
esp_timer_stop(s_retry); esp_timer_stop(s_retry);
@@ -407,10 +427,33 @@ static void wifi_apply(void)
} }
wifi_set_sta(s_idx); wifi_set_sta(s_idx);
ST_EDIT(s_st.wifi = NET_CONNECTING); 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,不打口令 */ 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) hal_err_t mlink_esp_init(void)
@@ -444,6 +487,7 @@ hal_err_t mlink_esp_init(void)
ESP_LOGE(TAG, "网络栈起不来 —— WiFi 链路不可用"); ESP_LOGE(TAG, "网络栈起不来 —— WiFi 链路不可用");
return HAL_ERR_STATE; 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(); const wifi_init_config_t wi = WIFI_INIT_CONFIG_DEFAULT();
if (esp_wifi_init(&wi) != ESP_OK) { if (esp_wifi_init(&wi) != ESP_OK) {
ESP_LOGE(TAG, "WiFi 驱动起不来 —— WiFi 链路不可用"); 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_storage(WIFI_STORAGE_RAM); /* 凭据只存我们自己的那份,驱动别再存一份 */
esp_wifi_set_mode(WIFI_MODE_STA); esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_start(); 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; s_up = true;
ESP_LOGI(TAG, "板子 ID %s,topic %s/…", mlink_id(), mlink_topic(MLINK_T_CMD)); ESP_LOGI(TAG, "板子 ID %s,topic %s/…", mlink_id(), mlink_topic(MLINK_T_CMD));
if (!have) { 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(); wifi_apply();
return HAL_OK; return HAL_OK;
@@ -481,14 +529,25 @@ void mlink_esp_poll(void)
if (!(wifi_same && mqtt_same)) { if (!(wifi_same && mqtt_same)) {
mqtt_stop(); 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; s_cur = next;
if (wifi_same) { if (wifi_same) {
s_idx = (uint8_t)j; s_idx = (uint8_t)j;
ST_EDIT(s_st.wifi_idx = s_idx); ST_EDIT(s_st.wifi_idx = s_idx);
} else { } else {
s_idx = first;
wifi_apply(); wifi_apply();
} }
} }
prov_poll();
/* MQTT 的生命周期只在这里管:WiFi 有地址、配了 broker、还没建客户端 ⇒ 建。 /* MQTT 的生命周期只在这里管:WiFi 有地址、配了 broker、还没建客户端 ⇒ 建。
之后掉线重连由 esp-mqtt 自己做(network.reconnect_timeout_ms,默认 10 s)。 */ 之后掉线重连由 esp-mqtt 自己做(network.reconnect_timeout_ms,默认 10 s)。 */
if (s_mqtt == NULL && s_st.wifi == NET_UP && s_cur.uri[0] != '\0') { if (s_mqtt == NULL && s_st.wifi == NET_UP && s_cur.uri[0] != '\0') {
+2 -2
查看文件
@@ -6,10 +6,10 @@
/* NVS → 配置 → WiFi 驱动 → 按配置连网。要在命令队列建好之后、任务起来之前调。 /* NVS → 配置 → WiFi 驱动 → 按配置连网。要在命令队列建好之后、任务起来之前调。
失败不 abort:WiFi 链路不可用,USB 链路与五路供电照常(§10.2)。 失败不 abort:WiFi 链路不可用,USB 链路与五路供电照常(§10.2)。
没配过网时只起驱动、不连,等 USB 下发 `wifi` / `mqtt`。 */ 没配过网时只起驱动、不连,开配网热点(prov.h),等网页或 USB 下发 `wifi` / `mqtt`。 */
hal_err_t mlink_esp_init(void); hal_err_t mlink_esp_init(void);
/* 链路任务每圈调一次(≤100 ms 一次):应用配置变更、按需建 MQTT 客户端、推遥测。 */ /* 链路任务每圈调一次(≤100 ms 一次):应用配置变更、开关配网热点、按需建 MQTT 客户端、推遥测。 */
void mlink_esp_poll(void); void mlink_esp_poll(void);
#endif /* MLINK_ESP_H */ #endif /* MLINK_ESP_H */
+9 -2
查看文件
@@ -4,8 +4,9 @@
USB 链路配网」。所以: USB 链路配网」。所以:
· 配置由两条命令写入:`wifi`(SSID / 密码,**最多存 NETCFG_WIFI_N 组**,0.1.9)与 · 配置由两条命令写入:`wifi`(SSID / 密码,**最多存 NETCFG_WIFI_N 组**,0.1.9)与
`mqtt`(broker 地址 / 账号 / 遥测周期), `mqtt`(broker 地址 / 账号 / 遥测周期),
**只收 USB 链路来的**(cmd.c 回 `link`)—— 经 WiFi 改 WiFi 配置,一次手误就把 **不收 MQTT 来的**(cmd.c 回 `link`)—— 经 WiFi 改 WiFi 配置,一次手误就把
唯一的远程通道弄断了,而那时只能拆机插 USB; 唯一的远程通道弄断了。收的是两条「人在板子旁边」的链路:USB,和 0.1.19 起
连不上 WiFi 时自己开的配网热点(prov.h —— 所以弄断了也不用再拆机插 USB);
· 本模块只管「校验 + 在内存里持有一份 + 调存储后端 + 通知网络那一侧」, · 本模块只管「校验 + 在内存里持有一份 + 调存储后端 + 通知网络那一侧」,
NVS 与 WiFi/MQTT 都在 ESP 胶水里(mlink_esp.c),这一半宿主机可测。 NVS 与 WiFi/MQTT 都在 ESP 胶水里(mlink_esp.c),这一半宿主机可测。
@@ -110,6 +111,12 @@ typedef struct {
202 = 认证失败,15 = 四次握手超时 ≈ 口令错),0 = 没断过 */ 202 = 认证失败,15 = 四次握手超时 ≈ 口令错),0 = 没断过 */
uint8_t wifi_idx; /* 正在连 / 已连上的是表里第几组(net 回应的 ssid 取它) */ uint8_t wifi_idx; /* 正在连 / 已连上的是表里第几组(net 回应的 ssid 取它) */
char id[13]; /* 板子 ID = STA MAC 的 12 位小写十六进制,topic 用它 */ 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; } net_status_t;
void net_status_set(const net_status_t *st); void net_status_set(const net_status_t *st);
+72
查看文件
@@ -0,0 +1,72 @@
/* CH224A 运行期轮询。契约见 pdmon.h。 */
#include "pdmon.h"
#include <stddef.h>
#include "ch224a.h"
#include "evlog.h"
#include "i2cbus.h"
#include "slot.h"
#include "tca9548a.h"
typedef struct {
uint8_t status;
uint32_t ma;
} pd_read_t;
static pdmon_snap_t s_snap;
static int s_ch = I2CBUS_CH_TRUNK; /* 上次应答的支路 */
static uint32_t s_logged_ma; /* 最近一次进了日志的档位 */
static uint32_t s_prev_ma; /* 上一拍读到的(去抖用) */
static hal_err_t read_fn(void *arg)
{
pd_read_t *r = (pd_read_t *)arg;
const hal_err_t err = ch224a_read_status(CH224A_ADDR_DEFAULT, &r->status);
return (err == HAL_OK) ? ch224a_read_max_ma(CH224A_ADDR_DEFAULT, &r->ma) : err;
}
void pdmon_init(uint32_t boot_ma)
{
s_snap = (pdmon_snap_t){ 0 };
s_ch = I2CBUS_CH_TRUNK;
s_logged_ma = boot_ma;
s_prev_ma = boot_ma;
}
void pdmon_poll(void)
{
pd_read_t r = { 0 };
hal_err_t err = i2cbus_xact(s_ch, SLOT_LOCK_TIMEOUT_MS, read_fn, &r);
if (err != HAL_OK) {
const int other = (s_ch == I2CBUS_CH_TRUNK) ? TCA9548A_CH_PD : I2CBUS_CH_TRUNK;
err = i2cbus_xact(other, SLOT_LOCK_TIMEOUT_MS, read_fn, &r);
if (err == HAL_OK) {
s_ch = other;
}
}
s_snap.polls++;
if (err != HAL_OK) {
s_snap.ok = false; /* 值留着:旧的档位比一个 0 可信(ch224a.h 同一条) */
s_snap.errors++;
return;
}
s_snap.status = r.status;
s_snap.ma = r.ma;
s_snap.ok = true;
/* 换档:连续两拍读到同一个非零值、且和日志里最后那条不一样。0 = 协商中途 / 没协商上,不记。 */
if (r.ma != 0 && r.ma == s_prev_ma && r.ma != s_logged_ma) {
s_logged_ma = r.ma;
evlog_put(0, LOG_EV_PD_LIMIT, PDMON_EV_ARG_CHANGED,
(int32_t)(ch224a_power_limit_mw(r.ma) / 100u), (int32_t)r.ma);
}
s_prev_ma = r.ma;
}
void pdmon_get(pdmon_snap_t *out)
{
if (out != NULL) {
*out = s_snap;
}
}
+51
查看文件
@@ -0,0 +1,51 @@
/* CH224A 运行期轮询(0.1.17):把充电器**此刻**给的 PD 档位放进遥测(status 的 `pd`)。
为什么不够用启动那一次(bringup.c 的 `pd_up()`):多口充电器在别的口插拔时会重新分配功率、
重发 Source_Capabilities,20 V 档的电流上限当场就变(板 B 实测过 5 A → 3.25 A → 3 A),
而 20 V 不一定掉、板子不一定重启 —— 只在启动读一次,面板上看到的就是旧档位。
做的事只有读:`0x09`(协议状态)和 `0x50`(档位电流)。**不写任何寄存器**(ch224a.h 那个框)。
读到的档位和上一次记进日志的不一样(连续两拍一致才算数,免得协商中途的瞬时值刷日志)
⇒ 记一条 `LOG_EV_PD_LIMIT`,arg = 2(运行中换档)。
⚠️ 和 bringup 一样只**报**不**动**:逼近上限的降载(§8.3)还没人做(HANDOFF)。
---- 线程 ----------------------------------------------------------------
`pdmon_poll()` 只在**巡检任务**里调,排在 `t_safety_step()` 之后、每 50 圈(1 s)一次,
一次 ≈ 选主干 + 两个单字节读。status 跨任务读快照,遥测无害(cmd.h 同一条)。
⚠️ 别挪到别的任务去:2026-10-06 板 B 实测放在热管理任务里时,它持锁的那一下正好撞上
巡检的下一拍,`skipped` 每秒整整涨 1(每秒有一圈五路都没读)。巡检自己读就没有人跟它抢。 */
#ifndef PDMON_H
#define PDMON_H
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* `LOG_EV_PD_LIMIT` 的 arg:0 = 启动时读到,1 = 启动时读不到 / 读到 0、按保守值(bringup.c),
2 = 运行中档位变了。⚠️ 冻结(上位机 protocol.py)。 */
#define PDMON_EV_ARG_CHANGED 2u
typedef struct {
bool ok; /* 上一拍读到了;false 时下面两个是最后一次读到的值 */
uint8_t status; /* 0x09 原值 */
uint32_t ma; /* 0x50 换算成 mA(没协商上 PD 时是 0) */
uint32_t polls;
uint32_t errors;
} pdmon_snap_t;
/* `boot_ma` = 启动时读到的档位(`bringup_report_t.pd_ma`,读不到是 0):换档事件拿它当基线。 */
void pdmon_init(uint32_t boot_ma);
/* 读一拍。先试上次应答的那条支路,不应答再试另一条(主干 / mux 通道 5,bringup.c `pd_up()` 的注释)。 */
void pdmon_poll(void);
void pdmon_get(pdmon_snap_t *out);
#ifdef __cplusplus
}
#endif
#endif /* PDMON_H */
+1 -1
查看文件
@@ -3,7 +3,7 @@
#define PHONERAIL_H #define PHONERAIL_H
/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ /* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */
#define PHONERAIL_FW_VERSION "0.1.16-c5" #define PHONERAIL_FW_VERSION "0.1.21-c5"
/* 通道数。五路直供,见设计文档 §2。 */ /* 通道数。五路直供,见设计文档 §2。 */
#define PHONERAIL_RAIL_COUNT 5 #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)。**跨任务**:命令处理器在执行器任务里写, /* 上位机 `fan` 命令给的下限(A12)。**跨任务**:命令处理器在执行器任务里写,
本模块在热管理任务里读。一个对齐的 16 位量,Xtensa 上读写是原子的,不加锁。 */ 本模块在热管理任务里读。一个对齐的 16 位量,Xtensa 上读写是原子的,不加锁。 */
static volatile uint16_t s_floor; 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 是低边管,栅极高 = 把风扇的 /* 风扇侧占空比 → 真正写进 `hal_pwm` 的值。Q6 是低边管,栅极高 = 把风扇的
PWM 线拉低 ⇒ 反相(`t_thermal.h` 硬件那三条的第 2 条)。 */ 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; return e;
} }
s->fan_en = true; s->fan_en = true;
s->fan_starts = 1;
s->en_ms = hal_time_now_ms(); s->en_ms = hal_time_now_ms();
s->last_ms = s->en_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 相减,回绕安全 */ const uint32_t delta = now_edges - t->last_edges; /* uint32 相减,回绕安全 */
t->last_edges = now_edges; 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) { if (s->fan_en && hal_time_elapsed_ms(s->en_ms) < T_THERMAL_SPINUP_MS) {
t->win_edges = 0; t->win_edges = 0;
@@ -216,11 +232,37 @@ hal_err_t t_thermal_step(thermal_snap_t *s)
s->temp_errors++; s->temp_errors++;
} }
uint16_t duty = t_thermal_duty_for(s->temp_max_mc, s->temp_valid); 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; const uint16_t floor_ = s_floor;
if (floor_ > duty) { if (floor_ > duty) {
duty = floor_; /* 手动值只抬不压:曲线要得更高时照曲线来 */ 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) { if (s->temp_valid) {
@@ -241,9 +283,25 @@ hal_err_t t_thermal_step(thermal_snap_t *s)
return HAL_OK; 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; s_floor = (permille > 1000u) ? 1000u : permille;
} }
uint16_t t_thermal_floor(void) { return s_floor; } 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, ⚠️ 与之配套的是**占空比下限** `T_THERMAL_DUTY_MIN`:占空比真掉到 0,
导通时间永远攒不满,这条判据就变成了「永远不判」。所以闭环的下限不是 0; 导通时间永远攒不满,这条判据就变成了「永远不判」。所以闭环的下限不是 0;
真要停风扇只能撤 `FAN_EN`(§22.6:0 % PWM 时四线风扇仍转 3300 RPM)。 真要停风扇只能撤 `FAN_EN`(§22.6:0 % PWM 时四线风扇仍转 3300 RPM)。
0.1.21 起凉的时候就是这么停的(`T_THERMAL_FAN_OFF_MC`):断电期间**不判停转**、窗口清零、
转速报 0;`stalled` 保持断电前的结论(一只没插的风扇不会因为每次启停各记一条事件)。
重新上电后照样先过启动宽限。
---- 事件:入队,不落盘 -------------------------------------------------- ---- 事件:入队,不落盘 --------------------------------------------------
`evlog_put()` 是几条指令的入队,任何任务都能调;`evlog_flush()` 是唯一碰 flash `evlog_put()` 是几条指令的入队,任何任务都能调;`evlog_flush()` 是唯一碰 flash
@@ -103,8 +106,18 @@ extern "C" {
而新位置的温升曲线只有上板才有。这里给的是**形状**(分段线性 + 迟滞), 而新位置的温升曲线只有上板才有。这里给的是**形状**(分段线性 + 迟滞),
数值先按常识填、单列在这里,上板实测之后只改这四行。 */ 数值先按常识填、单列在这里,上板实测之后只改这四行。 */
/* 低于它就按下限转(安静优先)。 */ /* 曲线起点:这个温度按下限转,往上线性加到满速。 */
#define T_THERMAL_TEMP_LO_MC 40000 /* 40.000 °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 —— 待实测 */ #define T_THERMAL_TEMP_HI_MC 65000 /* 65.000 °C —— 待实测 */
/* 过温降载门限(FR-13「超阈值先降流再关路」)。本模块只**报**,不动电源 —— /* 过温降载门限(FR-13「超阈值先降流再关路」)。本模块只**报**,不动电源 ——
@@ -204,10 +217,11 @@ typedef struct {
int32_t temp_max_mc; /* 两路里**高的那一个**:一点凉不代表另一点不烫 */ int32_t temp_max_mc; /* 两路里**高的那一个**:一点凉不代表另一点不烫 */
bool temp_valid; /* 至少有一路读到了 */ bool temp_valid; /* 至少有一路读到了 */
uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径 */ uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径;风扇断电时是 0 */
uint16_t gpio_duty; /* 真写进 hal_pwm 的值(Q6 反相 ⇒ 1000 − duty) */ 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; /* 拉高的时刻,启动宽限从这里算 */ uint32_t en_ms; /* 拉高的时刻,启动宽限从这里算 */
bool derate; /* 正在过温降载 */ bool derate; /* 正在过温降载 */
@@ -239,9 +253,19 @@ uint16_t t_thermal_duty_for(int32_t milli_c, bool valid);
/* 上位机手动给的占空比**下限**(风扇侧千分比,A12 的 `fan` 命令)。0 = 全自动。 /* 上位机手动给的占空比**下限**(风扇侧千分比,A12 的 `fan` 命令)。0 = 全自动。
⚠️ 只抬不压:实际占空比 = max(温度曲线, 下限)。「手动把风扇调小」这件事 ⚠️ 只抬不压:实际占空比 = max(温度曲线, 下限)。「手动把风扇调小」这件事
不提供 —— 那等于允许远程关掉散热,而温度读不到时满速(§10.10)的兜底也会被它盖掉。 不提供 —— 那等于允许远程关掉散热,而温度读不到时满速(§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(uint16_t permille);
void t_thermal_set_floor_for(uint16_t permille, uint32_t ttl_ms);
uint16_t t_thermal_floor(void); 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 #ifdef __cplusplus
} }
+3
查看文件
@@ -14,6 +14,9 @@ CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# 配网热点的网页(prov_esp.c):手机浏览器 / 联网探测的请求头常常超过默认的 512 字节(回 431)。
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
# 自定义分区表:默认那张里没有事件日志的地方(需求 §10.7)。见 partitions.csv。 # 自定义分区表:默认那张里没有事件日志的地方(需求 §10.7)。见 partitions.csv。
CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
+27 -1
查看文件
@@ -11,6 +11,7 @@
#include <string.h> #include <string.h>
#include "board_pins.h" #include "board_pins.h"
#include "ch224a.h"
#include "chgmode.h" #include "chgmode.h"
#include "cmd.h" #include "cmd.h"
#include "dac63204.h" #include "dac63204.h"
@@ -21,6 +22,7 @@
#include "hal_time.h" #include "hal_time.h"
#include "i2cbus.h" #include "i2cbus.h"
#include "pca9555.h" #include "pca9555.h"
#include "pdmon.h"
#include "phonerail.h" #include "phonerail.h"
#include "rail.h" #include "rail.h"
#include "rail_cfg.h" #include "rail_cfg.h"
@@ -590,6 +592,20 @@ static void test_fan_floor(void)
TEST_ASSERT_EQUAL_UINT16(700, t_thermal_floor()); TEST_ASSERT_EQUAL_UINT16(700, t_thermal_floor());
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"fan\",\"duty\":0}"), "\"floor\":0"); ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"fan\",\"duty\":0}"), "\"floor\":0");
TEST_ASSERT_EQUAL_UINT16(0, t_thermal_floor()); 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:改模式 / 保持窗口,两条链路都收;越界记事件、缺参数不记; /* 0.1.14 hub:改模式 / 保持窗口,两条链路都收;越界记事件、缺参数不记;
@@ -689,9 +705,18 @@ static void test_status_fits_and_reports_unknowns_as_null(void)
ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"host\":false"); ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"host\":false");
sim_gpio_drive(BOARD_PIN_UP_VBUS_DET, 1); /* 留着高电平:下面量满配长度 */ sim_gpio_drive(BOARD_PIN_UP_VBUS_DET, 1); /* 留着高电平:下面量满配长度 */
/* 0.1.17 CH224A:没读到过 ⇒ null;读到了报原值。 */
pdmon_init(0);
ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"pd\":null");
sim_i2c_add(CH224A_ADDR_DEFAULT, 1);
sim_i2c_set_reg(CH224A_ADDR_DEFAULT, CH224A_REG_STATUS, 0x08);
sim_i2c_set_reg(CH224A_ADDR_DEFAULT, CH224A_REG_CURRENT, 5000 / 50);
pdmon_poll();
const char *r = send("{\"seq\":4000000000,\"cmd\":\"status\"}"); const char *r = send("{\"seq\":4000000000,\"cmd\":\"status\"}");
ASSERT_HAS(r, "\"host\":true"); ASSERT_HAS(r, "\"host\":true");
ASSERT_HAS(r, "{\"seq\":4000000000,\"ok\":true,\"fw\":\"" PHONERAIL_FW_VERSION "\""); ASSERT_HAS(r, "{\"seq\":4000000000,\"ok\":true,\"fw\":\"" PHONERAIL_FW_VERSION "\"");
ASSERT_HAS(r, "\"pd\":{\"st\":8,\"ma\":5000}");
ASSERT_HAS(r, "\"in\":{\"mv\":12030,\"ua\":1234567}"); ASSERT_HAS(r, "\"in\":{\"mv\":12030,\"ua\":1234567}");
ASSERT_HAS(r, "{\"r\":2,\"pr\":true,\"en\":false,\"vb\":false,\"set\":3900,\"mv\":null,\"ua\":null"); ASSERT_HAS(r, "{\"r\":2,\"pr\":true,\"en\":false,\"vb\":false,\"set\":3900,\"mv\":null,\"ua\":null");
ASSERT_HAS(r, "\"r\":4,"); ASSERT_HAS(r, "\"r\":4,");
@@ -701,7 +726,8 @@ static void test_status_fits_and_reports_unknowns_as_null(void)
ASSERT_HAS(r, "\"st\":\"idle\""); ASSERT_HAS(r, "\"st\":\"idle\"");
TEST_ASSERT_EQUAL_CHAR('}', r[strlen(r) - 1]); TEST_ASSERT_EQUAL_CHAR('}', r[strlen(r) - 1]);
printf("status 满配 %u 字节(上限 %u)\n", (unsigned)strlen(r), (unsigned)CMD_OUT_MAX); printf("status 满配 %u 字节(上限 %u)\n", (unsigned)strlen(r), (unsigned)CMD_OUT_MAX);
TEST_ASSERT_TRUE(strlen(r) < CMD_OUT_MAX * 3 / 4); /* 留四分之一给以后加字段 */ /* 原来留的是四分之一;0.1.17 加 `pd` 用掉了一点(1164 字节)。再加字段先看这个数,不够就该涨 CMD_OUT_MAX 了。 */
TEST_ASSERT_TRUE(strlen(r) < CMD_OUT_MAX - 256);
/* 不给快照:只报影子,照样是一行合法回应。 */ /* 不给快照:只报影子,照样是一行合法回应。 */
cmd_init(NULL); cmd_init(NULL);
+178
查看文件
@@ -0,0 +1,178 @@
/* 0.1.17:CH224A 运行期轮询(pdmon.h)。
守四件事:
1. 读到的就是芯片此刻的值,换了档下一拍就跟着变(这个模块存在的理由);
2. 读不到时 `ok` 落下、旧值留着,恢复后自己回来;
3. 换档事件:连续两拍一致才记、同一个档位只记一次、和启动时那条不重复;
4. 主干不应答就去 mux 通道 5 找(设计与实物不一致,bringup.c `pd_up()`)。 */
#include "pdmon.h"
#include "board_pins.h"
#include "ch224a.h"
#include "evlog.h"
#include "hal_i2c.h"
#include "hal_lock.h"
#include "i2cbus.h"
#include "sim_hal.h"
#include "tca9548a.h"
#include "unity.h"
static int count_pd_ev(evlog_rec_t *last)
{
(void)evlog_flush(0);
evlog_stats_t st;
evlog_stats(&st);
int n = 0;
evlog_rec_t rec;
for (uint32_t seq = st.oldest_seq; seq < st.next_seq; seq++) {
if (evlog_read(seq, &rec, 1) == 1 && rec.type == (uint8_t)LOG_EV_PD_LIMIT) {
n++;
if (last != NULL) {
*last = rec;
}
}
}
return n;
}
static void charger(uint32_t ma)
{
sim_i2c_set_reg(CH224A_ADDR_DEFAULT, CH224A_REG_STATUS, 0x08);
sim_i2c_set_reg(CH224A_ADDR_DEFAULT, CH224A_REG_CURRENT, (uint16_t)(ma / 50u));
}
void setUp(void)
{
sim_hal_reset();
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init());
sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N);
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init());
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init_ex(0));
}
void tearDown(void) {}
static void test_reports_what_the_chip_says_now(void)
{
sim_i2c_add(CH224A_ADDR_DEFAULT, 1);
charger(3000);
pdmon_init(3000);
pdmon_snap_t s;
pdmon_get(&s);
TEST_ASSERT_FALSE(s.ok); /* 还没读过第一拍 */
pdmon_poll();
pdmon_get(&s);
TEST_ASSERT_TRUE(s.ok);
TEST_ASSERT_EQUAL_UINT8(0x08, s.status);
TEST_ASSERT_EQUAL_UINT32(3000, s.ma);
charger(5000);
pdmon_poll();
pdmon_get(&s);
TEST_ASSERT_EQUAL_UINT32(5000, s.ma); /* 下一拍就是新值,不等去抖 */
}
static void test_read_failure_keeps_the_old_value_and_recovers(void)
{
sim_i2c_add(CH224A_ADDR_DEFAULT, 1);
charger(3250);
pdmon_init(3250);
pdmon_poll();
sim_i2c_set_present(CH224A_ADDR_DEFAULT, false);
pdmon_poll();
pdmon_snap_t s;
pdmon_get(&s);
TEST_ASSERT_FALSE(s.ok);
TEST_ASSERT_EQUAL_UINT32(3250, s.ma);
TEST_ASSERT_EQUAL_UINT32(1, s.errors);
sim_i2c_set_present(CH224A_ADDR_DEFAULT, true);
pdmon_poll();
pdmon_get(&s);
TEST_ASSERT_TRUE(s.ok);
TEST_ASSERT_EQUAL_INT(0, count_pd_ev(NULL)); /* 掉线再回来、档位没变:不记 */
}
static void test_change_is_logged_once_after_two_matching_reads(void)
{
sim_i2c_add(CH224A_ADDR_DEFAULT, 1);
charger(5000);
pdmon_init(5000); /* 启动时 bringup 已经记过 5 A 那条 */
pdmon_poll();
pdmon_poll();
TEST_ASSERT_EQUAL_INT(0, count_pd_ev(NULL));
charger(0); /* 重新协商的中途 */
pdmon_poll();
pdmon_poll();
TEST_ASSERT_EQUAL_INT(0, count_pd_ev(NULL));
charger(3000);
pdmon_poll();
TEST_ASSERT_EQUAL_INT(0, count_pd_ev(NULL)); /* 只见过一拍 */
pdmon_poll();
evlog_rec_t rec;
TEST_ASSERT_EQUAL_INT(1, count_pd_ev(&rec));
TEST_ASSERT_EQUAL_UINT8(PDMON_EV_ARG_CHANGED, rec.arg);
TEST_ASSERT_EQUAL_INT32(600, rec.bus_mv); /* 60 W,单位 mW/100 */
TEST_ASSERT_EQUAL_INT32(3000, rec.current_ua);
for (int i = 0; i < 5; i++) {
pdmon_poll();
}
TEST_ASSERT_EQUAL_INT(1, count_pd_ev(NULL));
}
static void test_boot_missed_it_but_runtime_reads_it(void)
{
sim_i2c_add(CH224A_ADDR_DEFAULT, 1);
charger(3000);
pdmon_init(0); /* 启动时没读到,日志里是「按保守值」那条 */
pdmon_poll();
pdmon_poll();
evlog_rec_t rec;
TEST_ASSERT_EQUAL_INT(1, count_pd_ev(&rec));
TEST_ASSERT_EQUAL_INT32(3000, rec.current_ua);
}
static void test_found_behind_channel_5(void)
{
sim_i2c_add_ch(TCA9548A_CH_PD, CH224A_ADDR_DEFAULT, 1);
sim_i2c_set_reg_ch(TCA9548A_CH_PD, CH224A_ADDR_DEFAULT, CH224A_REG_STATUS, 0x08);
sim_i2c_set_reg_ch(TCA9548A_CH_PD, CH224A_ADDR_DEFAULT, CH224A_REG_CURRENT, 5000 / 50);
pdmon_init(5000);
pdmon_poll();
pdmon_snap_t s;
pdmon_get(&s);
TEST_ASSERT_TRUE(s.ok);
TEST_ASSERT_EQUAL_UINT32(5000, s.ma);
TEST_ASSERT_EQUAL_UINT32(0, s.errors);
}
static void test_absent_chip_is_just_not_ok(void)
{
pdmon_init(0);
pdmon_poll();
pdmon_snap_t s;
pdmon_get(&s);
TEST_ASSERT_FALSE(s.ok);
TEST_ASSERT_EQUAL_UINT32(1, s.polls);
TEST_ASSERT_EQUAL_UINT32(1, s.errors);
TEST_ASSERT_EQUAL_INT(0, count_pd_ev(NULL));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_reports_what_the_chip_says_now);
RUN_TEST(test_read_failure_keeps_the_old_value_and_recovers);
RUN_TEST(test_change_is_logged_once_after_two_matching_reads);
RUN_TEST(test_boot_missed_it_but_runtime_reads_it);
RUN_TEST(test_found_behind_channel_5);
RUN_TEST(test_absent_chip_is_just_not_ok);
return UNITY_END();
}
+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(); sim_hal_reset();
fans_up(); fans_up();
set_temp(25000); /* 25 °C:常温开机 */ set_temp(T_THERMAL_TEMP_LO_MC); /* 曲线起点:风扇开着、按下限转(再凉就断电了,见最后一组用例) */
t_thermal_set_floor(0); /* 下限是模块级静态量,不随 init 清零(t_thermal.h) */ t_thermal_set_floor(0); /* 下限是模块级静态量,不随 init 清零(t_thermal.h) */
} }
void tearDown(void) {} void tearDown(void) {}
@@ -186,7 +186,7 @@ static void test_unreadable_temperature_means_full_speed(void)
{ {
thermal_snap_t s; thermal_snap_t s;
boot(&s); boot(&s);
set_temp(25000); set_temp(T_THERMAL_TEMP_LO_MC);
run(&s, 1); run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); 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; thermal_snap_t s;
boot(&s); boot(&s);
set_temp(25000); set_temp(T_THERMAL_TEMP_LO_MC);
t_thermal_set_floor(800); t_thermal_set_floor(800);
run(&s, 1); run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(800, s.duty); 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); TEST_ASSERT_EQUAL_UINT16(1000, s.duty);
t_thermal_set_floor(T_THERMAL_DUTY_MIN / 2); /* 比曲线下限还低:没有效果 */ t_thermal_set_floor(T_THERMAL_DUTY_MIN / 2); /* 比曲线下限还低:没有效果 */
set_temp(25000); set_temp(T_THERMAL_TEMP_LO_MC);
run(&s, 1); run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); 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; thermal_snap_t s;
boot(&s); boot(&s);
set_temp(25000); /* 凉 ⇒ 占空比压到下限 */ set_temp(T_THERMAL_TEMP_LO_MC); /* 凉 ⇒ 占空比压到下限 */
run(&s, 1); run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); 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)); 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) int main(void)
{ {
UNITY_BEGIN(); UNITY_BEGIN();
@@ -492,5 +633,8 @@ int main(void)
RUN_TEST(test_spinup_grace_suppresses_the_first_windows); RUN_TEST(test_spinup_grace_suppresses_the_first_windows);
RUN_TEST(test_derate_is_edge_triggered_with_hysteresis); RUN_TEST(test_derate_is_edge_triggered_with_hysteresis);
RUN_TEST(test_thermal_never_touches_flash); 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(); return UNITY_END();
} }
+3 -2
查看文件
@@ -77,7 +77,8 @@ bash rack/deploy/deploy.sh # 构建 → 传 → 服务器上 Python 3.8
| 门户 | 应用 id 16,key `rack`、端口 7764(`phonerail` 已被需求文档站占用);授权 admin + staff,**不给 viewer**(没有只读模式,能断电重启) | | 门户 | 应用 id 16,key `rack`、端口 7764(`phonerail` 已被需求文档站占用);授权 admin + staff,**不给 viewer**(没有只读模式,能断电重启) |
| 保留期 | 原始 1 s 遥测 2 天;分钟聚合 90 天;事件 / 审计 / 恢复记录永久 | | 保留期 | 原始 1 s 遥测 2 天;分钟聚合 90 天;事件 / 审计 / 恢复记录永久 |
加一块新板:USB 配网(`firmware/tools/prlink.py` 发 `wifi` / `mqtt`)+ 服务器上给它开 MQTT 账号(见 memory 里的 broker 说明),上线后自动出现在总览。 加一块新板:配网(固件 ≥0.1.19:手机连板子自己开的热点 `PhoneRail-xxxxxx`,在 192.168.4.1 网页里填 WiFi 与 MQTT,
热点口令 `88888888`;或 USB:`firmware/tools/prlink.py` 发 `wifi` / `mqtt`)+ 服务器上给它开 MQTT 账号(见 memory 里的 broker 说明),上线后自动出现在总览。
## 踩过的坑 ## 踩过的坑
@@ -90,7 +91,7 @@ bash rack/deploy/deploy.sh # 构建 → 传 → 服务器上 Python 3.8
## 已知限制 / 下一步 ## 已知限制 / 下一步
- **自动恢复阈值没有实测依据**(平线 5 mA / 过低 30 mA / 窗口 5 分钟)。插上刀片和手机后先看几天曲线再定,默认逐台关闭。 - **自动恢复阈值 2026-10-06 按板 B 实测定过一次**(平线:p95 − p05 < 30 mA 且中位数 ≥ 100 mA / 过低:窗内最大 < 30 mA / 窗口 5 分钟;依据见 `recovery.py` 模块头)。样本只有一次真卡死 + 一晚睡眠数据,**还没开过一次自动恢复**,默认逐台关闭。
- **adb 探活**:接口 `recovery.Prober` 已留,服务器目前够不着手机(机架在 192.168.8.x 的 NAT 后面)。 - **adb 探活**:接口 `recovery.Prober` 已留,服务器目前够不着手机(机架在 192.168.8.x 的 NAT 后面)。
- 前端 Element Plus 是整包引入(gzip ≈335 KB),手机首屏偏慢;改成按需引入可以再砍一半。 - 前端 Element Plus 是整包引入(gzip ≈335 KB),手机首屏偏慢;改成按需引入可以再砍一半。
- 没有细分权限:门户授权了这个应用的人都能操作;审计记着是谁。 - 没有细分权限:门户授权了这个应用的人都能操作;审计记着是谁。
+16
查看文件
@@ -114,6 +114,15 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
_board_or_404(hub, board) _board_or_404(hub, board)
return hub.db.board_history(board, start, end or now_ms()) return hub.db.board_history(board, start, end or now_ms())
@app.get("/api/boards/{board}/energy")
def energy(board: str, days: int = 90):
"""每日耗电量(Db.energy_rollup)。单位 mW·s 与秒,换算留给前端;`today` 是服务器本地日期。
今天这一行最多滞后两分钟(分钟聚合 + 60 s 一次的滚动)。"""
_board_or_404(hub, board)
today = hub.db.q("SELECT date(?/1000,'unixepoch','localtime') d", (now_ms(),))[0]["d"]
return {"today": today, "days": hub.db.energy(board, max(1, min(days, 3660))),
"total": hub.db.energy_total(board)}
@app.get("/api/boards/{board}/rails/{rail}/history") @app.get("/api/boards/{board}/rails/{rail}/history")
def history(board: str, rail: int, start: int, end: Optional[int] = None): def history(board: str, rail: int, start: int, end: Optional[int] = None):
_board_or_404(hub, board) _board_or_404(hub, board)
@@ -188,6 +197,13 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
if d["arg"] == 3: if d["arg"] == 3:
d["name"] += ":" + protocol.OTA_ERR_NAMES.get(d["mv"], str(d["mv"])) d["name"] += ":" + protocol.OTA_ERR_NAMES.get(d["mv"], str(d["mv"]))
d["severe"] = d["arg"] in (3, 5) d["severe"] = d["arg"] in (3, 5)
elif d["type"] == protocol.EV_PD_LIMIT:
pd = protocol.pd_from_event(d["arg"], d["mv"], d["ua"])
d["name"] += "(%s,上限 %g W)" % (
"CH224A 读不到,按保守值" if pd["ma"] is None else
"%sPD %g V / %.2f A" % ("运行中换档:" if d["arg"] == protocol.PD_EV_CHANGED else "",
pd["mv"] / 1000, pd["ma"] / 1000), pd["limit_mw"] / 1000)
d["mv"] = d["ua"] = None # 两列装的是上限和档位电流,不是当时的电压电流
elif d["type"] == 21: elif d["type"] == 21:
a = d["arg"] a = d["arg"]
d["name"] += "(%s)" % ("手动关" if a == protocol.CHG_EV_OFF else d["name"] += "(%s)" % ("手动关" if a == protocol.CHG_EV_OFF else
+49
查看文件
@@ -4,6 +4,7 @@
每分钟降采样一行(均值 / 最小 / 最大)留 `MIN_KEEP_S`(90 天)—— 每分钟降采样一行(均值 / 最小 / 最大)留 `MIN_KEEP_S`(90 天)——
电流曲线的用途是「这台手机前天夜里是不是卡死过」,一分钟的 min/max 足够看出来。 电流曲线的用途是「这台手机前天夜里是不是卡死过」,一分钟的 min/max 足够看出来。
事件、审计、恢复记录**永久保留**(量小,而且是排查的唯一线索)。 事件、审计、恢复记录**永久保留**(量小,而且是排查的唯一线索)。
每日耗电量(`energy_day`)也永久保留:它从分钟聚合里滚出来,分钟数据过了 90 天被清掉之后就再也算不回来了。
⚠️ 时间戳一律是**服务器收到的时刻**(毫秒)。板子没有墙钟(需求 §10.12: ⚠️ 时间戳一律是**服务器收到的时刻**(毫秒)。板子没有墙钟(需求 §10.12:
上电从 0 计),它自己的 `t_ms` 只存在事件表的 `t_ms` 列里,作参考。 上电从 0 计),它自己的 `t_ms` 只存在事件表的 `t_ms` 列里,作参考。
@@ -60,6 +61,14 @@ CREATE TABLE IF NOT EXISTS board_tele_min (
fan_avg INTEGER, in_mv_avg INTEGER, in_ua_avg INTEGER, n INTEGER, fan_avg INTEGER, in_mv_avg INTEGER, in_ua_avg INTEGER, n INTEGER,
PRIMARY KEY (board_id, ts) PRIMARY KEY (board_id, ts)
); );
-- 每日耗电量(Db.energy_rollup)。rail 0 = 底板 12 V 输入,1~5 = 各路手机主板。
-- mws = 这一天累计的能量(mW·s,÷3.6e6 = Wh),secs = 这一天里有数据的秒数(平均功率 = mws / secs)。
-- day 是服务器本地时间的日期:「当日」按机房的零点切,不按 UTC。
CREATE TABLE IF NOT EXISTS energy_day (
board_id TEXT NOT NULL, day TEXT NOT NULL, rail INTEGER NOT NULL,
mws INTEGER NOT NULL, secs INTEGER NOT NULL,
PRIMARY KEY (board_id, day, rail)
);
CREATE TABLE IF NOT EXISTS events ( CREATE TABLE IF NOT EXISTS events (
board_id TEXT NOT NULL, seq INTEGER NOT NULL, board_id TEXT NOT NULL, seq INTEGER NOT NULL,
t_ms INTEGER, rail INTEGER, type INTEGER, arg INTEGER, mv INTEGER, ua INTEGER, t_ms INTEGER, rail INTEGER, type INTEGER, arg INTEGER, mv INTEGER, ua INTEGER,
@@ -190,6 +199,46 @@ class Db:
self.conn.execute("DELETE FROM tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,)) self.conn.execute("DELETE FROM tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
self.conn.execute("DELETE FROM board_tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,)) self.conn.execute("DELETE FROM board_tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
# ---- 耗电量 --------------------------------------------------------
def energy_rollup(self, days_back: int = 1, now: Optional[int] = None):
"""把分钟聚合滚成每日耗电量。重算最近 `days_back`+1 个本地日(含今天,幂等覆盖),
更早的日子**只补缺、不覆盖**:分钟数据过了保留期会被清,拿残缺的一天去覆盖就把历史改小了。
每分钟的能量 = 均值功率 × 这一分钟里的样本数(遥测 1 s 一条,n 就是有数据的秒数,最多按 60 算);
功率拿 mv_avg × ua_avg 近似,电压几乎不动(同 board_history)。板子离线的时段没有行,自然不计。"""
t = now if now is not None else now_ms()
day = "date(ts/1000,'unixepoch','localtime')"
srcs = (("0", "in_mv_avg", "in_ua_avg", "board_tele_min"), ("rail", "mv_avg", "ua_avg", "tele_min"))
with self._lock:
cut = self.conn.execute("SELECT date(?/1000,'unixepoch','localtime',?)",
(t, "-%d days" % max(0, days_back))).fetchone()[0]
for rail, mv, ua, table in srcs:
self.conn.execute(
"INSERT INTO energy_day (board_id, day, rail, mws, secs) "
"SELECT board_id, {day} AS d, {rail} AS r, "
"CAST(SUM({mv}*{ua}/1e6*MIN(n,60)) AS INTEGER), SUM(MIN(n,60)) FROM {table} "
"WHERE {mv} IS NOT NULL AND {ua} IS NOT NULL GROUP BY board_id, d, r "
"ON CONFLICT(board_id, day, rail) DO UPDATE SET mws=excluded.mws, secs=excluded.secs "
"WHERE excluded.day >= ?".format(day=day, rail=rail, mv=mv, ua=ua, table=table), (cut,))
def energy(self, board: str, days: int = 90) -> List[dict]:
"""最近 `days` 个有数据的本地日,旧 → 新。每天:底板输入的能量 / 有数据秒数,各路主板的能量。"""
rows = self.q("SELECT day, rail, mws, secs FROM energy_day WHERE board_id=? AND day IN "
"(SELECT DISTINCT day FROM energy_day WHERE board_id=? ORDER BY day DESC LIMIT ?) "
"ORDER BY day, rail", (board, board, days))
out: dict = {}
for r in rows:
d = out.setdefault(r["day"], {"day": r["day"], "in_mws": None, "secs": 0, "rails": [None] * 5})
if r["rail"] == 0:
d["in_mws"], d["secs"] = r["mws"], r["secs"]
elif 1 <= r["rail"] <= 5:
d["rails"][r["rail"] - 1] = {"mws": r["mws"], "secs": r["secs"]}
return list(out.values())
def energy_total(self, board: str) -> dict:
r = self.q("SELECT COALESCE(SUM(mws),0) mws, COALESCE(SUM(secs),0) secs, MIN(day) since, COUNT(*) days "
"FROM energy_day WHERE board_id=? AND rail=0", (board,))[0]
return dict(r)
def history(self, board: str, rail: int, t0: int, t1: int, max_points: int = 1500) -> dict: def history(self, board: str, rail: int, t0: int, t1: int, max_points: int = 1500) -> dict:
"""[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据, """[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据,
否则回分钟聚合(带 min/max,界面画成带状)。点太多时按时间桶再抽一次。""" 否则回分钟聚合(带 min/max,界面画成带状)。点太多时按时间桶再抽一次。"""
+84
查看文件
@@ -0,0 +1,84 @@
"""每台手机「自己说的」那一半:在不在线、在不在跑活、主板 / CPU 温度。
板子只知道这一路有没有电、多大电流;手机主板多热、代理还活没活着,是机架代理(PhoneRailAgent)
向 AppTask 后端(127.0.0.1:7736)报的。这里每隔几秒去那边读一次 `/api/fleet/phones`,
按机架位对到「哪块板的第几路」,放进板子快照的 `phones[].live`。
规矩:
1. **只读、只显示。** 这些数不参与任何判据 —— 自动恢复仍然只看电流(recovery.py),不看手机温度也不看它在不在线。
2. **读不到就是没有。** AppTask 后端挂了 / 令牌不对 / 超时 ⇒ 整份 live 清空,界面上那一行消失,
不留着上一次的温度冒充现在的。
3. **对不上不猜。** 机架位形如 `B-3`(板名-路号):只有一块板时全归它;多块板时只认「板子名字就是那个字母」的。
没填机架位、路号不在 1~5、板名对不上的手机,一律不摆。
"""
import asyncio
import json
import logging
import re
import urllib.request
from typing import Dict, List, Optional, Tuple
log = logging.getLogger("prrack.fleet")
POLL_S = 5.0
TIMEOUT_S = 4.0
RAIL_RE = re.compile(r"^(.*?)[-_ ]?([1-5])$")
def _board_key(name: str) -> str:
return re.sub(r"[\s板]", "", name or "").upper()
def map_phones(phones: List[dict], boards: List[Tuple[str, str]]) -> Dict[Tuple[str, int], dict]:
"""fleet 的手机表 → {(板子 id, 路号): live}。`boards` 是 [(id, name)]。规矩 3 在这里。"""
out: Dict[Tuple[str, int], dict] = {}
for p in phones:
m = RAIL_RE.match(str(p.get("rail") or "").strip())
if not m or not p.get("enabled", True):
continue
prefix, rail = m.group(1).upper(), int(m.group(2))
if len(boards) == 1:
board = boards[0][0]
else:
hit = [b for b, name in boards if prefix and _board_key(name) == prefix]
if len(hit) != 1:
continue
board = hit[0]
at = p.get("temp_at")
out[(board, rail)] = {
"fleet_rail": p.get("rail"), "online": bool(p.get("online")), "busy": bool(p.get("busy")),
"agent": p.get("version") or "", "ip": p.get("ip") or "",
"temp_board_mc": p.get("temp_board_mc"), "temp_cpu_mc": p.get("temp_cpu_mc"),
"temp_at": at * 1000 if isinstance(at, int) else None, # fleet 是秒,这边一律毫秒
}
return out
class FleetPoller:
def __init__(self, hub, url: str, token: str):
self.hub = hub
self.url = url.rstrip("/") + "/api/fleet/phones"
self.token = token
self._warned = False
def _fetch(self) -> List[dict]:
req = urllib.request.Request(self.url, headers={"X-Auth": self.token})
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as r:
return json.loads(r.read().decode()).get("phones") or []
async def run(self):
loop = asyncio.get_running_loop()
while True:
live: Optional[Dict[Tuple[str, int], dict]]
try:
phones = await loop.run_in_executor(None, self._fetch)
boards = [(r["id"], r["name"]) for r in self.hub.db.q("SELECT id, name FROM boards")]
live = map_phones(phones, boards)
self._warned = False
except Exception as e: # 读不到:清空(规矩 2),只在第一次吭一声
if not self._warned:
log.warning("AppTask 的手机表读不到(%s)—— 手机温度 / 在线状态不显示", e)
self._warned = True
live = {}
self.hub.set_phone_live(live)
await asyncio.sleep(POLL_S)
+26 -2
查看文件
@@ -46,6 +46,7 @@ class Hub:
self._clients: Set[asyncio.Queue] = set() self._clients: Set[asyncio.Queue] = set()
self._tasks: List[asyncio.Task] = [] self._tasks: List[asyncio.Task] = []
self.tele_observers: List[Callable[[str, int, dict], None]] = [] self.tele_observers: List[Callable[[str, int, dict], None]] = []
self.phone_live: Dict[tuple, dict] = {} # (board, rail) → 手机自己报的那一半(fleet.py),只显示
# ---- 生命周期 ------------------------------------------------------------ # ---- 生命周期 ------------------------------------------------------------
async def start(self): async def start(self):
@@ -58,6 +59,7 @@ class Hub:
self.bus.start() self.bus.start()
for kind in ("rsp", "tele", "state"): for kind in ("rsp", "tele", "state"):
self.bus.subscribe("%s/+/%s" % (protocol.TOPIC_ROOT, kind), 0 if kind == "tele" else 1) self.bus.subscribe("%s/+/%s" % (protocol.TOPIC_ROOT, kind), 0 if kind == "tele" else 1)
self.db.energy_rollup(days_back=1) # 补上停机期间没滚的日子(更早的只补缺,见 Db.energy_rollup)
self._tasks = [asyncio.ensure_future(self._flusher()), asyncio.ensure_future(self._maint())] self._tasks = [asyncio.ensure_future(self._flusher()), asyncio.ensure_future(self._maint())]
async def stop(self): async def stop(self):
@@ -192,6 +194,8 @@ class Hub:
self.db.x("UPDATE boards SET ev_cursor=? WHERE id=?", (nxt, board)) self.db.x("UPDATE boards SET ev_cursor=? WHERE id=?", (nxt, board))
if ev: if ev:
self.broadcast({"t": "events", "board": board, "ev": ev, "received": now}) self.broadcast({"t": "events", "board": board, "ev": ev, "received": now})
if any(e[3] == protocol.EV_PD_LIMIT for e in ev): # 板子重启过:供电档位可能换了
self.broadcast({"t": "board", "board": self.snapshot(board)})
if nxt >= rsp.get("next", nxt) or not ev: if nxt >= rsp.get("next", nxt) or not ev:
return return
except CmdTimeout: except CmdTimeout:
@@ -200,6 +204,16 @@ class Hub:
self._syncing.discard(board) self._syncing.discard(board)
# ---- 查 ---------------------------------------------------------------- # ---- 查 ----------------------------------------------------------------
def pd(self, board: str) -> Optional[dict]:
"""CH224A 协商到的供电档位。固件只在启动时读一次、记成 PD_LIMIT 事件(不进遥测),
所以取最近一条;板子还没同步过事件是 None。充电器不断电换不了,启动那一次就是现状。"""
rows = self.db.q("SELECT arg, mv, ua, received FROM events WHERE board_id=? AND type=? "
"ORDER BY received DESC, seq DESC LIMIT 1", (board, protocol.EV_PD_LIMIT))
if not rows:
return None
r = rows[0]
return dict(protocol.pd_from_event(r["arg"], r["mv"], r["ua"]), ts=r["received"])
def snapshot(self, board: str) -> dict: def snapshot(self, board: str) -> dict:
b = self._board(board) b = self._board(board)
meta = self.db.q("SELECT name, location FROM boards WHERE id=?", (board,)) meta = self.db.q("SELECT name, location FROM boards WHERE id=?", (board,))
@@ -208,8 +222,17 @@ class Hub:
stale = b["last_tele"] == 0 or now_ms() - b["last_tele"] > STALE_MS stale = b["last_tele"] == 0 or now_ms() - b["last_tele"] > STALE_MS
return {"id": board, "name": meta[0]["name"] if meta else "", "location": meta[0]["location"] if meta else "", return {"id": board, "name": meta[0]["name"] if meta else "", "location": meta[0]["location"] if meta else "",
"online": b["online"], "stale": stale, "fw": b["fw"], "ip": b["ip"], "online": b["online"], "stale": stale, "fw": b["fw"], "ip": b["ip"],
"last_tele": b["last_tele"], "status": b["status"], "last_tele": b["last_tele"], "status": b["status"], "pd": self.pd(board),
"phones": [dict(p) for p in phones]} "phones": [dict(p, live=self.phone_live.get((board, p["rail"]))) for p in phones]}
def set_phone_live(self, live: Dict[tuple, dict]):
"""fleet.py 每轮读完调一次。只把**变了的板子**推给网页(温度几秒才动一下,不值得每轮都推)。"""
old, self.phone_live = self.phone_live, live
for board in {k[0] for k in old} | {k[0] for k in live}:
a = {r: v for (b, r), v in old.items() if b == board}
b_ = {r: v for (b, r), v in live.items() if b == board}
if a != b_:
self.broadcast({"t": "phones_live", "board": board, "live": b_})
def boards(self) -> List[dict]: def boards(self) -> List[dict]:
return [self.snapshot(b) for b in sorted(self.latest)] return [self.snapshot(b) for b in sorted(self.latest)]
@@ -257,6 +280,7 @@ class Hub:
await asyncio.sleep(MAINT_S) await asyncio.sleep(MAINT_S)
try: try:
self.db.downsample() self.db.downsample()
self.db.energy_rollup() # 先滚再清:今天 / 昨天每分钟重算一遍,量是几千行
self.db.prune() self.db.prune()
except Exception: except Exception:
log.exception("降采样 / 清理失败") log.exception("降采样 / 清理失败")
+15 -1
查看文件
@@ -22,6 +22,7 @@ from .api import create_app
from .bus import MemBroker, MemBus, PahoBus from .bus import MemBroker, MemBus, PahoBus
from .db import Db, now_ms from .db import Db, now_ms
from .firmware import FwStore from .firmware import FwStore
from .fleet import FleetPoller
from .hub import Hub from .hub import Hub
from .recovery import RecoveryManager from .recovery import RecoveryManager
from .simboard import HUNG, SimBoard from .simboard import HUNG, SimBoard
@@ -77,9 +78,20 @@ def build(args):
await hub.stop() await hub.stop()
db.close() db.close()
# 手机自己报的那一半(在线 / 温度)从 AppTask 后端读(fleet.py)。没配令牌文件就不读,界面上那一行不出现。
token = ""
if args.apptask_env and os.path.isfile(args.apptask_env):
token = read_env(args.apptask_env).get("APPTASK_TOKEN", "")
if token:
async def fleet_up():
asyncio.ensure_future(FleetPoller(hub, args.apptask_url, token).run())
else:
fleet_up = None
log.info("没有 AppTask 令牌(--apptask-env):不读手机温度 / 在线状态")
static = args.static if args.static and os.path.isdir(args.static) else None static = args.static if args.static and os.path.isdir(args.static) else None
fw = FwStore(db, args.fw_dir, args.fw_base) if args.fw_dir else None fw = FwStore(db, args.fw_dir, args.fw_base) if args.fw_dir else None
startup = [up] startup = [up] + ([fleet_up] if fleet_up else [])
if fw is not None: if fw is not None:
async def fw_gc(): async def fw_gc():
async def loop(): async def loop():
@@ -120,6 +132,8 @@ def main(argv=None):
help="固件下载目录(nginx 7765 的 /fw/,生产是 /root/phonerail/fw);不给就没有 OTA") help="固件下载目录(nginx 7765 的 /fw/,生产是 /root/phonerail/fw);不给就没有 OTA")
ap.add_argument("--fw-base", default="http://home.example:7765/fw", ap.add_argument("--fw-base", default="http://home.example:7765/fw",
help="板子下载固件用的 URL 前缀(板子只能走公网)") help="板子下载固件用的 URL 前缀(板子只能走公网)")
ap.add_argument("--apptask-url", default="http://127.0.0.1:7736", help="AppTask 后端(读机架手机的在线状态 / 温度)")
ap.add_argument("--apptask-env", default=None, help="含 APPTASK_TOKEN= 的 env 文件(生产是 /root/apptask/apptask-py.env)")
ap.add_argument("--sim", type=int, default=0, help="不连 broker,起 N 块模拟板子") ap.add_argument("--sim", type=int, default=0, help="不连 broker,起 N 块模拟板子")
args = ap.parse_args(argv) args = ap.parse_args(argv)
if not ipaddress.ip_address(args.bind).is_loopback: if not ipaddress.ip_address(args.bind).is_loopback:
+24 -1
查看文件
@@ -42,6 +42,21 @@ EVENT_TYPES: Dict[int, Tuple[str, str]] = {
22: ("HUB", "Hub 启停"), # 0.1.14:arg 0 = 休眠、1 = 唤醒、0x10|模式 = 改模式;mV 列 = 唤醒理由(HUB_WHY) 22: ("HUB", "Hub 启停"), # 0.1.14:arg 0 = 休眠、1 = 唤醒、0x10|模式 = 改模式;mV 列 = 唤醒理由(HUB_WHY)
} }
# LOG_EV_PD_LIMIT(firmware/main/bringup.c 的 pd_up):每次启动记一条,arg 1 = CH224A 读不到 / 读到 0、按保守值;
# 固件 0.1.17 起运行中档位变了也记(arg 2,pdmon.h),并且遥测里有实时的 `pd`:{st,ma}(界面优先用它)。
# mV 列 = 整板功率上限 mW/100,µA 列 = CH224A 0x50 读到的档位电流 mA(读不到是 0)。两列都不是电压电流。
EV_PD_LIMIT = 16
PD_EV_FALLBACK, PD_EV_CHANGED = 1, 2
PD_FIXED_MV = 20000 # 板上 R7 硬件固定请求 20 V 档(ch224a.h 的 CH224A_FIXED_MV)
def pd_from_event(arg: int, mv: int, ua: int) -> dict:
"""PD_LIMIT 事件 → 界面用的供电信息。`ma` 是充电器在 20 V 档给的最大电流(读不到是 None),
`limit_mw` 是固件据此定的整板功率上限,`fallback` = 没读到、按保守值。"""
return {"mv": PD_FIXED_MV, "ma": ua if ua and arg != PD_EV_FALLBACK else None,
"limit_mw": (mv or 0) * 100, "fallback": arg == PD_EV_FALLBACK}
# LOG_EV_HUB 的 arg 与理由(firmware/main/hubctl.h)。⚠️ 冻结。 # LOG_EV_HUB 的 arg 与理由(firmware/main/hubctl.h)。⚠️ 冻结。
HUB_EV_OFF, HUB_EV_ON, HUB_EV_MODE = 0, 1, 0x10 HUB_EV_OFF, HUB_EV_ON, HUB_EV_MODE = 0, 1, 0x10
HUB_MODES = ("auto", "on") # hub_mode_t 的下标 HUB_MODES = ("auto", "on") # hub_mode_t 的下标
@@ -157,7 +172,15 @@ def build_command(req: dict) -> dict:
duty = req.get("duty") duty = req.get("duty")
if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000: if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000:
raise CmdError("风扇下限必须是 0~1000‰(0 = 自动)") raise CmdError("风扇下限必须是 0~1000‰(0 = 自动)")
return {"cmd": cmd, "duty": duty} out = {"cmd": cmd, "duty": duty}
# 固件 ≥0.1.21:`sec` = 这个下限保持多少秒,到期自动回落到自动(没给 = 一直保持)。
sec = req.get("sec")
if sec is not None:
if not isinstance(sec, int) or isinstance(sec, bool) or not 0 <= sec <= 86400:
raise CmdError("风扇下限的保持时间必须是 0~86400 秒(0 / 不给 = 一直保持)")
if sec:
out["sec"] = sec
return out
raise CmdError("不支持的命令:%r" % (cmd,)) raise CmdError("不支持的命令:%r" % (cmd,))
+29 -9
查看文件
@@ -1,12 +1,28 @@
"""自动崩溃恢复(需求 §4.1 两级重启:先软后硬),2026-09-27 定为「先只看电流」。 """自动崩溃恢复(需求 §4.1 两级重启:先软后硬),2026-09-27 定为「先只看电流」。
服务器够不到手机的 adb(手机在机架那边的 NAT 后面),所以这一版的判据只来自板子遥测: 服务器够不到手机的 adb(手机在机架那边的 NAT 后面),所以这一版的判据只来自板子遥测:
· FLAT:窗口内电流几乎不变(max − min < flat_ua)且不低 —— 卡死的手机常见的样子; · FLAT:窗口内电流平在一个不低的水平上 —— **中位数 ≥ flat_min_ua 且 p95 − p05 < flat_ua**。卡死的手机就是这样;
· LOW :窗口内电流一直低于 low_ua —— 关机 / 掉到待机,却本该在跑。 · LOW :窗口内电流一直低于 low_ua —— 关机,或者睡下去之后再也没醒过(代理不轮询了)。
adb 探活留了接口(`Prober`),手机能从服务器够到(比如经 frp)时再接上。 adb 探活留了接口(`Prober`),手机能从服务器够到(比如经 frp)时再接上。
⚠️ 这两条阈值**还没有实测依据**(刀片还没插、手机还没上架),默认值是按常识填的。 阈值的依据(2026-10-06,板 B 五台小米8 的 1 Hz 遥测,300 s 窗口每 30 s 滑一次):
所以自动恢复**默认关闭、逐台打开**,而且:
| 状态 | 窗口数 | 中位数 | p95 − p05 | max − min | 窗内最大值 |
|---|---|---|---|---|---|
| 真卡死(10-04 第 1 路,平在 520 mA 五个半小时) | 590 | 518~524 mA | 5~20 mA | 17~189 mA | — |
| 健康、亮屏常醒(10-04 17:00 ~ 10-05 20:00,第 1~4 路) | 4×3187 | ≥ 213 mA | ≥ 42 mA | ≥ 141 mA | — |
| 健康、空闲睡着(10-05 23:15 起 5.8 h,五路) | 5×665 | 7~12 mA | ≥ 8 mA | ≥ 213 mA | ≥ 221 mA |
· 平线原来用 max − min < 5 mA:真卡死时偶尔冒一个尖峰,那 590 个窗口**一个都判不出来**。换成对尖峰不敏感的
p95 − p05,卡死 ≤ 20 mA、健康亮屏 ≥ 42 mA,取 30 mA。
· 平线还要「不低」(中位数 ≥ 100 mA):空闲睡着的手机 95 % 的时间都平在 ~10 mA(ROM 文档 §4.18),那是正常的。
卡死的手机 CPU 是醒着的(见过 254 / 258 / 365 / 520 mA),不会掉到那么低。
· 过低仍是「窗内最大值 < 30 mA」:睡着的手机每 25 s 被代理的长轮询叫醒一次,窗内最大值都在 221 mA 以上;
5 分钟里一个尖峰都没有 = 关机了,或者睡死了。
⚠️ 样本只有**一次**真卡死和一晚上的睡眠数据;过低那一条没有真实的关机样本。还没开过一次自动恢复。
⚠️ 熄屏但 WiFi 省电没还回去(旧 ROM / rc 触发器没生效)时会平在 ~178 mA,会被判成平线 —— 那本身也是该处理的异常。
所以自动恢复仍然**默认关闭、逐台打开**,而且:
· 只在「这台手机开了自动恢复 + 板子在线 + 刀片在位 + 这一路有电 + 执行器空闲」时才判定, · 只在「这台手机开了自动恢复 + 板子在线 + 刀片在位 + 这一路有电 + 执行器空闲」时才判定,
任何一条不满足就把积累的历史清掉 —— 失联不动作(§10.2); 任何一条不满足就把积累的历史清掉 —— 失联不动作(§10.2);
· 每小时最多 `max_per_hour` 次,超了就 GAVE_UP 停手、告警,要人来复位 —— · 每小时最多 `max_per_hour` 次,超了就 GAVE_UP 停手、告警,要人来复位 ——
@@ -33,8 +49,9 @@ IDLE, SOFT, HARD, GAVE_UP = "idle", "soft", "hard", "gave_up"
@dataclass @dataclass
class Params: class Params:
window_s: int = 300 # 判定窗口 window_s: int = 300 # 判定窗口
flat_ua: int = 5000 # 窗口内电流摆幅小于它 = 平线(5 mA) flat_ua: int = 30000 # 平线:窗口内 p95 − p05 小于它(30 mA)……
low_ua: int = 30000 # 窗口内电流一直低于它 = 像关机(30 mA) flat_min_ua: int = 100000 # ……并且中位数不低于它(100 mA):睡着的手机平在 ~10 mA 是正常的
low_ua: int = 30000 # 窗口内电流一直低于它 = 像关机 / 睡死(30 mA)
settle_s: int = 120 # 动作之后先等它重启完,这段不看 settle_s: int = 120 # 动作之后先等它重启完,这段不看
max_per_hour: int = 3 # 每台每小时最多动作几次 max_per_hour: int = 3 # 每台每小时最多动作几次
soft_ms: int = 10000 # 一级:长按电源键时长(需求 §4.1 ≈10 s) soft_ms: int = 10000 # 一级:长按电源键时长(需求 §4.1 ≈10 s)
@@ -78,10 +95,13 @@ class Detector:
def verdict(self, now: int) -> Optional[str]: def verdict(self, now: int) -> Optional[str]:
if not self.full(now): if not self.full(now):
return None return None
vals = [u for _, u in self.s] vals = sorted(u for _, u in self.s)
if max(vals) < self.p.low_ua: n = len(vals)
if vals[-1] < self.p.low_ua:
return "low" return "low"
if max(vals) - min(vals) < self.p.flat_ua: # 用分位数而不是 max − min:卡死时偶发的一个尖峰不该把平线判据废掉(依据见模块头)
spread = vals[n * 95 // 100] - vals[n * 5 // 100]
if vals[n // 2] >= self.p.flat_min_ua and spread < self.p.flat_ua:
return "flat" return "flat"
return None return None
+7 -1
查看文件
@@ -52,8 +52,10 @@ class SimBoard:
self.ota_req: Optional[dict] = None self.ota_req: Optional[dict] = None
self.ota_rate = 400000 # 每次 tick 下载多少字节(真板 ≈125 KB/s) self.ota_rate = 400000 # 每次 tick 下载多少字节(真板 ≈125 KB/s)
self.hub = {"on": True, "mode": "auto", "why": "idle", "hold": 0, "idle": 600} # 0.1.14,只回显不计时 self.hub = {"on": True, "mode": "auto", "why": "idle", "hold": 0, "idle": 600} # 0.1.14,只回显不计时
self.pd: Optional[dict] = {"st": 8, "ma": 3250}
bus.on_message = self._on_message bus.on_message = self._on_message
self._ev(0, 1, 0) # BOOT self._ev(0, 1, 0) # BOOT
self._ev(0, 16, 0, 650, 3250) # PD_LIMIT:65 W 充电器(mV 列 = 上限 mW/100,µA 列 = 档位 mA)
# ---- 对外 -------------------------------------------------------------- # ---- 对外 --------------------------------------------------------------
def start(self): def start(self):
@@ -143,6 +145,7 @@ class SimBoard:
"rpm": [3300, 3280], "stall": [False, False]}, "derate": False, "rpm": [3300, 3280], "stall": [False, False]}, "derate": False,
"safety": {"cycles": self.up_ms // 20, "skipped": 0, "i2c_err": 0, "events": 0}, "safety": {"cycles": self.up_ms // 20, "skipped": 0, "i2c_err": 0, "events": 0},
"hub": dict(self.hub), "hub": dict(self.hub),
"pd": dict(self.pd) if self.pd else None, # 0.1.17:CH224A 实时档位(st = 0x09 原值,bit3 = PD)
"ota": dict(self.ota), "ev_next": len(self.events), "ev_pending": 0, "ota": dict(self.ota), "ev_next": len(self.events), "ev_pending": 0,
"cmd": {"lines": len(self.received), "dups": 0, "rej": 0}} "cmd": {"lines": len(self.received), "dups": 0, "rej": 0}}
@@ -182,7 +185,10 @@ class SimBoard:
return {"ok": True, "oldest": 0, "next": len(self.events), "ev": ev} return {"ok": True, "oldest": 0, "next": len(self.events), "ev": ev}
if cmd == "fan": if cmd == "fan":
self.fan_floor = req["duty"] self.fan_floor = req["duty"]
return {"ok": True, "floor": self.fan_floor} rsp = {"ok": True, "floor": self.fan_floor}
if "sec" in req:
rsp["sec"] = req["sec"] # 模拟板不倒计时:只把形状回对
return rsp
if req.get("seq") is None: if req.get("seq") is None:
return {"ok": False, "err": "no_seq"} return {"ok": False, "err": "no_seq"}
if cmd == "hub": # 0.1.14:固件 hubctl.h 的命令形状;不模拟倒计时 if cmd == "hub": # 0.1.14:固件 hubctl.h 的命令形状;不模拟倒计时
+42 -1
查看文件
@@ -90,7 +90,7 @@ def test_phone_settings_toggle_auto_recovery(env):
rec = c.get("/api/recovery").json() rec = c.get("/api/recovery").json()
assert rec["params"]["window_s"] == 300 assert rec["params"]["window_s"] == 300
r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1}) r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1})
assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 5000 # 负数不收 assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 30000 # 负数不收
def test_history_after_telemetry(env): def test_history_after_telemetry(env):
@@ -238,3 +238,44 @@ def test_hub_round_trip_and_event_names(env):
assert "唤醒:有手机开着 VBUS" in ev[9201]["name"] and ev[9201]["mv"] is None assert "唤醒:有手机开着 VBUS" in ev[9201]["name"] and ev[9201]["mv"] is None
assert "休眠" in ev[9202]["name"] and ev[9202]["severe"] is False assert "休眠" in ev[9202]["name"] and ev[9202]["severe"] is False
assert "模式改为常开" in ev[9203]["name"] assert "模式改为常开" in ev[9203]["name"]
def test_pd_info_comes_from_the_latest_pd_limit_event(env):
# CH224A 的档位不在遥测里:取最近一条 PD_LIMIT 事件。模拟板启动时记的是 65 W
c, hub, sim, db = env
deadline = time.time() + 3
while hub.pd(B) is None and time.time() < deadline:
time.sleep(0.02)
pd = c.get("/api/boards/%s" % B).json()["pd"]
assert (pd["mv"], pd["ma"], pd["limit_mw"], pd["fallback"]) == (20000, 3250, 65000, False)
now = 10 ** 13
db.many("INSERT INTO events (board_id, seq, t_ms, rail, type, arg, mv, ua, received) VALUES (?,?,?,?,?,?,?,?,?)",
[(B, 9301, 1, 0, 16, 1, 600, 0, now)]) # 换了充电器重启,这次没读到
pd = c.get("/api/boards/%s" % B).json()["pd"]
assert pd["ma"] is None and pd["limit_mw"] == 60000 and pd["fallback"] is True and pd["ts"] == now
ev = {e["seq"]: e for e in c.get("/api/events", params={"board": B}).json()}
assert "读不到" in ev[9301]["name"] and "60 W" in ev[9301]["name"]
assert ev[9301]["mv"] is None and ev[9301]["ua"] is None # 不能显示成「0.600 V」
boot = next(e for e in ev.values() if e["type"] == 16 and e["seq"] != 9301)
assert "PD 20 V / 3.25 A" in boot["name"] and "65 W" in boot["name"]
# 固件 0.1.17:遥测里带实时档位;运行中换档的事件(arg 2)单独说明
assert c.post("/api/boards/%s/cmd" % B, json={"cmd": "status"}).json()["pd"] == {"st": 8, "ma": 3250}
db.many("INSERT INTO events (board_id, seq, t_ms, rail, type, arg, mv, ua, received) VALUES (?,?,?,?,?,?,?,?,?)",
[(B, 9302, 2, 0, 16, 2, 1000, 5000, now + 1)])
ev = {e["seq"]: e for e in c.get("/api/events", params={"board": B}).json()}
assert "运行中换档:PD 20 V / 5.00 A" in ev[9302]["name"] and "100 W" in ev[9302]["name"]
assert c.get("/api/boards/%s" % B).json()["pd"]["ma"] == 5000
def test_energy_endpoint(env):
c, hub, sim, db = env
ts = now_ms() // 60000 * 60000 - 120000
db.many("INSERT INTO board_tele_min (ts, board_id, in_mv_avg, in_ua_avg, n) VALUES (?,?,?,?,?)",
[(ts, B, 12000, 250000, 60)])
db.energy_rollup()
r = c.get("/api/boards/%s/energy" % B).json()
day = time.strftime("%Y-%m-%d", time.localtime(ts / 1000))
row = next(d for d in r["days"] if d["day"] == day)
assert row["in_mws"] >= 180000 and row["secs"] >= 60 and len(row["rails"]) == 5
assert r["total"]["mws"] >= 180000 and r["today"] == time.strftime("%Y-%m-%d")
assert c.get("/api/boards/ffffffffffff/energy").status_code == 404
+111 -2
查看文件
@@ -59,12 +59,24 @@ def test_build_command_whitelist_and_ranges():
{"cmd": "power", "rail": True, "on": True}, # bool 不是 int {"cmd": "power", "rail": True, "on": True}, # bool 不是 int
{"cmd": "vbus", "rail": 1, "on": 1}, {"cmd": "vbus", "rail": 1, "on": 1},
{"cmd": "fan", "duty": 1001}, {"cmd": "fan", "duty": 1001},
{"cmd": "fan", "duty": 500, "sec": 86401},
{"cmd": "fan", "duty": 500, "sec": -1},
{"cmd": "fan", "duty": 500, "sec": "60"},
{"cmd": "fan", "duty": 500, "sec": True},
] ]
for b in bad: for b in bad:
with pytest.raises(protocol.CmdError): with pytest.raises(protocol.CmdError):
protocol.build_command(b) protocol.build_command(b)
def test_fan_floor_may_carry_a_hold_time():
"""固件 0.1.21:`sec` 原样带下去;0 / 不给 = 一直保持,命令里就不出现这个键(老固件不认它也没关系)。"""
assert protocol.build_command({"cmd": "fan", "duty": 600}) == {"cmd": "fan", "duty": 600}
assert protocol.build_command({"cmd": "fan", "duty": 600, "sec": 0}) == {"cmd": "fan", "duty": 600}
assert protocol.build_command({"cmd": "fan", "duty": 600, "sec": 90}) == {"cmd": "fan", "duty": 600, "sec": 90}
assert protocol.build_command({"cmd": "fan", "duty": 0, "sec": 86400}) == {"cmd": "fan", "duty": 0, "sec": 86400}
def test_seq_is_strictly_increasing_and_in_range(): def test_seq_is_strictly_increasing_and_in_range():
t = [1700000000.0] t = [1700000000.0]
g = protocol.SeqGen(clock=lambda: t[0]) g = protocol.SeqGen(clock=lambda: t[0])
@@ -163,8 +175,8 @@ def test_timeout_is_audited_and_raised(tmp_path):
def test_events_are_synced_incrementally(tmp_path): def test_events_are_synced_incrementally(tmp_path):
hub, sim, db = rig(tmp_path) hub, sim, db = rig(tmp_path)
for i in range(19): for i in range(18):
sim._ev(1, 8, i) # 加上 BOOT 共 20 条:要分 3 页 sim._ev(1, 8, i) # 加上 BOOT、PD_LIMIT 共 20 条:要分 3 页
async def go(): async def go():
await hub.start() await hub.start()
@@ -237,6 +249,34 @@ def test_low_current_is_detected_and_normal_is_not():
assert feed(ph2, 0, 61, lambda s: 800 + s % 3)[-1][2][0] == "low" assert feed(ph2, 0, 61, lambda s: 800 + s % 3)[-1][2][0] == "low"
# 2026-10-06 按实测改的平线判据(recovery.py 模块头的表):下面三条钉的是「改之前判错」的那几种形状。
def test_flat_tolerates_occasional_spikes():
"""真卡死(10-04 第 1 路):平在 ~520 mA,偶尔冒一个 +150 mA 的尖峰。max − min 判据一个窗口都抓不到。"""
ph = PhoneRecovery(Params(window_s=60, settle_s=30))
r = feed(ph, 0, 61, lambda s: 520000 + (s % 3) * 4000 + (150000 if s % 25 == 0 else 0))
assert r and r[-1][1] == SOFT and r[-1][2][0] == "flat"
def test_sleeping_phone_is_healthy():
"""空闲睡着:平在 ~10 mA,每 25 s 被长轮询叫醒一秒(~250 mA)。既不是平线(低)也不是过低(有尖峰)。"""
ph = PhoneRecovery(Params(window_s=300, settle_s=30))
assert feed(ph, 0, 900, lambda s: 250000 if s % 25 == 0 else 9000 + s % 4 * 1000) == []
def test_asleep_and_never_waking_is_low():
"""睡下去之后再也没醒(代理不轮询了):5 分钟里一个尖峰都没有 ⇒ 过低。"""
ph = PhoneRecovery(Params(window_s=300, settle_s=30))
r = feed(ph, 0, 301, lambda s: 9000 + s % 4 * 1000)
assert r and r[-1][2][0] == "low"
def test_flat_but_low_level_is_not_flat():
"""平在 60 mA(低于 flat_min_ua、高于 low_ua):不下结论 —— 没见过卡死的手机掉到这么低。"""
ph = PhoneRecovery(Params(window_s=60, settle_s=30))
assert feed(ph, 0, 200, lambda s: 60000 + s % 3 * 1000) == []
def test_rate_limit_gives_up(): def test_rate_limit_gives_up():
ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2)) ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2))
t = 0 t = 0
@@ -341,3 +381,72 @@ def test_flash_mode_extend_command():
{"cmd": "flash_mode", "rail": 1, "add": 30, "min": 10}): {"cmd": "flash_mode", "rail": 1, "add": 30, "min": 10}):
with pytest.raises(protocol.CmdError): with pytest.raises(protocol.CmdError):
protocol.build_command(bad) protocol.build_command(bad)
# ---- 耗电量 -------------------------------------------------------------------
def test_energy_is_rolled_up_per_local_day_and_old_days_are_not_shrunk(tmp_path):
import time
db = Db(str(tmp_path / "e.db"))
day_ms = lambda y, m, d, h=12: int(time.mktime((y, m, d, h, 0, 0, 0, 0, -1)) * 1000)
d1, d2 = day_ms(2026, 10, 4), day_ms(2026, 10, 5)
# 10-04:两个满分钟 12 V × 0.5 A = 6 W ⇒ 720 W·s;10-05:一个只有 30 个样本的分钟 12 W ⇒ 360 W·s
db.many("INSERT INTO board_tele_min (ts, board_id, in_mv_avg, in_ua_avg, n) VALUES (?,?,?,?,?)",
[(d1, B, 12000, 500000, 60), (d1 + 60000, B, 12000, 500000, 60), (d2, B, 12000, 1000000, 30),
(d2 + 60000, B, None, None, 60)]) # 输入侧 INA226 没读到的那一分钟:不计,也不算时长
db.many("INSERT INTO tele_min (ts, board_id, rail, mv_avg, ua_avg, ua_min, ua_max, n) VALUES (?,?,?,?,?,?,?,?)",
[(d1, B, 2, 4000, 250000, 0, 0, 60)]) # 第 2 路 1 W × 60 s
db.energy_rollup(days_back=1, now=d2)
days = db.energy(B)
assert [d["day"] for d in days] == ["2026-10-04", "2026-10-05"]
assert (days[0]["in_mws"], days[0]["secs"]) == (720000, 120)
assert (days[1]["in_mws"], days[1]["secs"]) == (360000, 30)
assert days[0]["rails"][1] == {"mws": 60000, "secs": 60} and days[0]["rails"][0] is None
assert db.energy_total(B)["mws"] == 1080000 and db.energy_total(B)["since"] == "2026-10-04"
# 分钟数据被清掉一半之后再滚:窗口内的日子照实重算,窗口外的旧日子保持原值
db.x("DELETE FROM board_tele_min WHERE ts=?", (d1,))
db.energy_rollup(days_back=0, now=d2)
assert db.energy(B)[0]["in_mws"] == 720000
db.energy_rollup(days_back=1, now=d2)
assert db.energy(B)[0]["in_mws"] == 360000
assert len(db.energy(B, days=1)) == 1 and db.energy(B, days=1)[0]["day"] == "2026-10-05"
# ---- 手机自己报的那一半(fleet.py)---------------------------------------------
def test_fleet_phones_are_mapped_to_board_rails(tmp_path):
from prrack.fleet import map_phones
ph = lambda rail, **kw: dict({"rail": rail, "enabled": True, "online": True, "busy": False, "version": "0.8.0",
"temp_board_mc": 28000, "temp_cpu_mc": 31000, "temp_at": 1700000000}, **kw)
phones = [ph("B-1"), ph("B-3", busy=True), ph(""), ph("B-9"), ph("B-2", enabled=False),
ph("B-5", temp_board_mc=None, temp_cpu_mc=None, temp_at=None)]
one = map_phones(phones, [(B, "")])
assert sorted(r for _, r in one) == [1, 3, 5] # 没填机架位 / 路号越界 / 停用的不摆
assert one[(B, 1)]["temp_board_mc"] == 28000 and one[(B, 1)]["temp_at"] == 1700000000000
assert one[(B, 3)]["busy"] is True and one[(B, 5)]["temp_board_mc"] is None
# 多块板:只认名字就是那个字母的;对不上不猜
two = map_phones(phones + [ph("A-1")], [(B, "板 B"), ("aaaaaaaaaaaa", "")])
assert sorted(two) == [(B, 1), (B, 3), (B, 5)]
hub, sim, db = rig(tmp_path)
got = []
async def go():
await hub.start()
sim.start()
sim.tick(1000)
await settle()
q = hub.subscribe_client()
hub.set_phone_live(one)
hub.set_phone_live(one) # 没变:不再推
while not q.empty():
got.append(q.get_nowait())
snap = hub.snapshot(B)
assert [p["live"] and p["live"]["temp_cpu_mc"] for p in snap["phones"]] == [31000, None, 31000, None, None]
hub.set_phone_live({}) # AppTask 读不到:清空,界面那一行消失
assert all(p["live"] is None for p in hub.snapshot(B)["phones"])
run(go())
msgs = [m for m in got if m["t"] == "phones_live"]
assert len(msgs) == 1 and set(msgs[0]["live"]) == {1, 3, 5}
+1
查看文件
@@ -11,6 +11,7 @@ ExecStart=/root/phonerail-rack/venv/bin/python -m prrack.main \
--static /root/phonerail-rack/frontend/dist \ --static /root/phonerail-rack/frontend/dist \
--mqtt-env /root/phonerail/mqtt.env \ --mqtt-env /root/phonerail/mqtt.env \
--fw-dir /root/phonerail/fw \ --fw-dir /root/phonerail/fw \
--apptask-env /root/apptask/apptask-py.env \
--bind 127.0.0.1 --port 7764 --bind 127.0.0.1 --port 7764
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
+1
查看文件
@@ -26,6 +26,7 @@ export const api = {
req('GET', `/api/boards/${board}/rails/${rail}/history?start=${start}${end ? `&end=${end}` : ''}`), req('GET', `/api/boards/${board}/rails/${rail}/history?start=${start}${end ? `&end=${end}` : ''}`),
boardHistory: (board, start, end) => boardHistory: (board, start, end) =>
req('GET', `/api/boards/${board}/history?start=${start}${end ? `&end=${end}` : ''}`), req('GET', `/api/boards/${board}/history?start=${start}${end ? `&end=${end}` : ''}`),
energy: (board, days = 90) => req('GET', `/api/boards/${board}/energy?days=${days}`),
events: (q = {}) => req('GET', '/api/events?' + new URLSearchParams(q)), events: (q = {}) => req('GET', '/api/events?' + new URLSearchParams(q)),
audit: (q = {}) => req('GET', '/api/audit?' + new URLSearchParams(q)), audit: (q = {}) => req('GET', '/api/audit?' + new URLSearchParams(q)),
recovery: () => req('GET', '/api/recovery'), recovery: () => req('GET', '/api/recovery'),
+2 -2
查看文件
@@ -6,12 +6,12 @@
// · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线; // · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线;
// · 线宽 2px,不画数据点;min~max 画成同色 15% 的带子。 // · 线宽 2px,不画数据点;min~max 画成同色 15% 的带子。
import * as echarts from 'echarts/core' import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts' import { LineChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent } from 'echarts/components' import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { cssVar, dark } from './theme' import { cssVar, dark } from './theme'
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer]) echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer])
export { echarts } export { echarts }
// 字体是打包进来异步下的,而 zrender 按「字体 → 文字」缓存宽度、从不清空: // 字体是打包进来异步下的,而 zrender 按「字体 → 文字」缓存宽度、从不清空:
+169
查看文件
@@ -0,0 +1,169 @@
<template>
<section class="card chart-card">
<div class="hh">
<h2 class="ttl">耗电量</h2>
<el-radio-group v-model="span" size="small">
<el-radio-button v-for="o in SPANS" :key="o.v" :value="o.v">{{ o.t }}</el-radio-button>
</el-radio-group>
</div>
<div class="tiles">
<div v-for="t in tiles" :key="t.k" class="g" :title="t.tip">
<div class="gh"><span class="gk">{{ t.k }}</span><span class="gs">{{ t.sub }}</span></div>
<div class="gvs"><span class="gv readout">{{ t.e.v }}<small>{{ t.e.u }}</small></span>
<span class="gv sm readout">{{ t.w }}<small>W 平均</small></span></div>
</div>
</div>
<div ref="el" class="chart" style="height: 220px" role="img" aria-label="每日耗电量柱状图" />
<div class="rails" v-if="today">
<span class="muted">今日各路主板</span>
<span v-for="r in railsToday" :key="r.i" class="rl">
<b>#{{ r.i }}</b><template v-if="r.label"> {{ r.label }}</template>
<span class="readout">{{ r.e.v }}</span><small>{{ r.e.u }}</small>
<span class="readout w">{{ r.w }}</span><small>W</small>
</span>
</div>
<p class="muted note">{{ note }}</p>
</section>
</template>
<script setup>
// 每日耗电量:后端把分钟聚合滚成 energy_day(db.py energy_rollup),这里只做单位换算和求和。
// 量的是 12 V 母线上的底板输入(U13),不含 20 → 12 V 那级降压的损耗,墙上实际再多几个点。
// 平均功率 = 能量 ÷ 有数据的时长:板子离线的时段不算进分母,所以它是「开着的时候平均多少瓦」。
// 一个量一种颜色:柱子用调色板第 1 槽;今天还没过完,用同色半透明区分,不另起一种颜色。
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { api } from '../api'
import { state } from '../store'
import { dark } from '../theme'
import { echarts, palette, band, chartFonts } from '../chart'
const props = defineProps({ board: String })
const SPANS = [{ v: 14, t: '14 天' }, { v: 30, t: '30 天' }, { v: 90, t: '90 天' }, { v: 3660, t: '全部' }]
const span = ref(30)
const el = ref(null)
const data = ref(null) // { today, days: [{ day, in_mws, secs, rails }], total }
const note = ref('')
let chart = null
// mW·s → 显示:不到 1 kWh 用 Wh
const fmtE = mws => {
if (mws == null) return { v: '—', u: 'Wh' }
const wh = mws / 3.6e6
return wh >= 1000 ? { v: (wh / 1000).toFixed(2), u: 'kWh' } : { v: wh.toFixed(wh >= 100 ? 0 : 1), u: 'Wh' }
}
const fmtW = (mws, secs) => (mws == null || !secs ? '—' : (mws / secs / 1000).toFixed(2))
const hours = secs => (secs / 3600).toFixed(1)
const byDay = computed(() => Object.fromEntries((data.value?.days || []).map(d => [d.day, d])))
const today = computed(() => byDay.value[data.value?.today] || null)
// 最近 n 个日历日(含今天)的合计
function lastDays(n) {
if (!data.value) return { mws: null, secs: 0 }
const end = new Date(data.value.today + 'T00:00:00')
let mws = null, secs = 0
for (let i = 0; i < n; i++) {
const d = new Date(end); d.setDate(end.getDate() - i)
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const row = byDay.value[k]
if (row?.in_mws != null) { mws = (mws ?? 0) + row.in_mws; secs += row.secs }
}
return { mws, secs }
}
const tiles = computed(() => {
const mk = (k, x, sub, tip) => ({ k, sub, tip, e: fmtE(x.mws), w: fmtW(x.mws, x.secs) })
const t = lastDays(1), y2 = lastDays(2), w = lastDays(7), m = lastDays(30)
const y = { mws: y2.mws == null || !(y2.secs - t.secs) ? null : y2.mws - (t.mws ?? 0), secs: y2.secs - t.secs }
const tot = data.value?.total
return [
mk('今日', t, t.secs ? `已记 ${hours(t.secs)} h` : '还没有数据', '从今天 0 点到现在,最多滞后两分钟'),
mk('昨日', y, y.secs ? `${hours(y.secs)} h` : ''),
mk('近 7 天', w, w.secs ? `${hours(w.secs)} h` : ''),
mk('近 30 天', m, m.secs ? `${hours(m.secs)} h` : ''),
mk('累计', tot?.days ? tot : { mws: null, secs: 0 }, tot?.since ? `自 ${tot.since.slice(5)} 起 ${tot.days} 天` : ''),
]
})
const railsToday = computed(() => {
const phones = state.boards[props.board]?.phones || []
return (today.value?.rails || []).map((r, i) => ({
i: i + 1, label: phones.find(p => p.rail === i + 1)?.label || '',
e: fmtE(r?.mws), w: fmtW(r?.mws, r?.secs),
}))
})
function render() {
if (!chart || !data.value) return
const p = palette()
const days = data.value.days
const railsSum = d => d.rails.reduce((s, r) => (r ? (s ?? 0) + r.mws : s), null)
chart.setOption({
animation: false,
textStyle: { fontFamily: getComputedStyle(document.body).fontFamily },
grid: { left: 52, right: 16, top: 28, bottom: 28 },
xAxis: { type: 'category', data: days.map(d => d.day.slice(5)), axisLine: { lineStyle: { color: p.axis } },
axisTick: { show: false }, axisLabel: { color: p.muted, fontSize: 11, hideOverlap: true } },
yAxis: { type: 'value', name: '每日耗电 Wh', min: 0, splitNumber: 3,
nameTextStyle: { color: p.ink2, fontSize: 12, align: 'left', padding: [0, 0, 0, -34] },
axisLabel: { color: p.muted, fontSize: 11 }, splitLine: { lineStyle: { color: p.grid } } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow', shadowStyle: { color: band(p.s1, 0.06) } },
backgroundColor: p.panel, borderColor: p.axis, textStyle: { color: p.ink, fontSize: 12 },
formatter: ps => {
const d = days[ps[0].dataIndex]
const e = fmtE(d.in_mws), r = railsSum(d), re = fmtE(r)
const loss = d.in_mws == null || r == null ? null : fmtE(d.in_mws - r)
return [`<b>${d.day}</b>${d.day === data.value.today ? '(今天,未满)' : ''}`,
`底板输入 ${e.v} ${e.u}`, `平均功率 ${fmtW(d.in_mws, d.secs)} W`,
`五路主板 ${re.v} ${re.u}`, ...(loss ? [`板上损耗 ${loss.v} ${loss.u}`] : []),
`有数据  ${hours(d.secs)} h`].join('<br>')
} },
series: [{ type: 'bar', barMaxWidth: 22, itemStyle: { borderRadius: [2, 2, 0, 0] },
data: days.map(d => ({ value: d.in_mws == null ? null : +(d.in_mws / 3.6e6).toFixed(1),
itemStyle: { color: d.day === data.value.today ? band(p.s1, 0.5) : p.s1 } })) }],
}, true)
}
async function load() {
try {
data.value = await api.energy(props.board, span.value)
note.value = data.value.days.length === 0 ? '还没有耗电记录(板子上线一两分钟后出现)'
: '量的是底板 12 V 输入,不含 PD 20 → 12 V 降压的损耗;平均功率只按有数据的时段算。浅色柱是今天(未满一天)'
render()
} catch (e) {
note.value = '耗电量没取到:' + e.message
}
}
const onResize = () => chart && chart.resize()
let gone = false
let timer = null
onMounted(async () => {
window.addEventListener('resize', onResize)
timer = setInterval(load, 60000)
await chartFonts('每日耗电')
if (gone) return
chart = echarts.init(el.value)
load()
})
onBeforeUnmount(() => { gone = true; clearInterval(timer); window.removeEventListener('resize', onResize); chart && chart.dispose() })
watch(() => [props.board, span.value], load)
watch(dark, render)
</script>
<style scoped>
.tiles { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; margin: 12px 0 4px; }
.g { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay); padding: 10px 14px 12px;
display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.gh { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 20px; }
.gk { color: var(--muted); font-size: 13px; white-space: nowrap; }
.gs { color: var(--muted); font-size: 11px; text-align: right; min-width: 0; }
.gvs { display: flex; gap: 6px 18px; align-items: baseline; flex-wrap: wrap; }
.gv { font-size: 24px; color: var(--ink); line-height: 1.1; white-space: nowrap; }
.gv.sm { font-size: 17px; color: var(--ink-2); }
.gv small { font-size: 11px; color: var(--muted); margin-left: 3px; font-family: var(--ui); }
.rails { display: flex; flex-wrap: wrap; gap: 4px 20px; align-items: baseline; font-size: 13px; color: var(--ink-2); margin-top: 4px; }
.rl small { color: var(--muted); font-size: 11px; margin: 0 2px; }
.rl b { color: var(--ink); font-weight: 600; margin-right: 4px; }
.rl .readout { margin-left: 4px; }
.rl .w { margin-left: 8px; }
@media (max-width: 1200px) { .tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 700px) { .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } .gv { font-size: 20px; } }
</style>
+19 -2
查看文件
@@ -76,6 +76,10 @@
<span class="readout pw">{{ W(x.r.mv, x.r.ua) }}<small>W</small></span></div> <span class="readout pw">{{ W(x.r.mv, x.r.ua) }}<small>W</small></span></div>
<div class="ps" :style="{ color: loud(x.h.level) ? LEVEL_COLOR[x.h.level] : 'var(--muted)' }"> <div class="ps" :style="{ color: loud(x.h.level) ? LEVEL_COLOR[x.h.level] : 'var(--muted)' }">
{{ x.h.text }}<span class="muted readout"> {{ V(x.r.mv) }} V</span></div> {{ x.h.text }}<span class="muted readout"> {{ V(x.r.mv) }} V</span></div>
<div class="pt" v-if="x.temp" :title="x.temp.tip">
<span :style="{ color: x.temp.color }">主板 <b class="readout">{{ x.temp.board }}</b> °C</span>
<span class="muted">CPU <span class="readout">{{ x.temp.cpu }}</span> °C</span>
</div>
</template> </template>
<div class="ps muted" v-else>空槽</div> <div class="ps muted" v-else>空槽</div>
</div> </div>
@@ -128,11 +132,14 @@ import { computed, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import RailOps from './RailOps.vue' import RailOps from './RailOps.vue'
import { api } from '../api' import { api } from '../api'
import { mA, V, W, railHealth, LEVEL_COLOR } from '../util' import { mA, V, W, C, railHealth, LEVEL_COLOR } from '../util'
const props = defineProps({ board: Object, sel: Number, recStates: Object }) const props = defineProps({ board: Object, sel: Number, recStates: Object })
defineEmits(['select']) defineEmits(['select'])
// 手机主板 / CPU 温度的提示线(只改颜色,不参与任何动作):50 °C 橙、60 °C 红
const TEMP_WARN_MC = 50000
const TEMP_BAD_MC = 60000
const LIVE_UA = 5000 const LIVE_UA = 5000
const s = computed(() => props.board?.status) const s = computed(() => props.board?.status)
const online = computed(() => !!props.board?.online && !props.board?.stale && !!s.value) const online = computed(() => !!props.board?.online && !props.board?.stale && !!s.value)
@@ -160,11 +167,19 @@ const rails = computed(() => [1, 2, 3, 4, 5].map(i => {
const live = online.value && pr && r.en && Math.abs(r.ua ?? 0) > LIVE_UA const live = online.value && pr && r.en && Math.abs(r.ua ?? 0) > LIVE_UA
const fault = pr && r.flt && !(r.chg > 0) const fault = pr && r.flt && !(r.chg > 0)
const phone = props.board?.phones?.find(x => x.rail === i) const phone = props.board?.phones?.find(x => x.rail === i)
// 手机主板温度:机架代理(≥0.8.0)每次轮询报的 quiet_therm 和最热的 CPU 核;手机不在线 / 老代理 ⇒ 这一行不出现
const lv = phone?.live
const tmax = Math.max(lv?.temp_board_mc ?? -1e9, lv?.temp_cpu_mc ?? -1e9)
const temp = lv?.online && tmax > -1e9 ? {
board: C(lv.temp_board_mc), cpu: C(lv.temp_cpu_mc),
color: tmax >= TEMP_BAD_MC ? LEVEL_COLOR.bad : tmax >= TEMP_WARN_MC ? LEVEL_COLOR.warn : 'var(--ink-2)',
tip: `手机自己报的(机架代理 ${lv.agent},机架位 ${lv.fleet_rail}):主板热敏 quiet_therm 和最热的 CPU 核,约 25 秒更新一次`,
} : null
const w1 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 母线 → 降压(12 V 侧) const w1 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 母线 → 降压(12 V 侧)
const w2 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 降压 → eFuse const w2 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 降压 → eFuse
const w3 = !online.value || !pr || !r.en ? 'off' : fault ? 'bad' : live ? 'live' : 'idle' // eFuse → 手机 const w3 = !online.value || !pr || !r.en ? 'off' : fault ? 'bad' : live ? 'live' : 'idle' // eFuse → 手机
return { return {
i, r, h, pr, fault, w1, w2, w3, label: phone?.label || '', i, r, h, pr, fault, w1, w2, w3, temp, label: phone?.label || '',
ctl: online.value && pr, // 图上元件可点(调压 / 上下电 / USB);序列类动作另看执行器空闲,在 RailOps 里 ctl: online.value && pr, // 图上元件可点(调压 / 上下电 / USB);序列类动作另看执行器空闲,在 RailOps 里
rev: live && (r.ua ?? 0) < -LIVE_UA, rev: live && (r.ua ?? 0) < -LIVE_UA,
f: flow(r?.ua, p), f: flow(r?.ua, p),
@@ -282,6 +297,8 @@ const vbN = computed(() => rails.value.filter(x => x.pr && x.r.vb).length)
.pv .pw { margin-left: auto; font-size: 14px; color: var(--ink-2); } .pv .pw { margin-left: auto; font-size: 14px; color: var(--ink-2); }
.ps { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ps { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ps .muted { font-weight: 400; font-size: 11px; } .ps .muted { font-weight: 400; font-size: 11px; }
.pt { display: flex; gap: 10px; font-size: 11px; white-space: nowrap; overflow: hidden; margin-top: 1px; }
.pt b { font-weight: 600; }
.phone.bad { border-color: var(--st-bad); } .phone.bad { border-color: var(--st-bad); }
.phone.busy { border-color: var(--st-busy); } .phone.busy { border-color: var(--st-busy); }
.phone.warn { border-color: var(--st-warn); } .phone.warn { border-color: var(--st-warn); }
+45 -5
查看文件
@@ -31,6 +31,12 @@
<div class="g"><div class="gh"><span class="gk">输入</span></div> <div class="g"><div class="gh"><span class="gk">输入</span></div>
<div class="gvs"><span class="gv readout">{{ V(b.status.in?.mv) }}<small>V</small></span> <div class="gvs"><span class="gv readout">{{ V(b.status.in?.mv) }}<small>V</small></span>
<span class="gv readout">{{ mA(b.status.in?.ua) }}<small>mA</small></span></div></div> <span class="gv readout">{{ mA(b.status.in?.ua) }}<small>mA</small></span></div></div>
<div class="g" :title="PD_TIP"><div class="gh"><span class="gk">PD 供电</span><span class="gs">{{ pdNote }}</span></div>
<div class="gvs" v-if="pd"><span class="gv readout">{{ pd.mv / 1000 }}<small>V</small></span>
<span class="gv readout" :class="{ bad: pd.fallback }">{{ pd.ma == null ? '—' : (pd.ma / 1000).toFixed(2) }}<small>A</small></span>
<span class="gv sm readout">{{ pd.limit_mw / 1000 }}<small>W 上限</small></span>
<span v-if="pdUsed != null" class="gv sm readout" :class="{ bad: pdUsed >= 90 }">{{ pdUsed }}<small>% 已用</small></span></div>
<div class="gvs" v-else><span class="gv readout">—</span></div></div>
<div class="g"><div class="gh"><span class="gk">功率</span><span class="gs">底板输入 · 主板合计 · 损耗</span></div> <div class="g"><div class="gh"><span class="gk">功率</span><span class="gs">底板输入 · 主板合计 · 损耗</span></div>
<div class="gvs"><span class="gv readout">{{ W(b.status.in?.mv, b.status.in?.ua) }}<small>W</small></span> <div class="gvs"><span class="gv readout">{{ W(b.status.in?.mv, b.status.in?.ua) }}<small>W</small></span>
<span class="gv sm readout">{{ fmtW(pw.rails) }}<small>W 主板</small></span> <span class="gv sm readout">{{ fmtW(pw.rails) }}<small>W 主板</small></span>
@@ -40,9 +46,10 @@
<div class="g"><div class="gh"><span class="gk">风扇</span> <div class="g"><div class="gh"><span class="gk">风扇</span>
<el-select v-model="fanFloor" size="small" class="floor" aria-label="风扇占空比下限" @change="setFan"> <el-select v-model="fanFloor" size="small" class="floor" aria-label="风扇占空比下限" @change="setFan">
<el-option :value="0" label="自动" /> <el-option :value="0" label="自动" />
<el-option v-for="d in [500, 700, 850, 1000]" :key="d" :value="d" :label="`不低于 ${d / 10}%`" /> <el-option v-for="d in [300, 500, 700, 850, 1000]" :key="d" :value="d" :label="`不低于 ${d / 10}%`" />
</el-select></div> </el-select></div>
<div class="gvs"><span class="gv readout">{{ (b.status.fan?.duty ?? 0) / 10 }}<small>%</small></span> <div class="gvs"><span v-if="b.status.fan?.en === false" class="gv readout" title="板温低于 40 °C:风扇断电(固件 ≥0.1.21)">停</span>
<span v-else class="gv readout">{{ (b.status.fan?.duty ?? 0) / 10 }}<small>%</small></span>
<span v-for="(r, i) in b.status.fan?.rpm || []" :key="i" class="gv sm readout" :class="{ bad: b.status.fan?.stall?.[i] }">{{ r ?? '—' }}<small>rpm</small></span></div></div> <span v-for="(r, i) in b.status.fan?.rpm || []" :key="i" class="gv sm readout" :class="{ bad: b.status.fan?.stall?.[i] }">{{ r ?? '—' }}<small>rpm</small></span></div></div>
<div class="g"><div class="gh"><span class="gk">巡检</span></div> <div class="g"><div class="gh"><span class="gk">巡检</span></div>
<div class="gvs"><span class="gv readout">{{ b.status.safety?.cycles }}<small>圈</small></span> <div class="gvs"><span class="gv readout">{{ b.status.safety?.cycles }}<small>圈</small></span>
@@ -58,6 +65,8 @@
<BoardChart :board="b.id" /> <BoardChart :board="b.id" />
</div> </div>
<EnergyCard :board="b.id" />
<div class="two"> <div class="two">
<section class="card"> <section class="card">
<h2 class="ttl">最近事件</h2> <h2 class="ttl">最近事件</h2>
@@ -86,6 +95,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import HistoryChart from '../components/HistoryChart.vue' import HistoryChart from '../components/HistoryChart.vue'
import BoardChart from '../components/BoardChart.vue' import BoardChart from '../components/BoardChart.vue'
import EnergyCard from '../components/EnergyCard.vue'
import OtaState from '../components/OtaState.vue' import OtaState from '../components/OtaState.vue'
import Topology from '../components/Topology.vue' import Topology from '../components/Topology.vue'
import { api, rspText } from '../api' import { api, rspText } from '../api'
@@ -101,6 +111,36 @@ const pw = computed(() => {
const rails = railsMw(s?.rails) const rails = railsMw(s?.rails)
return { rails, loss: input == null || rails == null ? null : input - rails } return { rails, loss: input == null || rails == null ? null : input - rails }
}) })
// CH224A(U36)协商到的 PD 档位。固件 ≥0.1.17 每秒读一次、放在遥测的 status.pd({st, ma},这一拍没读到是 null);
// 老固件只在启动时读一次、记成「PD 限功率」事件,后端取最近一条放进快照的 b.pd —— 没有实时值时退回它。
// 「已用」拿 12 V 母线上的输入功率比上限,没算 20 → 12 V 那级降压的损耗,实际从充电器取的还要多几个点。
const PD_TIP = 'CH224A(U36)向充电器请求的是固定 20 V 档;电流是充电器在这一档此刻最多肯给的(它广播的 Source Capabilities),' +
'固件按它定整板功率上限(读不到按 60 W)。只有 3 A:充电器本身 ≤65 W、线不是带 E-Marker 的 5 A 线,' +
'或者多口充电器别的口正在用、把这一口降了档 —— 三种情况受电端看到的都一样'
// 0x09 协议状态的各位(CH224A 手册):哪种快充协议握上了
const PD_PROTO = [[0x10, 'EPR'], [0x08, 'PD'], [0x04, 'QC3'], [0x02, 'QC2'], [0x01, 'BC1.2']]
const pd = computed(() => {
const ev = b.value?.pd
const live = b.value?.status?.pd
if (live) {
const ma = live.ma || null
return { mv: 20000, ma, limit_mw: ma ? 20 * ma : ev?.limit_mw ?? 60000, fallback: !ma, live: true,
proto: PD_PROTO.filter(([bit]) => live.st & bit).map(([, n]) => n).join('+') || '未握手' }
}
return ev ? { ...ev, live: false, missed: live === null } : null
})
const pdNote = computed(() => {
const p = pd.value
if (!p) return '还没收到启动事件'
if (p.live) return `实时 · ${p.proto}`
if (p.missed) return 'CH224A 这一拍没读到'
return p.fallback ? 'CH224A 读不到 · 按保守值' : `启动时读 · ${ago(p.ts)}`
})
const pdUsed = computed(() => {
const p = pd.value
const input = mW(b.value?.status?.in?.mv, b.value?.status?.in?.ua)
return !p?.limit_mw || input == null ? null : Math.round(input / p.limit_mw * 100)
})
// 固件 0.1.3 起才有 host 字段;老固件没有 ⇒ 显示「—」 // 固件 0.1.3 起才有 host 字段;老固件没有 ⇒ 显示「—」
const HOST = { true: '已连电脑', false: '未连电脑' } const HOST = { true: '已连电脑', false: '未连电脑' }
const fmtW = v => (v == null ? '—' : (v / 1000).toFixed(2)) const fmtW = v => (v == null ? '—' : (v / 1000).toFixed(2))
@@ -188,8 +228,8 @@ watch(() => props.id, refresh)
.meta div { display: flex; gap: 10px; align-items: center; min-width: 0; } .meta div { display: flex; gap: 10px; align-items: center; min-width: 0; }
.meta dt { color: var(--muted); width: 28px; flex-shrink: 0; } .meta dt { color: var(--muted); width: 28px; flex-shrink: 0; }
.meta dd { margin: 0; color: var(--ink-2); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .meta dd { margin: 0; color: var(--ink-2); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* 五个仪表格:凹进面板的小槽,标签在上、读数在下 */ /* 六个仪表格:凹进面板的小槽,标签在上、读数在下 */
.gauges { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; } .gauges { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }
.g { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay); padding: 10px 14px 12px; .g { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay); padding: 10px 14px 12px;
display: flex; flex-direction: column; gap: 6px; min-width: 0; } display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.gh { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; } .gh { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; }
@@ -210,7 +250,7 @@ watch(() => props.id, refresh)
.quiet { color: var(--muted); } .quiet { color: var(--muted); }
.charts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; } .charts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
.two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; } .two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
@media (max-width: 1500px) { .gauges { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 1700px) { .gauges { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 1200px) { .charts { grid-template-columns: 1fr; } @media (max-width: 1200px) { .charts { grid-template-columns: 1fr; }
.gauges { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .gauges { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 800px) { .two { grid-template-columns: 1fr; } } @media (max-width: 800px) { .two { grid-template-columns: 1fr; } }
+6 -4
查看文件
@@ -1,14 +1,15 @@
<template> <template>
<div class="rc"> <div class="rc">
<el-alert type="warning" :closable="false" show-icon <el-alert type="warning" :closable="false" show-icon
title="判据只看电流(服务器够不着手机的 adb);阈值还没有实测依据。自动恢复默认关,在板子页逐台打开。" /> title="判据只看电流(服务器够不着手机的 adb)。阈值 2026-10-06 按板 B 的实测定过一次(一次真卡死 + 一晚睡眠数据),还没开过一次自动恢复。默认关,在板子页逐台打开。" />
<div class="card"> <div class="card">
<div class="ttl">判据参数(全局)</div> <div class="ttl">判据参数(全局)</div>
<el-form :model="p" label-width="170px" size="small" class="form"> <el-form :model="p" label-width="170px" size="small" class="form">
<el-form-item label="判定窗口"><el-input-number v-model="p.window_s" :min="30" :max="3600" /> 秒</el-form-item> <el-form-item label="判定窗口"><el-input-number v-model="p.window_s" :min="30" :max="3600" /> 秒</el-form-item>
<el-form-item label="平线:电流摆幅小于"><el-input-number v-model="flatMa" :min="0.1" :max="200" :step="0.5" /> mA</el-form-item> <el-form-item label="平线:摆幅小于"><el-input-number v-model="flatMa" :min="1" :max="200" :step="1" /> mA(p95 − p05,不怕偶发尖峰)</el-form-item>
<el-form-item label="过低:电流一直低于"><el-input-number v-model="lowMa" :min="1" :max="500" /> mA</el-form-item> <el-form-item label="平线:且中位数不低于"><el-input-number v-model="flatMinMa" :min="1" :max="1000" :step="10" /> mA(睡着的手机平在 ~10 mA 是正常的)</el-form-item>
<el-form-item label="过低:电流一直低于"><el-input-number v-model="lowMa" :min="1" :max="500" /> mA(关机,或睡下去再没醒)</el-form-item>
<el-form-item label="动作后先等"><el-input-number v-model="p.settle_s" :min="10" :max="1800" /> 秒(不看这段电流)</el-form-item> <el-form-item label="动作后先等"><el-input-number v-model="p.settle_s" :min="10" :max="1800" /> 秒(不看这段电流)</el-form-item>
<el-form-item label="每台每小时最多"><el-input-number v-model="p.max_per_hour" :min="1" :max="10" /> 次(超了就停手等人)</el-form-item> <el-form-item label="每台每小时最多"><el-input-number v-model="p.max_per_hour" :min="1" :max="10" /> 次(超了就停手等人)</el-form-item>
<el-form-item label="一级:长按电源"><el-input-number v-model="p.soft_ms" :min="3000" :max="15000" :step="500" /> ms</el-form-item> <el-form-item label="一级:长按电源"><el-input-number v-model="p.soft_ms" :min="3000" :max="15000" :step="500" /> ms</el-form-item>
@@ -51,8 +52,9 @@ import { api } from '../api'
import { state, onMessage } from '../store' import { state, onMessage } from '../store'
import { fmtTime, RECOVERY_ZH, REASON_ZH } from '../util' import { fmtTime, RECOVERY_ZH, REASON_ZH } from '../util'
const p = reactive({ window_s: 300, flat_ua: 5000, low_ua: 30000, settle_s: 120, max_per_hour: 3, soft_ms: 10000 }) const p = reactive({ window_s: 300, flat_ua: 30000, flat_min_ua: 100000, low_ua: 30000, settle_s: 120, max_per_hour: 3, soft_ms: 10000 })
const flatMa = computed({ get: () => p.flat_ua / 1000, set: v => { p.flat_ua = Math.round(v * 1000) } }) const flatMa = computed({ get: () => p.flat_ua / 1000, set: v => { p.flat_ua = Math.round(v * 1000) } })
const flatMinMa = computed({ get: () => p.flat_min_ua / 1000, set: v => { p.flat_min_ua = Math.round(v * 1000) } })
const lowMa = computed({ get: () => p.low_ua / 1000, set: v => { p.low_ua = Math.round(v * 1000) } }) const lowMa = computed({ get: () => p.low_ua / 1000, set: v => { p.low_ua = Math.round(v * 1000) } })
const states = ref([]) const states = ref([])
const log = ref([]) const log = ref([])
+6
查看文件
@@ -51,6 +51,12 @@ function handle(m) {
case 'board': case 'board':
state.boards[m.board.id] = m.board state.boards[m.board.id] = m.board
break break
case 'phones_live': {
// 手机自己报的那一半(在线 / 温度,后端 fleet.py);live 里没有的路 = 读不到,清掉
const b = state.boards[m.board]
if (b) for (const p of b.phones || []) p.live = m.live[p.rail] ?? null
break
}
case 'tele': { case 'tele': {
const b = state.boards[m.board] const b = state.boards[m.board]
if (b) { b.status = m.status; b.last_tele = m.ts; b.stale = false; b.online = true } if (b) { b.status = m.status; b.last_tele = m.ts; b.stale = false; b.online = true }
+1 -1
查看文件
@@ -516,7 +516,7 @@ R-3 依赖 R-1+R-2;R-4 依赖 R-3;R-5 / R-6 随时可插。
| 包 | 内容 | 触发条件 | | 包 | 内容 | 触发条件 |
|---|---|---| |---|---|---|
| **C1** | WiFi 配网流程(USB 下发凭据写 NVS,IF-19) | A14 之后 | | **C1** | WiFi 配网流程(USB 下发凭据写 NVS,IF-19;0.1.19 加配网热点 + 网页,`prov.h`) | A14 之后 |
| **C2** | ✅ OTA:双分区 + 失败回滚(2026-09-27,见需求 §10.8「C2 定稿的 OTA」) | B9 之后 | | **C2** | ✅ OTA:双分区 + 失败回滚(2026-09-27,见需求 §10.8「C2 定稿的 OTA」) | B9 之后 |
| **C3** | 出厂标定流程(两点线性标定存 NVS) | B2 之后 | | **C3** | 出厂标定流程(两点线性标定存 NVS) | B2 之后 |
| **C4** | 文档回填:实测值填回 `需求文档.md` §12.2,坑写进 §10 | 随时 | | **C4** | 文档回填:实测值填回 `需求文档.md` §12.2,坑写进 §10 | 随时 |
+16 -3
查看文件
@@ -110,7 +110,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU
| **FR-7** | 对外只用**一根 USB 线**,五路下行由板载 Hub 分 | 必须 | §15.1 | | **FR-7** | 对外只用**一根 USB 线**,五路下行由板载 Hub 分 | 必须 | §15.1 |
| **FR-8** | 每路可实时读取**输出电压与电流**;整板可读取**输入电压与电流** | 必须 | §8.2 | | **FR-8** | 每路可实时读取**输出电压与电流**;整板可读取**输入电压与电流** | 必须 | §8.2 |
| **FR-9** | 上报每路 eFuse 故障、电源好(PG)等状态 | 必须 | §8.2 | | **FR-9** | 上报每路 eFuse 故障、电源好(PG)等状态 | 必须 | §8.2 |
| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」 | 必须 | §22.6 | | **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」。**固件 0.1.21(2026-10-06 用户定)**:板温(两路 NTC 取高)低于 40 ℃ 风扇断电(≥40 ℃ 开、<38 ℃ 关),40→65 ℃ 按 30 %→100 % 线性调速;温度读不到或上位机给了下限时一律开着。`fan` 命令的下限只抬不压,可带 `sec` 到点自动交回(rack 网页与 AppTask「机架」页都能发) | 必须 | §22.6 |
| **FR-11** | 记录过流 / 故障事件(哪一路、何时、多大电流) | 高 | §8.3 | | **FR-11** | 记录过流 / 故障事件(哪一路、何时、多大电流) | 高 | §8.3 |
| **FR-12** | 模拟电量曲线,避免系统因「电量永远 100%」触发异常逻辑 | 中(可选) | §8.3 | | **FR-12** | 模拟电量曲线,避免系统因「电量永远 100%」触发异常逻辑 | 中(可选) | §8.3 |
| **FR-13** | 板级温度监控,超阈值先降流再关路 | 高 | §8.3 | | **FR-13** | 板级温度监控,超阈值先降流再关路 | 高 | §8.3 |
@@ -234,7 +234,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU
| **IF-12** | 上游 USB | 载板 USB-C(UFP),一根线接电脑 | | **IF-12** | 上游 USB | 载板 USB-C(UFP),一根线接电脑 |
| **IF-13** | 调试 USB | 下板 USB-C,ESP32-S3 原生 USB-Serial-JTAG;**VBUS 故意不接**(物理上杜绝反灌 3V3)。**同一个口兼作 SW-12 的兜底命令链路**(原生 USB-Serial-JTAG 本身就是 CDC-ACM,不影响 esptool 下载),分帧要求见 §10.8 | | **IF-13** | 调试 USB | 下板 USB-C,ESP32-S3 原生 USB-Serial-JTAG;**VBUS 故意不接**(物理上杜绝反灌 3V3)。**同一个口兼作 SW-12 的兜底命令链路**(原生 USB-Serial-JTAG 本身就是 CDC-ACM,不影响 esptool 下载),分帧要求见 §10.8 |
| **IF-14** | 风扇 | 5 × KF2510-3A(+12 V / 回流 / TACH) | | **IF-14** | 风扇 | 5 × KF2510-3A(+12 V / 回流 / TACH) |
| **IF-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时先用 IF-13 的 USB 链路配网 | | **IF-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时用**配网热点**(固件 0.1.19,2026-10-06:没配网 / 连续 60 s 连不上 WiFi 时板子自己开 SoftAP `PhoneRail-<id 末 6 位>`,手机连上在 192.168.4.1 网页里填)或 IF-13 的 USB 链路配网 |
> ⚠️ **IF-12 与 IF-13 是两个长得一样的 USB-C,插错的现象是「adb 整排不见」**, > ⚠️ **IF-12 与 IF-13 是两个长得一样的 USB-C,插错的现象是「adb 整排不见」**,
> 很难联想到是插错口。**丝印必须写清楚**,建议用不同颜色座子或在外壳上做标记。 > 很难联想到是插错口。**丝印必须写清楚**,建议用不同颜色座子或在外壳上做标记。
@@ -765,6 +765,7 @@ ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U
> ⚠️ **别把 WiFi 凭据和 MQTT 地址编译进固件。** 放 NVS,用 USB 链路配置 —— > ⚠️ **别把 WiFi 凭据和 MQTT 地址编译进固件。** 放 NVS,用 USB 链路配置 ——
> 这正好是兜底链路的第二个用途:新板子上架时先插 USB 配网。 > 这正好是兜底链路的第二个用途:新板子上架时先插 USB 配网。
> (0.1.19 起多一条不用插线的路:配网热点,见下面「配网热点」那一条。)
命令语义与链路无关,两条链路完全一致: 命令语义与链路无关,两条链路完全一致:
@@ -842,8 +843,20 @@ base = `phonerail/<id>`,id = 板子 STA MAC 的 12 位小写十六进制(板
| `<base>/tele` | 板 → 服务器 | 0 | 否 | 周期遥测 = 一条 status 回应(无 seq),默认 1 s(`tele_ms` 200~60000) | | `<base>/tele` | 板 → 服务器 | 0 | 否 | 周期遥测 = 一条 status 回应(无 seq),默认 1 s(`tele_ms` 200~60000) |
| `<base>/state` | 板 → 服务器 | 1 | 是 | `{"online":true,"fw":..,"ip":..}`;掉线由遗嘱改成 `{"online":false}`(keepalive 15 s) | | `<base>/state` | 板 → 服务器 | 1 | 是 | `{"online":true,"fw":..,"ip":..}`;掉线由遗嘱改成 `{"online":false}`(keepalive 15 s) |
- **配网(C1)**:`wifi`(ssid / pass)与 `mqtt`(uri / user / pass / tele_ms)两条命令,存 NVS,**只收 USB**(经 WiFi 来回 `link` 并记事件)。 - **配网(C1)**:`wifi`(ssid / pass)与 `mqtt`(uri / user / pass / tele_ms)两条命令,存 NVS,**只收 USB 与配网热点**(经 MQTT 来回 `link` 并记事件)。
`net` 查网络状态,**永不回显密码**(只报 `pass_set` / `mpass_set`);含 `wifi_reason`(最近一次断开原因码)。 `net` 查网络状态,**永不回显密码**(只报 `pass_set` / `mpass_set`);含 `wifi_reason`(最近一次断开原因码)。
`mqtt` 可带 `"keep_pass":true` = 口令沿用已存的(与 `pass` 同时给回 `arg`)。
- **配网热点(0.1.19,2026-10-06 用户定「SoftAP + 网页,连 WiFi 失败后默认回到配网模式」)**,`firmware/main/prov.h`:
- 开:一组 WiFi 都没配 ⇒ 立刻;配了但**连续 60 s 没拿到地址** ⇒ 开。开着期间 STA 照常在后台重连,**热点不设超时**。
- 关:STA 连上 ⇒ 热点上没人立刻关,有人再留 60 s(让网页看到结果)。
- 热点 `PhoneRail-<id 末 6 位>`,WPA2,口令**所有板子固定 `88888888`**(0.1.20,2026-10-06 用户定;0.1.19 曾是每板随机),
`net` 回应的 `"ap":{"on","why","sta","ssid","pass"}` 里也报(两条链路都报)。
- 网页 `http://192.168.4.1/`(DNS 劫持 ⇒ 手机连上自动弹出):看状态、扫描、加 / 删 WiFi、改 MQTT。
提交的内容拼成 `wifi` / `mqtt` 命令走**同一个命令处理器**(链路号 3 `CMD_LINK_AP`),这条链路拼不出别的命令;
网页同样不回显任何已存口令。只应答热点那一侧来的连接。
- 有人在网页上操作时 STA 重连间隔不短于 20 s(每次重连要离开热点信道扫一圈);只放慢、不停。
- **热点不碰任何输出**(同「失联不动作」)。代价:路由器短暂重启也会让热点冒出来约一分钟;
口令是公开的固定值 ⇒ 在无线范围内的任何人,都能在板子没网时把它指向别的 broker(等同拿到这块板)。
- uri 收 `mqtts://`(TLS,CA 编在固件:`firmware/main/certs/evse_ca.pem`)或 `mqtt://`(只该用在局域网)。 - uri 收 `mqtts://`(TLS,CA 编在固件:`firmware/main/certs/evse_ca.pem`)或 `mqtt://`(只该用在局域网)。
- **broker = home 服务器现有 mosquitto 的 8883(TLS)**,与 evse 共用:板子用户名 = id,ACL 用 `pattern … phonerail/%u/…` - **broker = home 服务器现有 mosquitto 的 8883(TLS)**,与 evse 共用:板子用户名 = id,ACL 用 `pattern … phonerail/%u/…`
只许碰自己的四个 topic;后端用户 `phonerail-backend` 可读写 `phonerail/#`。口令在服务器 `/root/phonerail/mqtt.env`(仅 root)。 只许碰自己的四个 topic;后端用户 `phonerail-backend` 可读写 `phonerail/#`。口令在服务器 `/root/phonerail/mqtt.env`(仅 root)。