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 把 `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/chgmode.c
${FW_ROOT}/main/hubctl.c
${FW_ROOT}/main/pdmon.c
${FW_ROOT}/main/t_safety.c
${FW_ROOT}/main/t_actuator.c
${FW_ROOT}/main/t_thermal.c
@@ -42,6 +43,7 @@ set(PHONERAIL_CORE_SRCS
${FW_ROOT}/main/cmd_net.c
${FW_ROOT}/main/netcfg.c
${FW_ROOT}/main/mlink.c
${FW_ROOT}/main/prov.c
${FW_ROOT}/main/bringup.c
${FW_ROOT}/main/ntc.c
${FW_ROOT}/main/ota.c
+7 -3
查看文件
@@ -1,5 +1,5 @@
# ESP-IDF 侧的 main 组件。
# app_main.c / ulink_esp.c / mlink_esp.c / thermal_esp.c / ota_esp.c / warm_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过
# app_main.c / ulink_esp.c / mlink_esp.c / prov_esp.c / thermal_esp.c / ota_esp.c / warm_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过
# (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。
idf_component_register(
SRCS "app_main.c"
@@ -14,6 +14,7 @@ idf_component_register(
"tasks.c"
"chgmode.c"
"hubctl.c"
"pdmon.c"
"t_safety.c"
"t_actuator.c"
"t_thermal.c"
@@ -26,6 +27,8 @@ idf_component_register(
"cmd_net.c"
"netcfg.c"
"mlink.c"
"prov.c"
"prov_esp.c"
"bringup.c"
"ntc.c"
"thermal_esp.c"
@@ -33,11 +36,12 @@ idf_component_register(
"ota_esp.c"
"warm_esp.c"
INCLUDE_DIRS "."
EMBED_TXTFILES "certs/evse_ca.pem"
EMBED_TXTFILES "certs/evse_ca.pem" "prov.html"
REQUIRES phonerail_hal drv esp_driver_usb_serial_jtag
esp_wifi esp_netif esp_event esp_timer nvs_flash mqtt
esp_adc esp_driver_pcnt
app_update esp_http_client mbedtls)
app_update esp_http_client mbedtls
esp_http_server lwip)
# warm_esp.c 的能力标记(.rodata_custom_desc,镜像第 288 字节)没有代码引用它,
# 不加 -u 会被 --gc-sections 丢掉 —— 那样每个新固件都会被当成「不认接管」,EN 永远不锁(安全,但功能白做)。
+8
查看文件
@@ -33,6 +33,7 @@
#include "nvs.h"
#include "nvs_flash.h"
#include "ota.h"
#include "pdmon.h"
#include "ota_esp.h"
#include "warm_esp.h"
#include "ulink_esp.h"
@@ -151,6 +152,12 @@ static void task_safety(void *arg)
for (;;) {
(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 %」,
首板上电时要拿逻辑分析仪复核这个数(§10.3 末尾那句)。 */
if ((snapp->cycles % 250u) == 0u) {
@@ -462,6 +469,7 @@ void app_main(void)
abort();
}
log_report(&boot);
pdmon_init(boot.pd_ma);
if (adopt != 0) {
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 失联)",
+7 -4
查看文件
@@ -76,17 +76,20 @@ typedef enum {
CMD_ERR_STATE = 8, /* 当前状态下不许(例:eFuse 关着时开 VBUS) */
CMD_ERR_IO = 9, /* 下发到器件失败(I²C) */
CMD_ERR_OVERFLOW = 10, /* 回应装不进缓冲 —— 本模块的 bug,不该出现 */
CMD_ERR_LINK = 11, /* 这条命令不许从这条链路来(经 WiFi 改 WiFi 配置) */
CMD_ERR_LINK = 11, /* 这条命令不许从这条链路来(经 MQTT 改 WiFi 配置) */
CMD_ERR_COUNT,
} cmd_err_t;
/* 哪条链路来的。进 `LOG_EV_REBOOT` 的 arg 高 4 位。§10.8:两条链路的命令语义完全一致,
**唯一的例外**是 `wifi` / `mqtt` 两条配置命令只收 USB(netcfg.h:经 WiFi 改 WiFi 配置,
一次手误就把唯一的远程通道弄断了)。 */
**唯一的例外**是 `wifi` / `mqtt` 两条配置命令不收 MQTT(netcfg.h:经 WiFi 改 WiFi 配置,
一次手误就把唯一的远程通道弄断了)。
`CMD_LINK_AP`(0.1.19)= 配网热点的网页(prov.h):人就在板子旁边,和 USB 一样收配置命令;
这条链路上只会出现 `wifi` / `mqtt` / `net` 三条(prov_esp.c 只拼这三条)。 */
typedef enum {
CMD_LINK_TEST = 0,
CMD_LINK_USB = 1,
CMD_LINK_WIFI = 2,
CMD_LINK_WIFI = 2, /* MQTT */
CMD_LINK_AP = 3,
} cmd_link_t;
/* `LOG_EV_REBOOT` 的 arg:bit0 = 1 硬(断电)/ 0 软(长按电源键),bit4~7 = 链路。 */
+14 -1
查看文件
@@ -401,8 +401,21 @@ cmd_err_t do_fan(ctx_t *c)
if (duty < 0 || duty > 1000) {
return reject(c, CMD_ERR_RANGE, duty);
}
/* `sec`(0.1.21,可选):这个下限保持多少秒,到期自动回 0。不给 / 0 = 一直保持(老行为)。
定时续期的上位机(AppTask)用它:它挂了,风扇不会永远停在最后那个转速上。 */
int64_t sec = 0;
const int gs = cj_get_int(c->o, "sec", &sec);
if (gs < 0) {
return CMD_ERR_ARG;
}
if (sec < 0 || sec > T_THERMAL_FLOOR_TTL_MAX_S) {
return reject(c, CMD_ERR_RANGE, sec);
}
/* 手动值只是**下限**:温度曲线要得更高时照曲线来(t_thermal.h)。0 = 交回自动。 */
t_thermal_set_floor((uint16_t)duty);
t_thermal_set_floor_for((uint16_t)duty, (uint32_t)sec * 1000u);
cj_w(c->w, ",\"floor\":%u", (unsigned)t_thermal_floor());
if (gs == 1) {
cj_w(c->w, ",\"sec\":%lu", (unsigned long)((t_thermal_floor_left_ms() + 999u) / 1000u));
}
return CMD_OK;
}
+28 -6
查看文件
@@ -1,4 +1,4 @@
/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB**),`net` 读状态;
/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB 与配网热点**),`net` 读状态;
外加 C2 的 `ota`(下载固件,两条链路都收)。
`net` 的回应里**永远没有密码**,只有 `pass_set` / `mpass_set`(netcfg.h「密码永不外泄」)。 */
@@ -7,9 +7,11 @@
#include "cmd_priv.h"
#include "netcfg.h"
#include "ota.h"
#include "prov.h"
/* 配置命令只收 USB(cmd.h 链路那一段)。宿主机测试用的 CMD_LINK_TEST 视同 USB。 */
static bool usb_only_ok(const ctx_t *c) { return c->link != CMD_LINK_WIFI; }
/* 配置命令不收 MQTT(cmd.h 链路那一段):USB 与配网热点都是「人在板子旁边」。
宿主机测试用的 CMD_LINK_TEST 视同 USB。 */
static bool local_ok(const ctx_t *c) { return c->link != CMD_LINK_WIFI; }
static cmd_err_t get_str(const ctx_t *c, const char *key, bool required, const char **out)
{
@@ -27,7 +29,7 @@ static cmd_err_t get_str(const ctx_t *c, const char *key, bool required, const c
cmd_err_t do_wifi(ctx_t *c)
{
if (!usb_only_ok(c)) {
if (!local_ok(c)) {
c->bad = CMD_WIFI;
return CMD_ERR_LINK;
}
@@ -69,7 +71,7 @@ cmd_err_t do_wifi(ctx_t *c)
cmd_err_t do_mqtt(ctx_t *c)
{
if (!usb_only_ok(c)) {
if (!local_ok(c)) {
c->bad = CMD_MQTT;
return CMD_ERR_LINK;
}
@@ -77,11 +79,23 @@ cmd_err_t do_mqtt(ctx_t *c)
const char *user = NULL;
const char *mpass = NULL;
int64_t tele = 0;
bool keep = false;
const int gt = cj_get_int(c->o, "tele_ms", &tele);
if (get_str(c, "uri", true, &uri) != CMD_OK || get_str(c, "user", false, &user) != CMD_OK ||
get_str(c, "pass", false, &mpass) != CMD_OK || gt < 0) {
get_str(c, "pass", false, &mpass) != CMD_OK || gt < 0 ||
cj_get_bool(c->o, "keep_pass", &keep) < 0) {
return CMD_ERR_ARG;
}
/* `"keep_pass":true`(0.1.19)= 口令沿用已存的那份:配网网页看不到旧口令(永不回显),
只想改地址时不该逼人重输。同时又给了 `pass` 是自相矛盾,拒。 */
static netcfg_t cur; /* ≈560 B,不放栈上(只有执行器任务进来) */
if (keep) {
if (mpass[0] != '\0') {
return CMD_ERR_ARG;
}
netcfg_get(&cur);
mpass = cur.mpass;
}
if (tele < 0 || tele > NETCFG_TELE_MAX_MS ||
!netcfg_mqtt_ok(uri, user, mpass, (uint32_t)tele)) {
c->bad = tele;
@@ -139,6 +153,14 @@ cmd_err_t do_net(ctx_t *c)
cfg.mpass[0] ? "true" : "false", (unsigned long)cfg.tele_ms,
(unsigned long)st.wifi_drops, (unsigned long)st.mqtt_drops,
(unsigned long)st.tele_sent, (unsigned long)st.tele_skipped);
/* 配网热点(prov.h)。`pass` 是**热点自己的**口令,不是上面那两个「永不外泄」的:
人得先知道它才连得上热点,而读到它的前提是已经在 USB / MQTT 上了。 */
cj_w(w, ",\"ap\":{\"on\":%s,\"why\":\"%s\",\"sta\":%u,\"ssid\":", st.ap_on ? "true" : "false",
prov_why_str((prov_why_t)st.ap_why), (unsigned)st.ap_sta);
w_str(w, st.ap_ssid);
cj_w(w, ",\"pass\":");
w_str(w, st.ap_pass);
cj_w(w, "}");
return CMD_OK;
}
+18 -2
查看文件
@@ -15,6 +15,7 @@
#include "hubctl.h"
#include "ota.h"
#include "pca9555.h"
#include "pdmon.h"
#include "phonerail.h"
#include "rail.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, "],\"fan\":{\"duty\":%u,\"floor\":%u,\"en\":%s,\"rpm\":[", (unsigned)t->duty,
(unsigned)t_thermal_floor(), b(t->fan_en));
cj_w(w, "],\"fan\":{\"duty\":%u,\"floor\":%u,\"left\":%lu,\"en\":%s,\"rpm\":[", (unsigned)t->duty,
(unsigned)t_thermal_floor(), (unsigned long)((t_thermal_floor_left_ms() + 999u) / 1000u),
b(t->fan_en));
for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) {
if (t->fan[i].rpm_valid) {
cj_w(w, "%s%lu", i ? "," : "", (unsigned long)t->fan[i].rpm);
@@ -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));
}
/* 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)
{
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");
}
cmd_report_hub(w);
pd_obj(w);
if (s != NULL) {
if (s->input_ok) {
cj_w(w, ",\"in\":{\"mv\":%ld,\"ua\":%ld}", (long)s->input.bus_mv,
+64 -5
查看文件
@@ -18,7 +18,9 @@
---- 失联不动作(§10.2 / §10.8) -----------------------------------------
WiFi 断、broker 断,本文件只做一件事:**重连**(WiFi 指数退避,MQTT 由 esp-mqtt 每 10 s 重试)。
没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。 */
没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。
0.1.19 起多一件:WiFi 连续 60 s 连不上就**开配网热点**(prov.h),STA 照常在后台重连,
连上了热点自己关。热点同样不碰输出。 */
#include <string.h>
#include "cmd_queue.h"
@@ -39,6 +41,8 @@
#include "nvs.h"
#include "nvs_flash.h"
#include "phonerail.h"
#include "prov.h"
#include "prov_esp.h"
#include "tasks.h"
#include "ulink.h"
@@ -91,6 +95,9 @@ static uint32_t s_attempt;
static mlink_tele_t s_tele;
static volatile bool s_tele_inflight;
static bool s_up;
static volatile bool s_slow; /* 有人在配网网页上:重连间隔不短于 PROV_SLOW_MS(prov.h) */
static uint32_t s_ap_try_ms; /* 上次开 / 关热点是什么时候(没成功时别每拍都试) */
static bool s_ap_tried;
/* 发件队列(见文件头):执行器 / esp-mqtt 任务往里拷,t_link 取出来交给 esp-mqtt。
深度 4 × 1.5 KB:遥测 1 s 一条、回应偶尔一条,t_link 至少每 100 ms 来取一次;
@@ -343,6 +350,16 @@ static void wifi_set_sta(uint8_t idx)
ST_EDIT(s_st.wifi_idx = idx);
}
/* 发起一次连接。驱动当场拒了(配网网页正在扫描时会)就不会有断开事件来接着排下一次 ——
重连链在这里断掉 = 板子永远不回网,所以自己排一次。 */
static void sta_connect(void)
{
if (esp_wifi_connect() != ESP_OK) {
esp_timer_stop(s_retry);
esp_timer_start_once(s_retry, 3000u * 1000u);
}
}
static void retry_cb(void *arg)
{
(void)arg;
@@ -350,7 +367,7 @@ static void retry_cb(void *arg)
if (s_st.wifi != NET_UP && n > 0) {
const uint8_t i = s_idx < n ? s_idx : 0;
wifi_set_sta(i);
esp_wifi_connect();
sta_connect();
}
}
@@ -374,7 +391,10 @@ static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data)
if (!was_up && n > 1) {
s_idx = (uint8_t)((s_idx + 1) % n);
}
const uint32_t ms = mlink_backoff_ms(s_attempt++ / (uint32_t)n);
uint32_t ms = mlink_backoff_ms(s_attempt++ / (uint32_t)n);
if (s_slow && ms < PROV_SLOW_MS) {
ms = PROV_SLOW_MS; /* 每试一次热点都要离开信道扫一圈,试密了网页就卡(prov.h) */
}
ESP_LOGW(TAG, "WiFi 断开,原因 %u,%u ms 后连「%s」", (unsigned)d->reason, (unsigned)ms,
s_cur.wifi[s_idx < n ? s_idx : 0].ssid);
esp_timer_stop(s_retry);
@@ -407,10 +427,33 @@ static void wifi_apply(void)
}
wifi_set_sta(s_idx);
ST_EDIT(s_st.wifi = NET_CONNECTING);
esp_wifi_connect();
sta_connect();
ESP_LOGI(TAG, "连 WiFi「%s」(%u/%d)", s_cur.wifi[s_idx].ssid, (unsigned)s_idx + 1, n); /* 只打 SSID,不打口令 */
}
/* ---- 配网热点(t_link 里调):判据在 prov.c,开关在 prov_esp.c ------------- */
static void prov_poll(void)
{
const uint32_t now = hal_time_now_ms();
const prov_in_t in = { .n_wifi = netcfg_wifi_count(&s_cur), .wifi_up = s_st.wifi == NET_UP,
.ap_sta = prov_esp_sta_count(), .touched = prov_esp_take_touch() };
const prov_out_t o = prov_step(&in, now);
s_slow = o.hold;
if (o.ap != prov_esp_on() && (!s_ap_tried || (uint32_t)(now - s_ap_try_ms) >= 10000u)) {
s_ap_tried = true;
s_ap_try_ms = now;
prov_esp_set(o.ap); /* 没成功就 10 s 后再来,不是每拍都试 */
}
const bool on = prov_esp_on();
if (on == o.ap) {
s_ap_tried = false;
}
if (on != s_st.ap_on || (uint8_t)in.ap_sta != s_st.ap_sta || (uint8_t)o.why != s_st.ap_why) {
ST_EDIT(s_st.ap_on = on; s_st.ap_sta = (uint8_t)in.ap_sta; s_st.ap_why = (uint8_t)o.why);
}
}
/* ---- 对外 ---------------------------------------------------------------- */
hal_err_t mlink_esp_init(void)
@@ -444,6 +487,7 @@ hal_err_t mlink_esp_init(void)
ESP_LOGE(TAG, "网络栈起不来 —— WiFi 链路不可用");
return HAL_ERR_STATE;
}
esp_netif_t *ap_netif = esp_netif_create_default_wifi_ap(); /* 配网热点用;平时模式是 STA,它闲着 */
const wifi_init_config_t wi = WIFI_INIT_CONFIG_DEFAULT();
if (esp_wifi_init(&wi) != ESP_OK) {
ESP_LOGE(TAG, "WiFi 驱动起不来 —— WiFi 链路不可用");
@@ -454,10 +498,14 @@ hal_err_t mlink_esp_init(void)
esp_wifi_set_storage(WIFI_STORAGE_RAM); /* 凭据只存我们自己的那份,驱动别再存一份 */
esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_start();
prov_init(hal_time_now_ms());
prov_esp_init(ap_netif, mlink_id());
ST_EDIT(snprintf(s_st.ap_ssid, sizeof(s_st.ap_ssid), "%s", prov_esp_ssid());
snprintf(s_st.ap_pass, sizeof(s_st.ap_pass), "%s", prov_esp_pass()));
s_up = true;
ESP_LOGI(TAG, "板子 ID %s,topic %s/…", mlink_id(), mlink_topic(MLINK_T_CMD));
if (!have) {
ESP_LOGW(TAG, "还没配网:经 USB 发 wifi / mqtt 两条命令(tools/prlink.py)");
ESP_LOGW(TAG, "还没配网:连热点「%s」开网页,或经 USB 发 wifi / mqtt(tools/prlink.py)", prov_esp_ssid());
}
wifi_apply();
return HAL_OK;
@@ -481,14 +529,25 @@ void mlink_esp_poll(void)
if (!(wifi_same && mqtt_same)) {
mqtt_stop();
}
/* 没连着的时候:刚加 / 刚改口令的那一组先试 —— 人正等着看它连不连得上(配网网页尤其)。 */
uint8_t first = s_idx;
for (int i = 0; i < netcfg_wifi_count(&next); i++) {
const int k = netcfg_wifi_find(&s_cur, next.wifi[i].ssid);
if (k < 0 || strcmp(s_cur.wifi[k].pass, next.wifi[i].pass) != 0) {
first = (uint8_t)i;
break;
}
}
s_cur = next;
if (wifi_same) {
s_idx = (uint8_t)j;
ST_EDIT(s_st.wifi_idx = s_idx);
} else {
s_idx = first;
wifi_apply();
}
}
prov_poll();
/* MQTT 的生命周期只在这里管:WiFi 有地址、配了 broker、还没建客户端 ⇒ 建。
之后掉线重连由 esp-mqtt 自己做(network.reconnect_timeout_ms,默认 10 s)。 */
if (s_mqtt == NULL && s_st.wifi == NET_UP && s_cur.uri[0] != '\0') {
+2 -2
查看文件
@@ -6,10 +6,10 @@
/* NVS → 配置 → WiFi 驱动 → 按配置连网。要在命令队列建好之后、任务起来之前调。
失败不 abort:WiFi 链路不可用,USB 链路与五路供电照常(§10.2)。
没配过网时只起驱动、不连,等 USB 下发 `wifi` / `mqtt`。 */
没配过网时只起驱动、不连,开配网热点(prov.h),等网页或 USB 下发 `wifi` / `mqtt`。 */
hal_err_t mlink_esp_init(void);
/* 链路任务每圈调一次(≤100 ms 一次):应用配置变更、按需建 MQTT 客户端、推遥测。 */
/* 链路任务每圈调一次(≤100 ms 一次):应用配置变更、开关配网热点、按需建 MQTT 客户端、推遥测。 */
void mlink_esp_poll(void);
#endif /* MLINK_ESP_H */
+9 -2
查看文件
@@ -4,8 +4,9 @@
USB 链路配网」。所以:
· 配置由两条命令写入:`wifi`(SSID / 密码,**最多存 NETCFG_WIFI_N 组**,0.1.9)与
`mqtt`(broker 地址 / 账号 / 遥测周期),
**只收 USB 链路来的**(cmd.c 回 `link`)—— 经 WiFi 改 WiFi 配置,一次手误就把
唯一的远程通道弄断了,而那时只能拆机插 USB;
**不收 MQTT 来的**(cmd.c 回 `link`)—— 经 WiFi 改 WiFi 配置,一次手误就把
唯一的远程通道弄断了。收的是两条「人在板子旁边」的链路:USB,和 0.1.19 起
连不上 WiFi 时自己开的配网热点(prov.h —— 所以弄断了也不用再拆机插 USB);
· 本模块只管「校验 + 在内存里持有一份 + 调存储后端 + 通知网络那一侧」,
NVS 与 WiFi/MQTT 都在 ESP 胶水里(mlink_esp.c),这一半宿主机可测。
@@ -110,6 +111,12 @@ typedef struct {
202 = 认证失败,15 = 四次握手超时 ≈ 口令错),0 = 没断过 */
uint8_t wifi_idx; /* 正在连 / 已连上的是表里第几组(net 回应的 ssid 取它) */
char id[13]; /* 板子 ID = STA MAC 的 12 位小写十六进制,topic 用它 */
/* 配网热点(prov.h,0.1.19)。ap_pass 是热点自己的口令,`net` 会报(见 cmd_net.c)。 */
bool ap_on;
uint8_t ap_why; /* prov_why_t */
uint8_t ap_sta; /* 热点上连着几台 */
char ap_ssid[20];
char ap_pass[12];
} net_status_t;
void net_status_set(const net_status_t *st);
+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_FW_VERSION "0.1.16-c5"
#define PHONERAIL_FW_VERSION "0.1.21-c5"
/* 通道数。五路直供,见设计文档 §2。 */
#define PHONERAIL_RAIL_COUNT 5
+206
查看文件
@@ -0,0 +1,206 @@
/* 配网热点的平台无关部分。判据表与表单格式在 prov.h。 */
#include "prov.h"
#include <stdio.h>
#include <string.h>
#include "cmd.h"
#include "netcfg.h"
static bool s_ap;
static bool s_was_up;
static uint32_t s_since; /* STA 上一次「有地址 ↔ 没地址」翻转的时刻(启动算一次) */
static bool s_aged; /* 翻转之后已经过了门限(过了就锁住:32 位时钟 49.7 天回绕,放着不管会「变年轻」) */
static bool s_touch; /* hold 窗口还开着(过期即清,同上) */
static uint32_t s_touch_at;
static int s_prev_sta;
void prov_init(uint32_t now)
{
s_ap = false;
s_was_up = false;
s_since = now;
s_aged = false;
s_touch = false;
s_touch_at = now;
s_prev_sta = 0;
}
prov_out_t prov_step(const prov_in_t *in, uint32_t now)
{
if (in->wifi_up != s_was_up) {
s_was_up = in->wifi_up;
s_since = now;
s_aged = false;
}
if (!s_aged && (uint32_t)(now - s_since) >= (in->wifi_up ? PROV_LINGER_MS : PROV_FAIL_MS)) {
s_aged = true;
}
if (in->touched || in->ap_sta > s_prev_sta) { /* 有人刚连上热点也算一次 */
s_touch = true;
s_touch_at = now;
}
s_prev_sta = in->ap_sta;
if (s_touch && (uint32_t)(now - s_touch_at) >= PROV_HOLD_MS) {
s_touch = false;
}
prov_out_t o = { .ap = false, .hold = false, .why = PROV_WHY_OFF };
if (in->wifi_up) {
if (s_ap && in->ap_sta > 0 && !s_aged) {
o.ap = true;
o.why = PROV_WHY_LINGER;
}
} else if (in->n_wifi <= 0) {
o.ap = true;
o.why = PROV_WHY_NOCFG;
} else if (s_aged || s_ap) {
/* `|| s_ap`:开着就不因为「刚配了一组、还没满 60 s」而关掉 —— 那正是人在网页上点完保存的那一刻。 */
o.ap = true;
o.why = PROV_WHY_FAIL;
}
o.hold = o.ap && !in->wifi_up && in->ap_sta > 0 && s_touch;
s_ap = o.ap;
return o;
}
const char *prov_why_str(prov_why_t w)
{
switch (w) {
case PROV_WHY_OFF: return "off";
case PROV_WHY_NOCFG: return "nocfg";
case PROV_WHY_FAIL: return "fail";
case PROV_WHY_LINGER: return "linger";
}
return "?";
}
/* ---- 名字与口令 ------------------------------------------------------------ */
void prov_ssid(const char *id, char out[PROV_SSID_MAX])
{
const size_t n = strlen(id);
snprintf(out, PROV_SSID_MAX, "PhoneRail-%s", n > 6 ? id + n - 6 : id);
}
/* ---- 表单 → 命令行 --------------------------------------------------------- */
#define FIELD_MAX 64 /* = CMD_JSON_STR_MAX − 1:再长解析器也不收 */
#define FIELDS 5
/* 把正文按 \n 切成至多 FIELDS 段(行尾的 \r 去掉),拷进 f[]。返回段数;−1 = 太长 / 段太多,−2 = 有坏字符。 */
static int split(const char *body, char f[FIELDS][FIELD_MAX + 1])
{
int n = 0;
size_t len = 0;
f[0][0] = '\0';
for (const char *p = body;; p++) {
if (*p == '\n' || *p == '\0') {
if (len > 0 && f[n][len - 1] == '\r') {
len--;
}
f[n][len] = '\0';
for (size_t i = 0; i < len; i++) {
const unsigned char c = (unsigned char)f[n][i];
if (c < 0x20 || c == 0x7F || c == '"' || c == '\\') {
return -2;
}
}
n++;
if (*p == '\0') {
return n;
}
if (n >= FIELDS) {
return -1;
}
len = 0;
continue;
}
if (len >= FIELD_MAX) {
return -1;
}
f[n][len++] = *p;
}
}
size_t prov_build_line(const char *body, uint32_t seq, char *out, size_t cap, const char **err)
{
static const char *dummy;
if (err == NULL) {
err = &dummy;
}
*err = "bad_form";
if (body == NULL || out == NULL || cap == 0 || seq == 0) {
return 0;
}
char f[FIELDS][FIELD_MAX + 1];
const int n = split(body, f);
if (n < 0) {
*err = (n == -2) ? "bad_char" : "too_long";
return 0;
}
for (int i = n; i < FIELDS; i++) {
f[i][0] = '\0';
}
int w;
if (strcmp(f[0], "wifi") == 0 && n >= 2 && n <= 3 && f[1][0] != '\0') {
w = snprintf(out, cap, "{\"seq\":%lu,\"cmd\":\"wifi\",\"ssid\":\"%s\",\"pass\":\"%s\"}",
(unsigned long)seq, f[1], f[2]);
} else if (strcmp(f[0], "del") == 0 && n == 2 && f[1][0] != '\0') {
w = snprintf(out, cap, "{\"seq\":%lu,\"cmd\":\"wifi\",\"ssid\":\"%s\",\"del\":true}",
(unsigned long)seq, f[1]);
} else if (strcmp(f[0], "mqtt") == 0 && n >= 2 && f[1][0] != '\0') {
unsigned long tele = 0;
for (const char *p = f[4]; *p != '\0'; p++) {
if (*p < '0' || *p > '9' || tele > NETCFG_TELE_MAX_MS) {
return 0;
}
tele = tele * 10u + (unsigned long)(*p - '0');
}
/* 口令留空 = 沿用已存的那份:网页上看不到旧口令(永不回显),不该逼人每次重输。 */
if (f[3][0] == '\0') {
w = snprintf(out, cap,
"{\"seq\":%lu,\"cmd\":\"mqtt\",\"uri\":\"%s\",\"user\":\"%s\",\"keep_pass\":true,"
"\"tele_ms\":%lu}",
(unsigned long)seq, f[1], f[2], tele);
} else {
w = snprintf(out, cap,
"{\"seq\":%lu,\"cmd\":\"mqtt\",\"uri\":\"%s\",\"user\":\"%s\",\"pass\":\"%s\","
"\"tele_ms\":%lu}",
(unsigned long)seq, f[1], f[2], f[3], tele);
}
} else {
return 0;
}
if (w <= 0 || (size_t)w >= cap || w >= CMD_LINE_MAX) {
*err = "too_long";
out[0] = '\0';
return 0;
}
*err = NULL;
return (size_t)w;
}
size_t prov_json_esc(const char *s, char *out, size_t cap)
{
size_t n = 0;
for (; *s != '\0'; s++) {
const unsigned char c = (unsigned char)*s;
const bool esc = c < 0x20 || c == 0x7F || c == '"' || c == '\\';
if (n + (esc ? 6u : 1u) >= cap) {
if (cap > 0) {
out[0] = '\0';
}
return 0;
}
if (esc) {
n += (size_t)snprintf(out + n, cap - n, "\\u%04x", (unsigned)c);
} else {
out[n++] = (char)c;
}
}
if (cap > 0) {
out[n] = '\0';
}
return n;
}
+104
查看文件
@@ -0,0 +1,104 @@
/* 配网热点(SoftAP + 网页,0.1.19,2026-10-06 用户定)的平台无关部分。
ESP 胶水(热点、HTTP 服务、DNS 劫持、热点口令的 NVS)在 prov_esp.c。
---- 为什么要有 ------------------------------------------------------------
0.1.18 之前改网络配置只有 USB 一条路(netcfg.h):路由器换了口令 / 板子搬了地方,
就得拆机插线,而 Hub 休眠之后连 COM 口都不在。现在:**连不上 WiFi 就自己开一个热点**,
手机连上去、在 192.168.4.1 的网页里改。
---- 什么时候开、什么时候关(`prov_step`)---------------------------------
| 情形 | 热点 |
|---|---|
| 一组 WiFi 都没配 | 立刻开(`nocfg`) |
| 配了,但连续 `PROV_FAIL_MS` 没拿到地址(含启动后一直连不上、连上过又掉) | 开(`fail`) |
| 开着、STA 仍没连上 | **一直开着**(不设超时:超时关掉 = 又只剩拆机) |
| STA 连上了,热点上没人 | 立刻关 |
| STA 连上了,热点上还有人 | 再留 `PROV_LINGER_MS` 让网页看到「连上了」,然后关(`linger`) |
⚠️ 路由器重启这类短暂断网也会让热点冒出来一会儿,网一恢复它就自己关。这是有意的:
「等多久才算真的连不上」没有对的数,而热点开着不碰任何输出 —— 五台手机照常供电(§10.2)。
---- 热点开着时 STA 还在重连 ----------------------------------------------
STA 每试一次都要离开热点的信道去扫一圈(≈2~3 s),试得太密网页就卡。所以
**热点上有人、且最近 `PROV_HOLD_MS` 内动过网页**时,重连间隔不短于 `PROV_SLOW_MS`
(`hold`,mlink_esp.c 的 on_wifi 里用)。只是放慢、**从不停**:停掉的话,
一台连着热点忘了断的手机就能让板子永远不回网。
---- 安全 ------------------------------------------------------------------
· 热点是 WPA2,口令**所有板子都是 `PROV_PASS`(88888888,0.1.20,2026-10-06 用户定)**,编在固件里。
0.1.19 是每板一份随机口令存 NVS —— 断网时人手边未必有那张纸条,用户选了好记的。
`net` 命令的 `ap.pass` 照样报它;它不是 netcfg.h 说的那「两个密码」:知道它只能**写**配置,
网页与 `net` 一样从不回显 WiFi / MQTT 口令。
· 网页只做三件事:读 `net`、扫描、提交 `wifi` / `mqtt`。别的命令从这条链路进不来
(`prov_build_line` 只会拼出这两条)。
· 能连上热点的人可以把板子指到另一个 broker —— 等同于拿到这块板。口令是公开的固定值,
所以这道门实际只靠两条:热点只在「板子没网」时才开,而且人必须在无线范围内。 */
#ifndef PROV_H
#define PROV_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define PROV_FAIL_MS 60000u /* STA 连续这么久没地址 ⇒ 开热点 */
#define PROV_LINGER_MS 60000u /* STA 连上后,热点上还有人时再留多久 */
#define PROV_HOLD_MS 300000u /* 最后一次动网页之后,放慢重连持续多久 */
#define PROV_SLOW_MS 20000u /* 放慢时两次重连之间至少隔多久 */
typedef enum {
PROV_WHY_OFF = 0,
PROV_WHY_NOCFG,
PROV_WHY_FAIL,
PROV_WHY_LINGER,
} prov_why_t;
typedef struct {
int n_wifi; /* 表里有几组 WiFi */
bool wifi_up; /* STA 有地址 */
int ap_sta; /* 热点上连着几台 */
bool touched; /* 上一拍以来网页有过「人的动作」(打开页面 / 扫描 / 提交;状态轮询不算) */
} prov_in_t;
typedef struct {
bool ap; /* 热点该开着 */
bool hold; /* STA 重连要放慢 */
prov_why_t why;
} prov_out_t;
void prov_init(uint32_t now);
prov_out_t prov_step(const prov_in_t *in, uint32_t now);
const char *prov_why_str(prov_why_t w);
/* ---- 热点的名字与口令 ------------------------------------------------------ */
#define PROV_SSID_MAX 20 /* "PhoneRail-" + id 末 6 位 + 0 */
#define PROV_PASS "88888888" /* 热点口令,所有板子一样(见文件头「安全」)。WPA2 要 8~63 位 */
/* `PhoneRail-<id 末 6 位>`。 */
void prov_ssid(const char *id, char out[PROV_SSID_MAX]);
/* ---- 网页表单 → 一行命令 ---------------------------------------------------
网页 POST 的正文是按行分的纯文本(免得在固件里做 URL 解码):
wifi\n<ssid>\n<口令> 加一组 / 改同名那组的口令
del\n<ssid> 删一组
mqtt\n<uri>\n<账号>\n<口令>\n<tele_ms> 口令留空 = 不改(`keep_pass`);tele_ms 留空 = 默认
拼出来的就是 USB 上那两条命令,照样进唯一的命令处理器(cmd.h),校验一处都不重写。
命令的 JSON 解析器不认转义(cmd_json.h),所以字段里有引号 / 反斜杠 / 控制字符一律拒。
返回行长;0 = 拒,`*err` 是原因(`bad_form` / `bad_char` / `too_long`)。 */
size_t prov_build_line(const char *body, uint32_t seq, char *out, size_t cap, const char **err);
/* 一段不可信的字符串(扫描到的 SSID)→ JSON 字符串的内容:引号 / 反斜杠 / 控制字符写成 \u00XX,
其余字节原样(按 UTF-8 理解)。返回写了几个字符;装不下返回 0 且 out 为空串。 */
size_t prov_json_esc(const char *s, char *out, size_t cap);
#ifdef __cplusplus
}
#endif
#endif /* PROV_H */
+92
查看文件
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="zh">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PhoneRail 配网</title>
<style>
body{font:16px/1.5 system-ui,sans-serif;margin:0;padding:16px;max-width:520px;background:#f4f5f7;color:#1c1e21}
h1{font-size:20px;margin:0 0 4px}h2{font-size:16px;margin:0 0 8px}
section{background:#fff;border-radius:8px;padding:12px 14px;margin:12px 0}
input[type=text],input[type=password]{width:100%;box-sizing:border-box;font-size:16px;padding:9px;margin:4px 0;border:1px solid #c5c8ce;border-radius:6px}
button{font-size:16px;padding:9px 14px;margin:6px 6px 0 0;border:0;border-radius:6px;background:#1668dc;color:#fff}
button.s{background:#e6e8eb;color:#1c1e21;padding:4px 10px;font-size:14px;margin:0}
.row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #eceef0}
.row:last-child{border:0}.dim{color:#6b7078;font-size:14px}.ok{color:#18794e}.bad{color:#c0362c}
.msg{min-height:1.5em;margin-top:6px}
</style>
<h1>PhoneRail 配网</h1>
<div class="dim" id="hd">读取中…</div>
<section><h2>状态</h2><div id="st">读取中…</div></section>
<section><h2>WiFi</h2>
<div id="saved"></div>
<button class="s" id="scan" type="button" style="margin-top:8px">扫描附近的 WiFi</button>
<div id="aps"></div>
<form id="fw">
<input type="text" id="ssid" placeholder="WiFi 名称" autocapitalize="off" autocomplete="off" required>
<input type="password" id="pass" placeholder="口令(开放网络留空)" autocomplete="off">
<label class="dim"><input type="checkbox" id="show"> 显示口令</label><br>
<button>保存并连接</button>
</form>
<div class="msg" id="mw"></div>
</section>
<section><h2>服务器(MQTT)</h2>
<form id="fm">
<input type="text" id="uri" placeholder="mqtts://主机:8883" autocapitalize="off" autocomplete="off" required>
<input type="text" id="user" placeholder="账号" autocapitalize="off" autocomplete="off">
<input type="password" id="mp" placeholder="口令(留空 = 不改)" autocomplete="off">
<button>保存</button>
</form>
<div class="msg" id="mm"></div>
</section>
<p class="dim">底板连上 WiFi 后约 1 分钟会自动关掉这个热点。口令只能写入,页面上不会显示已存的口令。</p>
<script>
var $=function(i){return document.getElementById(i)},tele='',filled=false;
var ERR={range:'长度不对:WiFi 口令要 8~63 位(开放网络留空),服务器地址以 mqtt:// 或 mqtts:// 开头',
state:'已经存满 4 组,先删掉一组',bad_char:'不支持引号、反斜杠和换行',too_long:'太长了',
bad_form:'没填完整',io:'写入闪存失败',arg:'参数不对',busy:'底板正忙,过几秒再试',timeout:'底板没有回应,再试一次'};
var WHY={201:'找不到这个 WiFi(名字不对 / 太远 / 只有 5G)',202:'口令不对',15:'口令不对(握手超时)',204:'口令不对(握手失败)',2:'认证过期',200:'信号太弱,掉线'};
var ST={off:'未配置',connecting:'连接中',up:'已连上'};
function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!=null)e.textContent=x;return e}
function row(k,v,c){var r=el('div','row');r.appendChild(el('span','dim',k));r.appendChild(el('span',c||'',v));return r}
function post(body){return fetch('/c',{method:'POST',body:body}).then(function(r){return r.json()})}
function say(id,j,okText){var m=$(id);m.className='msg '+(j.ok?'ok':'bad');m.textContent=j.ok?okText:('没保存:'+(ERR[j.err]||j.err))}
function fail(id){return function(){var m=$(id);m.className='msg bad';m.textContent='没连上底板,确认手机还连着 PhoneRail 热点'}}
function draw(d){
var n=d.net;$('hd').textContent='板子 '+n.id+' · 固件 '+d.fw;
var s=$('st');s.textContent='';
var w=ST[n.wifi]||n.wifi;
if(n.wifi=='up')w+='「'+n.ssid+'」 '+n.ip+'('+n.rssi+' dBm)';
else if(n.wifi=='connecting')w+='「'+n.ssid+'」';
s.appendChild(row('WiFi',w,n.wifi=='up'?'ok':''));
if(n.wifi!='up'&&n.wifi_reason)s.appendChild(row('上次失败',WHY[n.wifi_reason]||('原因码 '+n.wifi_reason),'bad'));
s.appendChild(row('服务器',n.uri?(ST[n.mqtt]||n.mqtt):'未配置',n.mqtt=='up'?'ok':''));
var v=$('saved');v.textContent='';
if(!n.ssids.length)v.appendChild(el('div','dim','还没有保存任何 WiFi'));
n.ssids.forEach(function(x){var r=el('div','row');r.appendChild(el('span','',x));
var b=el('button','s','删除');b.type='button';
b.onclick=function(){if(confirm('删除「'+x+'」?'))post('del\n'+x).then(function(j){say('mw',j,'已删除');poll()}).catch(fail('mw'))};
r.appendChild(b);v.appendChild(r)});
tele=n.tele_ms||'';
if(!filled){filled=true;$('uri').value=n.uri||'';$('user').value=n.user||'';
if(n.mpass_set)$('mp').placeholder='口令(已设置,留空 = 不改)'}
}
function poll(){fetch('/s').then(function(r){return r.json()}).then(draw).catch(function(){
$('st').textContent='读不到状态:热点可能已经关了(底板连上 WiFi 后会关)'})}
$('show').onchange=function(){$('pass').type=this.checked?'text':'password'};
$('scan').onclick=function(){var a=$('aps');a.textContent='扫描中,约 3 秒…';
fetch('/scan').then(function(r){return r.json()}).then(function(j){a.textContent='';
if(j.err){a.textContent='底板正在尝试连接,过几秒再点一次';return}
if(!j.aps.length)a.textContent='没扫到(底板只支持 2.4G)';
j.aps.forEach(function(p){var r=el('div','row');r.appendChild(el('span','',p.ssid));
r.appendChild(el('span','dim',p.rssi+' dBm'+(p.open?' · 开放':'')));
r.onclick=function(){$('ssid').value=p.ssid;$('pass').focus()};a.appendChild(r)})
}).catch(function(){a.textContent='扫描失败,再点一次'})};
$('fw').onsubmit=function(e){e.preventDefault();
post('wifi\n'+$('ssid').value+'\n'+$('pass').value).then(function(j){
say('mw',j,'已保存,正在连接 —— 看上面的状态');if(j.ok)$('pass').value='';poll()}).catch(fail('mw'))};
$('fm').onsubmit=function(e){e.preventDefault();
post('mqtt\n'+$('uri').value.trim()+'\n'+$('user').value.trim()+'\n'+$('mp').value+'\n'+tele).then(function(j){
say('mm',j,'已保存');if(j.ok)$('mp').value='';poll()}).catch(fail('mm'))};
poll();setInterval(poll,3000);
</script>
</html>
+416
查看文件
@@ -0,0 +1,416 @@
/* 配网热点的 ESP 胶水(0.1.19)。**只进 IDF 目标**;什么时候开关、为什么安全在 prov.h。
---- 三样东西,跟着热点一起开关 ---------------------------------------------
| 东西 | 说明 |
|---|---|
| SoftAP | `WIFI_MODE_STA` ↔ `WIFI_MODE_APSTA`。STA 那一半照常由 mlink_esp.c 重连 |
| HTTP 服务(esp_http_server,自己一个任务) | `/` 网页、`/s` 状态、`/scan` 扫描、`/c` 提交;其余一律 302 回 `/` |
| DNS 劫持(自己一个任务,起了就不退) | 所有 A 查询都答热点地址 ⇒ 手机连上热点自动弹出网页(captive portal) |
---- 配置怎么写进去 ---------------------------------------------------------
网页 POST 的正文经 `prov_build_line()` 拼成一行命令,**投进命令队列**(`CMD_LINK_AP`),
由执行器任务里那个唯一的命令处理器执行(cmd.h)—— 校验、存 NVS、通知 mlink_esp 重连,
走的是和 USB 完全相同的路。HTTP 任务只是等回应(最多 3 s)再原样转给浏览器。
---- 只认热点那一侧 ---------------------------------------------------------
HTTP 服务绑的是所有接口,而热点在 STA 连上之后还会留一会儿(`linger`)——
那段时间路由器那一侧也能访问到 80 端口。所以每个处理函数先看**这条连接的本地地址**
是不是热点地址,不是就 403;DNS 那边看来源网段。 */
#include "prov_esp.h"
#include <string.h>
#include "cmd_queue.h"
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_random.h" /* 表单命令的 seq */
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "lwip/sockets.h"
#include "phonerail.h"
#include "prov.h"
#include "tasks.h"
static const char *TAG = "prov";
#define AP_CHANNEL 1 /* STA 一旦连上别的信道,热点会跟过去(单射频) */
#define AP_MAX_STA 2
#define SCAN_MAX 16
#define CMD_WAIT_MS 3000
extern const char prov_html_start[] asm("_binary_prov_html_start");
extern const char prov_html_end[] asm("_binary_prov_html_end");
static esp_netif_t *s_netif;
static char s_ssid[PROV_SSID_MAX];
static httpd_handle_t s_httpd;
static volatile bool s_on;
static volatile int s_sta;
static volatile bool s_touch;
static uint32_t s_ip; /* 热点地址,网络字节序 */
static bool s_dns_started;
/* 等命令回应用的。回应是执行器任务送来的(cmd_queue.h),HTTP 任务在等:
`s_rgen` 对不上 = 这条回应来晚了(HTTP 那边已经超时走人),丢掉。 */
static SemaphoreHandle_t s_rmtx;
static SemaphoreHandle_t s_rdone;
static char s_rbuf[CMD_OUT_MAX];
static size_t s_rlen;
static uint32_t s_rgen;
/* ---- 命令队列 ---------------------------------------------------------------- */
static void on_reply(void *ctx, const char *line, size_t len)
{
xSemaphoreTake(s_rmtx, portMAX_DELAY); /* 持锁期间只有一次 memcpy:不会把执行器拖住 */
if ((uint32_t)(uintptr_t)ctx == s_rgen && len < sizeof(s_rbuf)) {
memcpy(s_rbuf, line, len);
s_rbuf[len] = '\0';
s_rlen = len;
xSemaphoreGive(s_rdone);
}
xSemaphoreGive(s_rmtx);
}
/* 投一行命令、等回应。回应在 s_rbuf(只有 HTTP 任务读)。返回长度,0 = 队列满 / 超时。 */
static size_t run_cmd(const char *line)
{
xSemaphoreTake(s_rmtx, portMAX_DELAY);
const uint32_t gen = ++s_rgen;
s_rlen = 0;
xSemaphoreGive(s_rmtx);
(void)xSemaphoreTake(s_rdone, 0);
bool ok = cmd_queue_submit(CMD_LINK_AP, line, on_reply, (void *)(uintptr_t)gen);
ok = ok && xSemaphoreTake(s_rdone, pdMS_TO_TICKS(CMD_WAIT_MS)) == pdTRUE;
xSemaphoreTake(s_rmtx, portMAX_DELAY);
s_rgen++; /* 从这里起,迟到的回应不许再碰 s_rbuf */
const size_t n = ok ? s_rlen : 0;
xSemaphoreGive(s_rmtx);
return n;
}
/* ---- HTTP ------------------------------------------------------------------ */
/* 这条连接是不是从热点那一侧进来的(文件头「只认热点那一侧」)。 */
static bool from_ap(httpd_req_t *r)
{
struct sockaddr_storage a;
socklen_t n = sizeof(a);
if (getsockname(httpd_req_to_sockfd(r), (struct sockaddr *)&a, &n) != 0) {
return false;
}
uint32_t ip = 0;
if (a.ss_family == AF_INET) {
ip = ((const struct sockaddr_in *)&a)->sin_addr.s_addr;
#if CONFIG_LWIP_IPV6
} else if (a.ss_family == AF_INET6) { /* 监听口是 v6 的,v4 连接以 ::ffff:a.b.c.d 出现 */
const struct sockaddr_in6 *a6 = (const struct sockaddr_in6 *)&a;
static const uint8_t mapped[12] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF };
if (memcmp(a6->sin6_addr.s6_addr, mapped, sizeof(mapped)) != 0) {
return false;
}
memcpy(&ip, &a6->sin6_addr.s6_addr[12], sizeof(ip));
#endif
}
return s_on && ip != 0 && ip == s_ip;
}
static esp_err_t deny(httpd_req_t *r)
{
httpd_resp_set_status(r, "403 Forbidden");
return httpd_resp_send(r, "403", HTTPD_RESP_USE_STRLEN);
}
static void json_hdr(httpd_req_t *r)
{
httpd_resp_set_type(r, "application/json");
httpd_resp_set_hdr(r, "Cache-Control", "no-store");
}
static esp_err_t h_root(httpd_req_t *r)
{
if (!from_ap(r)) {
return deny(r);
}
s_touch = true;
httpd_resp_set_type(r, "text/html; charset=utf-8");
httpd_resp_set_hdr(r, "Cache-Control", "no-store");
/* EMBED_TXTFILES 结尾补了一个 0,不发出去。 */
return httpd_resp_send(r, prov_html_start, (ssize_t)(prov_html_end - prov_html_start - 1));
}
/* 状态轮询(3 s 一次):不算「人的动作」,否则页面开着就永远在放慢重连。 */
static esp_err_t h_status(httpd_req_t *r)
{
if (!from_ap(r)) {
return deny(r);
}
json_hdr(r);
const size_t n = run_cmd("{\"cmd\":\"net\"}");
if (n == 0) {
httpd_resp_set_status(r, "503 Service Unavailable");
return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"timeout\"}");
}
httpd_resp_sendstr_chunk(r, "{\"fw\":\"" PHONERAIL_FW_VERSION "\",\"net\":");
httpd_resp_send_chunk(r, s_rbuf, (ssize_t)n);
httpd_resp_sendstr_chunk(r, "}");
return httpd_resp_sendstr_chunk(r, NULL);
}
static esp_err_t h_scan(httpd_req_t *r)
{
if (!from_ap(r)) {
return deny(r);
}
s_touch = true;
json_hdr(r);
/* 阻塞扫描 ≈2~3 s,卡的是 HTTP 任务自己。STA 正在连的那一两秒里驱动会拒(WIFI_STATE)——
如实告诉网页「过几秒再点」,不在这里重试。 */
if (esp_wifi_scan_start(NULL, true) != ESP_OK) {
return httpd_resp_sendstr(r, "{\"err\":\"busy\"}");
}
static wifi_ap_record_t rec[SCAN_MAX]; /* 只有 HTTP 任务进来 */
uint16_t n = SCAN_MAX;
if (esp_wifi_scan_get_ap_records(&n, rec) != ESP_OK) { /* 必须取:不取驱动不放那块内存 */
n = 0;
}
httpd_resp_sendstr_chunk(r, "{\"aps\":[");
bool first = true;
for (int i = 0; i < n; i++) { /* 驱动已按信号强弱排好 */
const char *ssid = (const char *)rec[i].ssid;
bool dup = ssid[0] == '\0'; /* 隐藏的不列 */
for (int k = 0; k < i && !dup; k++) {
dup = strcmp((const char *)rec[k].ssid, ssid) == 0; /* 同名的多个 AP 只列最强的 */
}
char esc[32 * 6 + 1];
if (dup || prov_json_esc(ssid, esc, sizeof(esc)) == 0) {
continue;
}
char item[sizeof(esc) + 48];
snprintf(item, sizeof(item), "%s{\"ssid\":\"%s\",\"rssi\":%d,\"open\":%s}", first ? "" : ",", esc,
(int)rec[i].rssi, rec[i].authmode == WIFI_AUTH_OPEN ? "true" : "false");
httpd_resp_sendstr_chunk(r, item);
first = false;
}
httpd_resp_sendstr_chunk(r, "]}");
return httpd_resp_sendstr_chunk(r, NULL);
}
static esp_err_t h_cfg(httpd_req_t *r)
{
if (!from_ap(r)) {
return deny(r);
}
s_touch = true;
json_hdr(r);
char body[CMD_LINE_MAX];
if (r->content_len == 0 || r->content_len >= sizeof(body)) {
return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"too_long\"}");
}
size_t got = 0;
while (got < r->content_len) {
const int k = httpd_req_recv(r, body + got, r->content_len - got);
if (k <= 0) {
return ESP_FAIL; /* 连接断了 / 超时:关掉这条连接 */
}
got += (size_t)k;
}
body[got] = '\0';
if (strlen(body) != got) {
return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"bad_char\"}"); /* 正文里有 NUL */
}
/* 会动东西的命令必须带 seq,而判重窗口是全局按值比的(cmd.h):用随机数,
和服务器那边的序号撞上的机会是 32 条 / 2^32。 */
uint32_t seq = esp_random();
if (seq == 0) {
seq = 1;
}
char line[CMD_LINE_MAX];
const char *err = NULL;
if (prov_build_line(body, seq, line, sizeof(line), &err) == 0) {
char out[48];
snprintf(out, sizeof(out), "{\"ok\":false,\"err\":\"%s\"}", err);
return httpd_resp_sendstr(r, out);
}
const size_t n = run_cmd(line);
memset(body, 0, sizeof(body)); /* 口令别留在栈上 */
memset(line, 0, sizeof(line));
if (n == 0) {
return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"timeout\"}");
}
return httpd_resp_send(r, s_rbuf, (ssize_t)n);
}
/* 别的路径(手机的联网探测 generate_204 / hotspot-detect.html 之类)一律引回网页。 */
static esp_err_t h_404(httpd_req_t *r, httpd_err_code_t e)
{
(void)e;
httpd_resp_set_status(r, "302 Found");
httpd_resp_set_hdr(r, "Location", "http://192.168.4.1/");
httpd_resp_set_hdr(r, "Cache-Control", "no-store");
return httpd_resp_send(r, "PhoneRail", HTTPD_RESP_USE_STRLEN);
}
static void http_start(void)
{
httpd_config_t hc = HTTPD_DEFAULT_CONFIG();
hc.task_priority = task_desc(TASK_LINK)->prio; /* §10.3:链路最低 */
hc.stack_size = 6144;
hc.max_open_sockets = 3; /* lwIP 一共 10 个 socket,MQTT / OTA / DNS 也要用 */
hc.lru_purge_enable = true;
hc.max_uri_handlers = 4;
if (httpd_start(&s_httpd, &hc) != ESP_OK) {
s_httpd = NULL;
ESP_LOGE(TAG, "HTTP 服务起不来 —— 热点开着但没有网页,只能走 USB");
return;
}
static const httpd_uri_t k_uris[] = {
{ .uri = "/", .method = HTTP_GET, .handler = h_root },
{ .uri = "/s", .method = HTTP_GET, .handler = h_status },
{ .uri = "/scan", .method = HTTP_GET, .handler = h_scan },
{ .uri = "/c", .method = HTTP_POST, .handler = h_cfg },
};
for (size_t i = 0; i < sizeof(k_uris) / sizeof(k_uris[0]); i++) {
httpd_register_uri_handler(s_httpd, &k_uris[i]);
}
httpd_register_err_handler(s_httpd, HTTPD_404_NOT_FOUND, h_404);
}
/* ---- DNS 劫持 --------------------------------------------------------------- */
/* 任务起了就不退(热点关着时收到的包直接丢):省掉「关热点时怎么把它从 recvfrom 里叫出来」。 */
static void dns_task(void *arg)
{
(void)arg;
int fd = -1;
for (;;) {
if (fd < 0) {
fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
struct sockaddr_in me = { .sin_family = AF_INET, .sin_port = htons(53),
.sin_addr.s_addr = htonl(INADDR_ANY) };
const struct timeval tv = { .tv_sec = 1, .tv_usec = 0 };
if (fd < 0 || bind(fd, (struct sockaddr *)&me, sizeof(me)) != 0 ||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) {
if (fd >= 0) {
close(fd);
fd = -1;
}
vTaskDelay(pdMS_TO_TICKS(5000)); /* socket 用光了:没有自动弹窗,网页照样能手输地址打开 */
continue;
}
}
uint8_t q[256 + 16];
struct sockaddr_in from;
socklen_t fl = sizeof(from);
const int n = recvfrom(fd, q, 256, 0, (struct sockaddr *)&from, &fl);
if (n < 0) {
continue; /* 1 s 超时 */
}
/* 只答热点网段来的、热点开着时的、单问题的标准查询。 */
if (!s_on || n < 17 || ((from.sin_addr.s_addr ^ s_ip) & htonl(0xFFFFFF00u)) != 0 ||
(q[2] & 0xF8) != 0 || q[4] != 0 || q[5] != 1) {
continue;
}
int p = 12;
while (p < n && q[p] != 0) {
p += q[p] + 1;
}
p += 5; /* 结尾的 0 + QTYPE + QCLASS */
if (p > n) {
continue;
}
const bool is_a = q[p - 4] == 0 && q[p - 3] == 1;
q[2] = 0x81; /* 回应 + 递归可用 */
q[3] = 0x80;
q[6] = 0;
q[7] = is_a ? 1 : 0; /* 别的类型(AAAA / HTTPS):没有记录 */
q[8] = q[9] = q[10] = q[11] = 0;
if (is_a) {
static const uint8_t ans[12] = { 0xC0, 0x0C, 0, 1, 0, 1, 0, 0, 0, 10, 0, 4 }; /* TTL 10 s */
memcpy(q + p, ans, sizeof(ans));
memcpy(q + p + sizeof(ans), &s_ip, 4);
p += (int)sizeof(ans) + 4;
}
(void)sendto(fd, q, (size_t)p, 0, (struct sockaddr *)&from, fl);
}
}
/* ---- 热点 ------------------------------------------------------------------ */
static void on_ap(void *arg, esp_event_base_t base, int32_t id, void *data)
{
(void)arg;
(void)base;
(void)data;
if (id == WIFI_EVENT_AP_STACONNECTED) {
s_sta = s_sta + 1;
} else if (id == WIFI_EVENT_AP_STADISCONNECTED && s_sta > 0) {
s_sta = s_sta - 1;
}
}
void prov_esp_init(esp_netif_t *ap, const char *id)
{
s_netif = ap;
s_rmtx = xSemaphoreCreateMutex();
s_rdone = xSemaphoreCreateBinary();
prov_ssid(id, s_ssid);
esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_AP_STACONNECTED, on_ap, NULL);
esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_AP_STADISCONNECTED, on_ap, NULL);
}
void prov_esp_set(bool on)
{
if (on == s_on || s_netif == NULL || s_rmtx == NULL || s_rdone == NULL) {
return;
}
if (!on) {
s_on = false; /* 先置:正在处理的请求下一步就 403 */
if (s_httpd != NULL) {
httpd_stop(s_httpd); /* 等 HTTP 任务退出(最多等完一次扫描 / 一条命令) */
s_httpd = NULL;
}
esp_wifi_set_mode(WIFI_MODE_STA);
s_sta = 0;
ESP_LOGI(TAG, "配网热点已关");
return;
}
wifi_config_t ac = { 0 };
memcpy(ac.ap.ssid, s_ssid, strlen(s_ssid));
ac.ap.ssid_len = (uint8_t)strlen(s_ssid);
memcpy(ac.ap.password, PROV_PASS, strlen(PROV_PASS));
ac.ap.channel = AP_CHANNEL;
ac.ap.authmode = WIFI_AUTH_WPA2_PSK;
ac.ap.max_connection = AP_MAX_STA;
if (esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK || esp_wifi_set_config(WIFI_IF_AP, &ac) != ESP_OK) {
ESP_LOGE(TAG, "配网热点开不起来 —— 只能走 USB");
esp_wifi_set_mode(WIFI_MODE_STA);
return; /* s_on 仍是 false:t_link 过 10 s 再试(mlink_esp.c 的 prov_poll) */
}
esp_netif_ip_info_t ip;
s_ip = (esp_netif_get_ip_info(s_netif, &ip) == ESP_OK) ? ip.ip.addr : 0;
s_sta = 0;
s_touch = false;
s_on = true;
http_start();
if (!s_dns_started) {
s_dns_started = xTaskCreate(dns_task, "prov_dns", 3072, NULL, task_desc(TASK_LINK)->prio, NULL) == pdPASS;
}
ESP_LOGW(TAG, "配网热点已开:「%s」,连上后开 http://192.168.4.1/", s_ssid); /* 只打名字,不打口令 */
}
bool prov_esp_on(void) { return s_on; }
int prov_esp_sta_count(void) { return s_sta; }
bool prov_esp_take_touch(void)
{
const bool t = s_touch;
s_touch = false;
return t;
}
const char *prov_esp_ssid(void) { return s_ssid; }
const char *prov_esp_pass(void) { return PROV_PASS; }
+22
查看文件
@@ -0,0 +1,22 @@
/* 配网热点的 ESP 胶水(0.1.19)。只在 IDF 目标里有;判据与安全说明在 prov.h,实现说明在 prov_esp.c 文件头。 */
#ifndef PROV_ESP_H
#define PROV_ESP_H
#include <stdbool.h>
#include "esp_netif.h"
/* 定热点名字、挂热点事件。要在 `esp_wifi_start()` 之后、链路任务起来之前调一次。
`ap` 是 `esp_netif_create_default_wifi_ap()` 的返回值,`id` 是板子 ID(热点名取它的末 6 位)。 */
void prov_esp_init(esp_netif_t *ap, const char *id);
/* 开 / 关热点(连同网页与 DNS 劫持)。**只在 t_link 里调**(关的时候要等 HTTP 任务退出)。幂等。 */
void prov_esp_set(bool on);
bool prov_esp_on(void); /* 热点现在真的开着吗(开失败时是 false) */
int prov_esp_sta_count(void); /* 热点上连着几台 */
bool prov_esp_take_touch(void); /* 上次问过之后网页有没有「人的动作」(读了就清) */
const char *prov_esp_ssid(void);
const char *prov_esp_pass(void);
#endif /* PROV_ESP_H */
+59 -1
查看文件
@@ -25,6 +25,10 @@ static bool s_inited;
/* 上位机 `fan` 命令给的下限(A12)。**跨任务**:命令处理器在执行器任务里写,
本模块在热管理任务里读。一个对齐的 16 位量,Xtensa 上读写是原子的,不加锁。 */
static volatile uint16_t s_floor;
/* 下限的时限(`t_thermal_set_floor_for`)。写的顺序是「先 until、再 timed、最后 floor」,
读的一侧最坏只会晚一圈(1 s)才认到新值。 */
static volatile uint32_t s_floor_until;
static volatile bool s_floor_timed;
/* 风扇侧占空比 → 真正写进 `hal_pwm` 的值。Q6 是低边管,栅极高 = 把风扇的
PWM 线拉低 ⇒ 反相(`t_thermal.h` 硬件那三条的第 2 条)。 */
@@ -107,6 +111,7 @@ hal_err_t t_thermal_init(thermal_snap_t *s, therm_temp_fn temp, therm_tach_fn ta
return e;
}
s->fan_en = true;
s->fan_starts = 1;
s->en_ms = hal_time_now_ms();
s->last_ms = s->en_ms;
@@ -133,6 +138,17 @@ static void tach_step(thermal_snap_t *s, int idx, uint32_t dt_ms, uint16_t duty)
const uint32_t delta = now_edges - t->last_edges; /* uint32 相减,回绕安全 */
t->last_edges = now_edges;
/* 风扇断着电:不累加、不判(惯性滑行那几圈的脉冲也不算数),转速如实报 0。
`stalled` 不动 —— 见头文件「断电期间不判停转」。 */
if (!s->fan_en) {
t->win_edges = 0;
t->win_on_ms = 0;
t->win_ms = 0;
t->rpm = 0;
t->rpm_valid = true;
return;
}
/* 启动宽限期内一直重置窗口:这时候风扇还在起转,脉冲本来就没有或很稀。 */
if (s->fan_en && hal_time_elapsed_ms(s->en_ms) < T_THERMAL_SPINUP_MS) {
t->win_edges = 0;
@@ -216,11 +232,37 @@ hal_err_t t_thermal_step(thermal_snap_t *s)
s->temp_errors++;
}
uint16_t duty = t_thermal_duty_for(s->temp_max_mc, s->temp_valid);
if (s_floor_timed && (int32_t)(now_ms - s_floor_until) >= 0) {
s_floor = 0; /* 上位机给的时限到了:交回自动 */
s_floor_timed = false;
}
const uint16_t floor_ = s_floor;
if (floor_ > duty) {
duty = floor_; /* 手动值只抬不压:曲线要得更高时照曲线来 */
}
/* ---- 开还是关(`T_THERMAL_FAN_OFF_MC` 上的说明)----
读不到温度、上位机要风:一律开。否则按迟滞:开着的掉到 OFF 以下才关,关着的到 ON 才开。 */
bool want_on = true;
if (s->temp_valid && floor_ == 0) {
want_on = s->fan_en ? (s->temp_max_mc >= T_THERMAL_FAN_OFF_MC)
: (s->temp_max_mc >= T_THERMAL_FAN_ON_MC);
}
if (want_on != s->fan_en) {
/* 开:先把占空比写好再给 12V(同 init 的顺序);关:先断 12V。写失败就维持原状,下一圈再来。 */
if (want_on) {
(void)apply_duty(s, duty);
}
if (hal_gpio_write(BOARD_PIN_FAN_EN, want_on ? 1 : 0) == HAL_OK) {
s->fan_en = want_on;
if (want_on) {
s->en_ms = now_ms; /* 启动宽限从这里重新算 */
s->fan_starts++;
}
}
}
/* 断着电时占空比报 0:遥测里「duty 300、en false」会让人以为它在转。 */
(void)apply_duty(s, s->fan_en ? duty : 0);
/* ---- 过温降载:本模块只**报**,关路是限功率那一包的事 ---- */
if (s->temp_valid) {
@@ -241,9 +283,25 @@ hal_err_t t_thermal_step(thermal_snap_t *s)
return HAL_OK;
}
void t_thermal_set_floor(uint16_t permille)
void t_thermal_set_floor(uint16_t permille) { t_thermal_set_floor_for(permille, 0); }
void t_thermal_set_floor_for(uint16_t permille, uint32_t ttl_ms)
{
const bool timed = ttl_ms > 0 && permille > 0;
if (timed) {
s_floor_until = hal_time_now_ms() + ttl_ms;
}
s_floor_timed = timed;
s_floor = (permille > 1000u) ? 1000u : permille;
}
uint16_t t_thermal_floor(void) { return s_floor; }
uint32_t t_thermal_floor_left_ms(void)
{
if (!s_floor_timed || s_floor == 0) {
return 0;
}
const int32_t left = (int32_t)(s_floor_until - hal_time_now_ms());
return left > 0 ? (uint32_t)left : 0;
}
+28 -4
查看文件
@@ -62,6 +62,9 @@
⚠️ 与之配套的是**占空比下限** `T_THERMAL_DUTY_MIN`:占空比真掉到 0,
导通时间永远攒不满,这条判据就变成了「永远不判」。所以闭环的下限不是 0;
真要停风扇只能撤 `FAN_EN`(§22.6:0 % PWM 时四线风扇仍转 3300 RPM)。
0.1.21 起凉的时候就是这么停的(`T_THERMAL_FAN_OFF_MC`):断电期间**不判停转**、窗口清零、
转速报 0;`stalled` 保持断电前的结论(一只没插的风扇不会因为每次启停各记一条事件)。
重新上电后照样先过启动宽限。
---- 事件:入队,不落盘 --------------------------------------------------
`evlog_put()` 是几条指令的入队,任何任务都能调;`evlog_flush()` 是唯一碰 flash
@@ -103,8 +106,18 @@ extern "C" {
而新位置的温升曲线只有上板才有。这里给的是**形状**(分段线性 + 迟滞),
数值先按常识填、单列在这里,上板实测之后只改这四行。 */
/* 低于它就按下限转(安静优先)。 */
/* 曲线起点:这个温度按下限转,往上线性加到满速。 */
#define T_THERMAL_TEMP_LO_MC 40000 /* 40.000 °C —— 待实测 */
/* **40 °C 以下风扇断电**(0.1.21,2026-10-06 用户定):撤 `FAN_EN`,不是把占空比压到 0 ——
四线风扇 0 % PWM 仍转 3300 RPM(§22.6),要它停只能断 12V。
到 `FAN_ON_MC` 开、掉到 `FAN_OFF_MC` 以下才关:两个数之间维持原状。
⚠️ 这 2 °C 的迟滞不能省:板温就在 40 °C 上下晃(板 B 空闲实测 38~41 °C),
没有它风扇每隔几秒启停一次,每次起转都是一声响,还各带 5 s 的停转判据盲区。
**不受它管的三种情况,风扇一律开着**:两路温度都读不到(§10.10)、上位机给了下限(`fan` 命令)、
以及启动后读到第一个温度之前的那一秒。 */
#define T_THERMAL_FAN_ON_MC T_THERMAL_TEMP_LO_MC
#define T_THERMAL_FAN_OFF_MC 38000
/* 高于它满速。 */
#define T_THERMAL_TEMP_HI_MC 65000 /* 65.000 °C —— 待实测 */
/* 过温降载门限(FR-13「超阈值先降流再关路」)。本模块只**报**,不动电源 ——
@@ -204,10 +217,11 @@ typedef struct {
int32_t temp_max_mc; /* 两路里**高的那一个**:一点凉不代表另一点不烫 */
bool temp_valid; /* 至少有一路读到了 */
uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径 */
uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径;风扇断电时是 0 */
uint16_t gpio_duty; /* 真写进 hal_pwm 的值(Q6 反相 ⇒ 1000 − duty) */
bool fan_en; /* `FAN_EN` 的命令值 */
bool fan_en; /* `FAN_EN` 的命令值(凉了会撤,见 `T_THERMAL_FAN_OFF_MC`) */
uint32_t fan_starts; /* 上过几次电(含启动那一次):启停太勤 = 迟滞不够 */
uint32_t en_ms; /* 拉高的时刻,启动宽限从这里算 */
bool derate; /* 正在过温降载 */
@@ -239,9 +253,19 @@ uint16_t t_thermal_duty_for(int32_t milli_c, bool valid);
/* 上位机手动给的占空比**下限**(风扇侧千分比,A12 的 `fan` 命令)。0 = 全自动。
⚠️ 只抬不压:实际占空比 = max(温度曲线, 下限)。「手动把风扇调小」这件事
不提供 —— 那等于允许远程关掉散热,而温度读不到时满速(§10.10)的兜底也会被它盖掉。
下一圈 `t_thermal_step()` 生效;不随 `t_thermal_init()` 清零(重启自然清零)。 */
下限 > 0 时风扇一定开着(哪怕板温低于 40 °C):板上的 NTC 看不见手机 SoC 有多热,
上位机(AppTask 按手机自报的温度)要风的时候就得有风。
下一圈 `t_thermal_step()` 生效;不随 `t_thermal_init()` 清零(重启自然清零)。
`ttl_ms`(0.1.21):下限保持多久,0 = 一直保持。到期自动回 0(全自动)——
给「程序定时续期」的上位机用:它挂了 / 断网了,风扇不会永远停在它最后要的那个转速上。
`t_thermal_floor_left_ms()`:还剩多久,没设时限或已经没有下限时是 0。 */
void t_thermal_set_floor(uint16_t permille);
void t_thermal_set_floor_for(uint16_t permille, uint32_t ttl_ms);
uint16_t t_thermal_floor(void);
uint32_t t_thermal_floor_left_ms(void);
#define T_THERMAL_FLOOR_TTL_MAX_S 86400 /* `fan` 命令的 `sec` 上限:一天 */
#ifdef __cplusplus
}
+3
查看文件
@@ -14,6 +14,9 @@ CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# 配网热点的网页(prov_esp.c):手机浏览器 / 联网探测的请求头常常超过默认的 512 字节(回 431)。
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
# 自定义分区表:默认那张里没有事件日志的地方(需求 §10.7)。见 partitions.csv。
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
+27 -1
查看文件
@@ -11,6 +11,7 @@
#include <string.h>
#include "board_pins.h"
#include "ch224a.h"
#include "chgmode.h"
#include "cmd.h"
#include "dac63204.h"
@@ -21,6 +22,7 @@
#include "hal_time.h"
#include "i2cbus.h"
#include "pca9555.h"
#include "pdmon.h"
#include "phonerail.h"
#include "rail.h"
#include "rail_cfg.h"
@@ -590,6 +592,20 @@ static void test_fan_floor(void)
TEST_ASSERT_EQUAL_UINT16(700, t_thermal_floor());
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"fan\",\"duty\":0}"), "\"floor\":0");
TEST_ASSERT_EQUAL_UINT16(0, t_thermal_floor());
/* 0.1.21 `sec`:带时限的下限(到期回落在 test_t_thermal 里测)。没给 sec 的回应里不带它。 */
ASSERT_LACKS(r, "\"sec\"");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"fan\",\"duty\":600,\"sec\":90}"), "\"floor\":600,\"sec\":90");
TEST_ASSERT_EQUAL_UINT32(90000, t_thermal_floor_left_ms());
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"fan\",\"duty\":600}"), "\"floor\":600"); /* 不带 sec = 改成一直保持 */
TEST_ASSERT_EQUAL_UINT32(0, t_thermal_floor_left_ms());
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"fan\",\"duty\":600,\"sec\":86401}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"fan\",\"duty\":600,\"sec\":-1}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"fan\",\"duty\":600,\"sec\":\"90\"}"), "\"err\":\"arg\"");
TEST_ASSERT_EQUAL_UINT16(600, t_thermal_floor());
ASSERT_HAS(send("{\"seq\":8,\"cmd\":\"fan\",\"duty\":0,\"sec\":90}"), "\"floor\":0"); /* 0 就是交回自动,时限无意义 */
TEST_ASSERT_EQUAL_UINT32(0, t_thermal_floor_left_ms());
t_thermal_set_floor(0);
}
/* 0.1.14 hub:改模式 / 保持窗口,两条链路都收;越界记事件、缺参数不记;
@@ -689,9 +705,18 @@ static void test_status_fits_and_reports_unknowns_as_null(void)
ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"host\":false");
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\"}");
ASSERT_HAS(r, "\"host\":true");
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, "{\"r\":2,\"pr\":true,\"en\":false,\"vb\":false,\"set\":3900,\"mv\":null,\"ua\":null");
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\"");
TEST_ASSERT_EQUAL_CHAR('}', r[strlen(r) - 1]);
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);
+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();
fans_up();
set_temp(25000); /* 25 °C:常温开机 */
set_temp(T_THERMAL_TEMP_LO_MC); /* 曲线起点:风扇开着、按下限转(再凉就断电了,见最后一组用例) */
t_thermal_set_floor(0); /* 下限是模块级静态量,不随 init 清零(t_thermal.h) */
}
void tearDown(void) {}
@@ -186,7 +186,7 @@ static void test_unreadable_temperature_means_full_speed(void)
{
thermal_snap_t s;
boot(&s);
set_temp(25000);
set_temp(T_THERMAL_TEMP_LO_MC);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
@@ -205,7 +205,7 @@ static void test_manual_floor_only_raises_the_duty(void)
{
thermal_snap_t s;
boot(&s);
set_temp(25000);
set_temp(T_THERMAL_TEMP_LO_MC);
t_thermal_set_floor(800);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(800, s.duty);
@@ -215,7 +215,7 @@ static void test_manual_floor_only_raises_the_duty(void)
TEST_ASSERT_EQUAL_UINT16(1000, s.duty);
t_thermal_set_floor(T_THERMAL_DUTY_MIN / 2); /* 比曲线下限还低:没有效果 */
set_temp(25000);
set_temp(T_THERMAL_TEMP_LO_MC);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
@@ -253,7 +253,7 @@ static void test_low_duty_windows_are_not_mistaken_for_a_stall(void)
{
thermal_snap_t s;
boot(&s);
set_temp(25000); /* 凉 ⇒ 占空比压到下限 */
set_temp(T_THERMAL_TEMP_LO_MC); /* 凉 ⇒ 占空比压到下限 */
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
@@ -476,6 +476,147 @@ static void test_step_before_init_is_refused(void)
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_thermal_step(NULL));
}
/* ---- ⑤ 0.1.21:凉了断电 -------------------------------------------------- */
/* ⭐ 40 °C 以下风扇断电:撤的是 `FAN_EN`(四线风扇 0 % PWM 仍在转),并且带迟滞 ——
38 °C 以下才关、回到 40 °C 才开,中间维持原状。
写错了会怎样:没有迟滞 ⇒ 板温在 40 °C 上下晃时风扇几秒启停一次;只压占空比不断电 ⇒ 风扇照转。 */
static void test_cool_board_cuts_fan_power_with_hysteresis(void)
{
thermal_snap_t s;
boot(&s);
TEST_ASSERT_TRUE(s.fan_en);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
TEST_ASSERT_EQUAL_UINT32(1, s.fan_starts);
set_temp(T_THERMAL_FAN_OFF_MC); /* 正好在关断点上:还开着(低于才关) */
run(&s, 3);
TEST_ASSERT_TRUE(s.fan_en);
TEST_ASSERT_TRUE(sim_fan_rpm(0) > 0);
set_temp(T_THERMAL_FAN_OFF_MC - 1);
run(&s, 1);
TEST_ASSERT_FALSE(s.fan_en);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, sim_gpio_level(BOARD_PIN_FAN_EN), "凉了没撤 FAN_EN");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, sim_fan_rpm(0), "风扇还在转");
TEST_ASSERT_EQUAL_UINT32(0, sim_fan_rpm(1));
TEST_ASSERT_EQUAL_UINT16_MESSAGE(0, s.duty, "断着电时遥测里的占空比该是 0");
set_temp(T_THERMAL_FAN_ON_MC - 1); /* 回到两个门限之间:不开 */
run(&s, 5);
TEST_ASSERT_FALSE(s.fan_en);
TEST_ASSERT_EQUAL_UINT32(1, s.fan_starts);
set_temp(T_THERMAL_FAN_ON_MC);
run(&s, 1);
TEST_ASSERT_TRUE(s.fan_en);
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(BOARD_PIN_FAN_EN));
TEST_ASSERT_TRUE(sim_fan_rpm(0) > 0);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
TEST_ASSERT_EQUAL_UINT32(2, s.fan_starts);
/* 在门限之间来回晃 20 圈:一次都不许再启停。 */
for (int i = 0; i < 20; i++) {
set_temp((i & 1) ? T_THERMAL_FAN_ON_MC - 100 : T_THERMAL_FAN_OFF_MC + 100);
run(&s, 1);
}
TEST_ASSERT_TRUE(s.fan_en);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, s.fan_starts, "门限之间晃动引起了启停");
}
/* ⭐ 断电期间不报停转;重新上电先过启动宽限;转起来之后判据照常工作。
一只**本来就没插**的风扇(一直判停转)不因为每次启停再各记一条事件。 */
static void test_power_cycling_the_fan_does_not_raise_stall_alarms(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
evlog_flush(0);
const uint32_t seq = next_seq();
thermal_snap_t s;
sim_fan_set_stalled(1, true); /* 第二只没插 */
boot(&s);
set_temp(70000);
run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u);
TEST_ASSERT_FALSE(s.fan[0].stalled);
TEST_ASSERT_TRUE(s.fan[1].stalled);
TEST_ASSERT_EQUAL_UINT32(1, s.fan[1].stalls);
for (int k = 0; k < 3; k++) {
set_temp(30000); /* 凉:断电 */
run(&s, 40); /* 比任何判据窗口都长 */
TEST_ASSERT_FALSE(s.fan_en);
TEST_ASSERT_FALSE_MESSAGE(s.fan[0].stalled, "断着电被判成了停转");
TEST_ASSERT_TRUE(s.fan[0].rpm_valid);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].rpm, "断着电转速没报 0");
set_temp(70000); /* 热:上电满速 */
run(&s, T_THERMAL_SPINUP_MS / 1000u + T_THERMAL_TACH_ON_MS / 1000u + 3u);
TEST_ASSERT_TRUE(s.fan_en);
TEST_ASSERT_FALSE(s.fan[0].stalled);
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].rpm > 5000, "重新上电后转速没恢复");
}
TEST_ASSERT_EQUAL_UINT32(0, s.fan[0].stalls);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, s.fan[1].stalls, "没插的那只每次启停都又记了一次");
evlog_flush(0);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(seq + 1, next_seq(), "启停期间多记了事件");
/* 上着电时真停转照样抓得到(判据没有被「断电不判」顺手关掉)。 */
sim_fan_set_stalled(0, true);
run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u);
TEST_ASSERT_TRUE(s.fan[0].stalled);
}
/* ⭐ 凉着也必须有风的两种情况:温度读不到(§10.10)、上位机给了下限。
带时限的下限到期自动回落 —— 上位机挂了,风扇不会永远停在它最后要的转速上。 */
static void test_cool_fan_still_runs_when_asked_or_blind(void)
{
thermal_snap_t s;
boot(&s);
set_temp(30000);
run(&s, 2);
TEST_ASSERT_FALSE(s.fan_en);
s_temp_ok[0] = false;
s_temp_ok[1] = false;
run(&s, 1);
TEST_ASSERT_TRUE_MESSAGE(s.fan_en, "温度读不到时风扇没开");
TEST_ASSERT_EQUAL_UINT16(1000, s.duty);
TEST_ASSERT_TRUE(sim_fan_rpm(0) > 0);
set_temp(30000);
run(&s, 1);
TEST_ASSERT_FALSE(s.fan_en);
t_thermal_set_floor_for(600, 30000); /* 30 s */
run(&s, 1);
TEST_ASSERT_TRUE_MESSAGE(s.fan_en, "上位机要风时风扇没开");
TEST_ASSERT_EQUAL_UINT16(600, s.duty);
TEST_ASSERT_EQUAL_UINT32(29000, t_thermal_floor_left_ms());
run(&s, 28);
TEST_ASSERT_TRUE(s.fan_en);
TEST_ASSERT_EQUAL_UINT16(600, t_thermal_floor());
run(&s, 1); /* 第 30 秒:到期 */
TEST_ASSERT_EQUAL_UINT16_MESSAGE(0, t_thermal_floor(), "下限到期没回落");
TEST_ASSERT_EQUAL_UINT32(0, t_thermal_floor_left_ms());
TEST_ASSERT_FALSE(s.fan_en);
/* 续期 = 再发一次:时限从这一刻重新算。 */
t_thermal_set_floor_for(500, 10000);
run(&s, 8);
t_thermal_set_floor_for(500, 10000);
run(&s, 8);
TEST_ASSERT_TRUE(s.fan_en);
run(&s, 3);
TEST_ASSERT_FALSE(s.fan_en);
/* 不带时限的下限一直保持(老行为),时钟回绕也不会让它「到期」。 */
t_thermal_set_floor(400);
run(&s, 1);
sim_time_advance_ms(0x80000000u);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(400, t_thermal_floor());
TEST_ASSERT_TRUE(s.fan_en);
t_thermal_set_floor(0);
}
int main(void)
{
UNITY_BEGIN();
@@ -492,5 +633,8 @@ int main(void)
RUN_TEST(test_spinup_grace_suppresses_the_first_windows);
RUN_TEST(test_derate_is_edge_triggered_with_hysteresis);
RUN_TEST(test_thermal_never_touches_flash);
RUN_TEST(test_cool_board_cuts_fan_power_with_hysteresis);
RUN_TEST(test_power_cycling_the_fan_does_not_raise_stall_alarms);
RUN_TEST(test_cool_fan_still_runs_when_asked_or_blind);
return UNITY_END();
}
+3 -2
查看文件
@@ -77,7 +77,8 @@ bash rack/deploy/deploy.sh # 构建 → 传 → 服务器上 Python 3.8
| 门户 | 应用 id 16,key `rack`、端口 7764(`phonerail` 已被需求文档站占用);授权 admin + staff,**不给 viewer**(没有只读模式,能断电重启) |
| 保留期 | 原始 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 后面)。
- 前端 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)
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")
def history(board: str, rail: int, start: int, end: Optional[int] = None):
_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:
d["name"] += ":" + protocol.OTA_ERR_NAMES.get(d["mv"], str(d["mv"]))
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:
a = d["arg"]
d["name"] += "(%s)" % ("手动关" if a == protocol.CHG_EV_OFF else
+49
查看文件
@@ -4,6 +4,7 @@
每分钟降采样一行(均值 / 最小 / 最大)留 `MIN_KEEP_S`(90 天)——
电流曲线的用途是「这台手机前天夜里是不是卡死过」,一分钟的 min/max 足够看出来。
事件、审计、恢复记录**永久保留**(量小,而且是排查的唯一线索)。
每日耗电量(`energy_day`)也永久保留:它从分钟聚合里滚出来,分钟数据过了 90 天被清掉之后就再也算不回来了。
⚠️ 时间戳一律是**服务器收到的时刻**(毫秒)。板子没有墙钟(需求 §10.12:
上电从 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,
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 (
board_id TEXT NOT NULL, seq INTEGER NOT NULL,
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 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:
"""[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据,
否则回分钟聚合(带 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._tasks: List[asyncio.Task] = []
self.tele_observers: List[Callable[[str, int, dict], None]] = []
self.phone_live: Dict[tuple, dict] = {} # (board, rail) → 手机自己报的那一半(fleet.py),只显示
# ---- 生命周期 ------------------------------------------------------------
async def start(self):
@@ -58,6 +59,7 @@ class Hub:
self.bus.start()
for kind in ("rsp", "tele", "state"):
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())]
async def stop(self):
@@ -192,6 +194,8 @@ class Hub:
self.db.x("UPDATE boards SET ev_cursor=? WHERE id=?", (nxt, board))
if ev:
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:
return
except CmdTimeout:
@@ -200,6 +204,16 @@ class Hub:
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:
b = self._board(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
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"],
"last_tele": b["last_tele"], "status": b["status"],
"phones": [dict(p) for p in phones]}
"last_tele": b["last_tele"], "status": b["status"], "pd": self.pd(board),
"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]:
return [self.snapshot(b) for b in sorted(self.latest)]
@@ -257,6 +280,7 @@ class Hub:
await asyncio.sleep(MAINT_S)
try:
self.db.downsample()
self.db.energy_rollup() # 先滚再清:今天 / 昨天每分钟重算一遍,量是几千行
self.db.prune()
except Exception:
log.exception("降采样 / 清理失败")
+15 -1
查看文件
@@ -22,6 +22,7 @@ from .api import create_app
from .bus import MemBroker, MemBus, PahoBus
from .db import Db, now_ms
from .firmware import FwStore
from .fleet import FleetPoller
from .hub import Hub
from .recovery import RecoveryManager
from .simboard import HUNG, SimBoard
@@ -77,9 +78,20 @@ def build(args):
await hub.stop()
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
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:
async def fw_gc():
async def loop():
@@ -120,6 +132,8 @@ def main(argv=None):
help="固件下载目录(nginx 7765 的 /fw/,生产是 /root/phonerail/fw);不给就没有 OTA")
ap.add_argument("--fw-base", default="http://home.example:7765/fw",
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 块模拟板子")
args = ap.parse_args(argv)
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)
}
# 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)。⚠️ 冻结。
HUB_EV_OFF, HUB_EV_ON, HUB_EV_MODE = 0, 1, 0x10
HUB_MODES = ("auto", "on") # hub_mode_t 的下标
@@ -157,7 +172,15 @@ def build_command(req: dict) -> dict:
duty = req.get("duty")
if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000:
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,))
+29 -9
查看文件
@@ -1,12 +1,28 @@
"""自动崩溃恢复(需求 §4.1 两级重启:先软后硬),2026-09-27 定为「先只看电流」。
服务器够不到手机的 adb(手机在机架那边的 NAT 后面),所以这一版的判据只来自板子遥测:
· FLAT:窗口内电流几乎不变(max − min < flat_ua)且不低 —— 卡死的手机常见的样子;
· LOW :窗口内电流一直低于 low_ua —— 关机 / 掉到待机,却本该在跑。
· FLAT:窗口内电流平在一个不低的水平上 —— **中位数 ≥ flat_min_ua 且 p95 − p05 < flat_ua**。卡死的手机就是这样;
· LOW :窗口内电流一直低于 low_ua —— 关机,或者睡下去之后再也没醒过(代理不轮询了)。
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);
· 每小时最多 `max_per_hour` 次,超了就 GAVE_UP 停手、告警,要人来复位 ——
@@ -33,8 +49,9 @@ IDLE, SOFT, HARD, GAVE_UP = "idle", "soft", "hard", "gave_up"
@dataclass
class Params:
window_s: int = 300 # 判定窗口
flat_ua: int = 5000 # 窗口内电流摆幅小于它 = 平线(5 mA)
low_ua: int = 30000 # 窗口内电流一直低于它 = 像关机(30 mA)
flat_ua: int = 30000 # 平线:窗口内 p95 − p05 小于它(30 mA)……
flat_min_ua: int = 100000 # ……并且中位数不低于它(100 mA):睡着的手机平在 ~10 mA 是正常的
low_ua: int = 30000 # 窗口内电流一直低于它 = 像关机 / 睡死(30 mA)
settle_s: int = 120 # 动作之后先等它重启完,这段不看
max_per_hour: int = 3 # 每台每小时最多动作几次
soft_ms: int = 10000 # 一级:长按电源键时长(需求 §4.1 ≈10 s)
@@ -78,10 +95,13 @@ class Detector:
def verdict(self, now: int) -> Optional[str]:
if not self.full(now):
return None
vals = [u for _, u in self.s]
if max(vals) < self.p.low_ua:
vals = sorted(u for _, u in self.s)
n = len(vals)
if vals[-1] < self.p.low_ua:
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 None
+7 -1
查看文件
@@ -52,8 +52,10 @@ class SimBoard:
self.ota_req: Optional[dict] = None
self.ota_rate = 400000 # 每次 tick 下载多少字节(真板 ≈125 KB/s)
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
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):
@@ -143,6 +145,7 @@ class SimBoard:
"rpm": [3300, 3280], "stall": [False, False]}, "derate": False,
"safety": {"cycles": self.up_ms // 20, "skipped": 0, "i2c_err": 0, "events": 0},
"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,
"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}
if cmd == "fan":
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:
return {"ok": False, "err": "no_seq"}
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()
assert rec["params"]["window_s"] == 300
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):
@@ -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 "休眠" in ev[9202]["name"] and ev[9202]["severe"] is False
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": "vbus", "rail": 1, "on": 1},
{"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:
with pytest.raises(protocol.CmdError):
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():
t = [1700000000.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):
hub, sim, db = rig(tmp_path)
for i in range(19):
sim._ev(1, 8, i) # 加上 BOOT 共 20 条:要分 3 页
for i in range(18):
sim._ev(1, 8, i) # 加上 BOOT、PD_LIMIT 共 20 条:要分 3 页
async def go():
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"
# 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():
ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2))
t = 0
@@ -341,3 +381,72 @@ def test_flash_mode_extend_command():
{"cmd": "flash_mode", "rail": 1, "add": 30, "min": 10}):
with pytest.raises(protocol.CmdError):
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 \
--mqtt-env /root/phonerail/mqtt.env \
--fw-dir /root/phonerail/fw \
--apptask-env /root/apptask/apptask-py.env \
--bind 127.0.0.1 --port 7764
Restart=on-failure
RestartSec=3
+1
查看文件
@@ -26,6 +26,7 @@ export const api = {
req('GET', `/api/boards/${board}/rails/${rail}/history?start=${start}${end ? `&end=${end}` : ''}`),
boardHistory: (board, start, 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)),
audit: (q = {}) => req('GET', '/api/audit?' + new URLSearchParams(q)),
recovery: () => req('GET', '/api/recovery'),
+2 -2
查看文件
@@ -6,12 +6,12 @@
// · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线;
// · 线宽 2px,不画数据点;min~max 画成同色 15% 的带子。
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 { CanvasRenderer } from 'echarts/renderers'
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 }
// 字体是打包进来异步下的,而 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>
<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>
<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>
<div class="ps muted" v-else>空槽</div>
</div>
@@ -128,11 +132,14 @@ import { computed, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import RailOps from './RailOps.vue'
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 })
defineEmits(['select'])
// 手机主板 / CPU 温度的提示线(只改颜色,不参与任何动作):50 °C 橙、60 °C 红
const TEMP_WARN_MC = 50000
const TEMP_BAD_MC = 60000
const LIVE_UA = 5000
const s = computed(() => props.board?.status)
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 fault = pr && r.flt && !(r.chg > 0)
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 w2 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 降压 → eFuse
const w3 = !online.value || !pr || !r.en ? 'off' : fault ? 'bad' : live ? 'live' : 'idle' // eFuse → 手机
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 里
rev: live && (r.ua ?? 0) < -LIVE_UA,
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); }
.ps { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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.busy { border-color: var(--st-busy); }
.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="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>
<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="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>
@@ -40,9 +46,10 @@
<div class="g"><div class="gh"><span class="gk">风扇</span>
<el-select v-model="fanFloor" size="small" class="floor" aria-label="风扇占空比下限" @change="setFan">
<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>
<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>
<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>
@@ -58,6 +65,8 @@
<BoardChart :board="b.id" />
</div>
<EnergyCard :board="b.id" />
<div class="two">
<section class="card">
<h2 class="ttl">最近事件</h2>
@@ -86,6 +95,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import HistoryChart from '../components/HistoryChart.vue'
import BoardChart from '../components/BoardChart.vue'
import EnergyCard from '../components/EnergyCard.vue'
import OtaState from '../components/OtaState.vue'
import Topology from '../components/Topology.vue'
import { api, rspText } from '../api'
@@ -101,6 +111,36 @@ const pw = computed(() => {
const rails = railsMw(s?.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 字段;老固件没有 ⇒ 显示「—」
const HOST = { true: '已连电脑', false: '未连电脑' }
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 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; }
/* 五个仪表格:凹进面板的小槽,标签在上、读数在下 */
.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;
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; }
@@ -210,7 +250,7 @@ watch(() => props.id, refresh)
.quiet { color: var(--muted); }
.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; }
@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; }
.gauges { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 800px) { .two { grid-template-columns: 1fr; } }
+6 -4
查看文件
@@ -1,14 +1,15 @@
<template>
<div class="rc">
<el-alert type="warning" :closable="false" show-icon
title="判据只看电流(服务器够不着手机的 adb);阈值还没有实测依据。自动恢复默认关,在板子页逐台打开。" />
title="判据只看电流(服务器够不着手机的 adb)。阈值 2026-10-06 按板 B 的实测定过一次(一次真卡死 + 一晚睡眠数据),还没开过一次自动恢复。默认关,在板子页逐台打开。" />
<div class="card">
<div class="ttl">判据参数(全局)</div>
<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="flatMa" :min="0.1" :max="200" :step="0.5" /> 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="flatMa" :min="1" :max="200" :step="1" /> mA(p95 − p05,不怕偶发尖峰)</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.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>
@@ -51,8 +52,9 @@ import { api } from '../api'
import { state, onMessage } from '../store'
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 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 states = ref([])
const log = ref([])
+6
查看文件
@@ -51,6 +51,12 @@ function handle(m) {
case 'board':
state.boards[m.board.id] = m.board
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': {
const b = state.boards[m.board]
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 之后 |
| **C3** | 出厂标定流程(两点线性标定存 NVS) | B2 之后 |
| **C4** | 文档回填:实测值填回 `需求文档.md` §12.2,坑写进 §10 | 随时 |
+16 -3
查看文件
@@ -110,7 +110,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU
| **FR-7** | 对外只用**一根 USB 线**,五路下行由板载 Hub 分 | 必须 | §15.1 |
| **FR-8** | 每路可实时读取**输出电压与电流**;整板可读取**输入电压与电流** | 必须 | §8.2 |
| **FR-9** | 上报每路 eFuse 故障、电源好(PG)等状态 | 必须 | §8.2 |
| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」 | 必须 | §22.6 |
| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」。**固件 0.1.21(2026-10-06 用户定)**:板温(两路 NTC 取高)低于 40 ℃ 风扇断电(≥40 ℃ 开、<38 ℃ 关),40→65 ℃ 按 30 %→100 % 线性调速;温度读不到或上位机给了下限时一律开着。`fan` 命令的下限只抬不压,可带 `sec` 到点自动交回(rack 网页与 AppTask「机架」页都能发) | 必须 | §22.6 |
| **FR-11** | 记录过流 / 故障事件(哪一路、何时、多大电流) | 高 | §8.3 |
| **FR-12** | 模拟电量曲线,避免系统因「电量永远 100%」触发异常逻辑 | 中(可选) | §8.3 |
| **FR-13** | 板级温度监控,超阈值先降流再关路 | 高 | §8.3 |
@@ -234,7 +234,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU
| **IF-12** | 上游 USB | 载板 USB-C(UFP),一根线接电脑 |
| **IF-13** | 调试 USB | 下板 USB-C,ESP32-S3 原生 USB-Serial-JTAG;**VBUS 故意不接**(物理上杜绝反灌 3V3)。**同一个口兼作 SW-12 的兜底命令链路**(原生 USB-Serial-JTAG 本身就是 CDC-ACM,不影响 esptool 下载),分帧要求见 §10.8 |
| **IF-14** | 风扇 | 5 × KF2510-3A(+12 V / 回流 / TACH) |
| **IF-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时先用 IF-13 的 USB 链路配网 |
| **IF-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时用**配网热点**(固件 0.1.19,2026-10-06:没配网 / 连续 60 s 连不上 WiFi 时板子自己开 SoftAP `PhoneRail-<id 末 6 位>`,手机连上在 192.168.4.1 网页里填)或 IF-13 的 USB 链路配网 |
> ⚠️ **IF-12 与 IF-13 是两个长得一样的 USB-C,插错的现象是「adb 整排不见」**,
> 很难联想到是插错口。**丝印必须写清楚**,建议用不同颜色座子或在外壳上做标记。
@@ -765,6 +765,7 @@ ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U
> ⚠️ **别把 WiFi 凭据和 MQTT 地址编译进固件。** 放 NVS,用 USB 链路配置 ——
> 这正好是兜底链路的第二个用途:新板子上架时先插 USB 配网。
> (0.1.19 起多一条不用插线的路:配网热点,见下面「配网热点」那一条。)
命令语义与链路无关,两条链路完全一致:
@@ -842,8 +843,20 @@ base = `phonerail/<id>`,id = 板子 STA MAC 的 12 位小写十六进制(板
| `<base>/tele` | 板 → 服务器 | 0 | 否 | 周期遥测 = 一条 status 回应(无 seq),默认 1 s(`tele_ms` 200~60000) |
| `<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`(最近一次断开原因码)。
`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://`(只该用在局域网)。
- **broker = home 服务器现有 mosquitto 的 8883(TLS)**,与 evse 共用:板子用户名 = id,ACL 用 `pattern … phonerail/%u/…`
只许碰自己的四个 topic;后端用户 `phonerail-backend` 可读写 `phonerail/#`。口令在服务器 `/root/phonerail/mqtt.env`(仅 root)。