比较提交
6
次代码提交
fw-0.1.13-flash
...
main
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
f2e0a7363e | ||
|
|
ece65f7c19 | ||
|
|
3bf4548c62 | ||
|
|
580468e48e | ||
|
|
489cd6a6f3 | ||
|
|
ca7435242f |
+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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 永远不锁(安全,但功能白做)。
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,22 @@
|
||||
/* 配网热点的 ESP 胶水(0.1.19)。只在 IDF 目标里有;判据与安全说明在 prov.h,实现说明在 prov_esp.c 文件头。 */
|
||||
#ifndef PROV_ESP_H
|
||||
#define PROV_ESP_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#include "esp_netif.h"
|
||||
|
||||
/* 定热点名字、挂热点事件。要在 `esp_wifi_start()` 之后、链路任务起来之前调一次。
|
||||
`ap` 是 `esp_netif_create_default_wifi_ap()` 的返回值,`id` 是板子 ID(热点名取它的末 6 位)。 */
|
||||
void prov_esp_init(esp_netif_t *ap, const char *id);
|
||||
|
||||
/* 开 / 关热点(连同网页与 DNS 劫持)。**只在 t_link 里调**(关的时候要等 HTTP 任务退出)。幂等。 */
|
||||
void prov_esp_set(bool on);
|
||||
|
||||
bool prov_esp_on(void); /* 热点现在真的开着吗(开失败时是 false) */
|
||||
int prov_esp_sta_count(void); /* 热点上连着几台 */
|
||||
bool prov_esp_take_touch(void); /* 上次问过之后网页有没有「人的动作」(读了就清) */
|
||||
const char *prov_esp_ssid(void);
|
||||
const char *prov_esp_pass(void);
|
||||
|
||||
#endif /* PROV_ESP_H */
|
||||
+60
-2
@@ -25,6 +25,10 @@ static bool s_inited;
|
||||
/* 上位机 `fan` 命令给的下限(A12)。**跨任务**:命令处理器在执行器任务里写,
|
||||
本模块在热管理任务里读。一个对齐的 16 位量,Xtensa 上读写是原子的,不加锁。 */
|
||||
static volatile uint16_t s_floor;
|
||||
/* 下限的时限(`t_thermal_set_floor_for`)。写的顺序是「先 until、再 timed、最后 floor」,
|
||||
读的一侧最坏只会晚一圈(1 s)才认到新值。 */
|
||||
static volatile uint32_t s_floor_until;
|
||||
static volatile bool s_floor_timed;
|
||||
|
||||
/* 风扇侧占空比 → 真正写进 `hal_pwm` 的值。Q6 是低边管,栅极高 = 把风扇的
|
||||
PWM 线拉低 ⇒ 反相(`t_thermal.h` 硬件那三条的第 2 条)。 */
|
||||
@@ -107,6 +111,7 @@ hal_err_t t_thermal_init(thermal_snap_t *s, therm_temp_fn temp, therm_tach_fn ta
|
||||
return e;
|
||||
}
|
||||
s->fan_en = true;
|
||||
s->fan_starts = 1;
|
||||
s->en_ms = hal_time_now_ms();
|
||||
s->last_ms = s->en_ms;
|
||||
|
||||
@@ -133,6 +138,17 @@ static void tach_step(thermal_snap_t *s, int idx, uint32_t dt_ms, uint16_t duty)
|
||||
const uint32_t delta = now_edges - t->last_edges; /* uint32 相减,回绕安全 */
|
||||
t->last_edges = now_edges;
|
||||
|
||||
/* 风扇断着电:不累加、不判(惯性滑行那几圈的脉冲也不算数),转速如实报 0。
|
||||
`stalled` 不动 —— 见头文件「断电期间不判停转」。 */
|
||||
if (!s->fan_en) {
|
||||
t->win_edges = 0;
|
||||
t->win_on_ms = 0;
|
||||
t->win_ms = 0;
|
||||
t->rpm = 0;
|
||||
t->rpm_valid = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/* 启动宽限期内一直重置窗口:这时候风扇还在起转,脉冲本来就没有或很稀。 */
|
||||
if (s->fan_en && hal_time_elapsed_ms(s->en_ms) < T_THERMAL_SPINUP_MS) {
|
||||
t->win_edges = 0;
|
||||
@@ -216,11 +232,37 @@ hal_err_t t_thermal_step(thermal_snap_t *s)
|
||||
s->temp_errors++;
|
||||
}
|
||||
uint16_t duty = t_thermal_duty_for(s->temp_max_mc, s->temp_valid);
|
||||
if (s_floor_timed && (int32_t)(now_ms - s_floor_until) >= 0) {
|
||||
s_floor = 0; /* 上位机给的时限到了:交回自动 */
|
||||
s_floor_timed = false;
|
||||
}
|
||||
const uint16_t floor_ = s_floor;
|
||||
if (floor_ > duty) {
|
||||
duty = floor_; /* 手动值只抬不压:曲线要得更高时照曲线来 */
|
||||
}
|
||||
(void)apply_duty(s, duty);
|
||||
|
||||
/* ---- 开还是关(`T_THERMAL_FAN_OFF_MC` 上的说明)----
|
||||
读不到温度、上位机要风:一律开。否则按迟滞:开着的掉到 OFF 以下才关,关着的到 ON 才开。 */
|
||||
bool want_on = true;
|
||||
if (s->temp_valid && floor_ == 0) {
|
||||
want_on = s->fan_en ? (s->temp_max_mc >= T_THERMAL_FAN_OFF_MC)
|
||||
: (s->temp_max_mc >= T_THERMAL_FAN_ON_MC);
|
||||
}
|
||||
if (want_on != s->fan_en) {
|
||||
/* 开:先把占空比写好再给 12V(同 init 的顺序);关:先断 12V。写失败就维持原状,下一圈再来。 */
|
||||
if (want_on) {
|
||||
(void)apply_duty(s, duty);
|
||||
}
|
||||
if (hal_gpio_write(BOARD_PIN_FAN_EN, want_on ? 1 : 0) == HAL_OK) {
|
||||
s->fan_en = want_on;
|
||||
if (want_on) {
|
||||
s->en_ms = now_ms; /* 启动宽限从这里重新算 */
|
||||
s->fan_starts++;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* 断着电时占空比报 0:遥测里「duty 300、en false」会让人以为它在转。 */
|
||||
(void)apply_duty(s, s->fan_en ? duty : 0);
|
||||
|
||||
/* ---- 过温降载:本模块只**报**,关路是限功率那一包的事 ---- */
|
||||
if (s->temp_valid) {
|
||||
@@ -241,9 +283,25 @@ hal_err_t t_thermal_step(thermal_snap_t *s)
|
||||
return HAL_OK;
|
||||
}
|
||||
|
||||
void t_thermal_set_floor(uint16_t permille)
|
||||
void t_thermal_set_floor(uint16_t permille) { t_thermal_set_floor_for(permille, 0); }
|
||||
|
||||
void t_thermal_set_floor_for(uint16_t permille, uint32_t ttl_ms)
|
||||
{
|
||||
const bool timed = ttl_ms > 0 && permille > 0;
|
||||
if (timed) {
|
||||
s_floor_until = hal_time_now_ms() + ttl_ms;
|
||||
}
|
||||
s_floor_timed = timed;
|
||||
s_floor = (permille > 1000u) ? 1000u : permille;
|
||||
}
|
||||
|
||||
uint16_t t_thermal_floor(void) { return s_floor; }
|
||||
|
||||
uint32_t t_thermal_floor_left_ms(void)
|
||||
{
|
||||
if (!s_floor_timed || s_floor == 0) {
|
||||
return 0;
|
||||
}
|
||||
const int32_t left = (int32_t)(s_floor_until - hal_time_now_ms());
|
||||
return left > 0 ? (uint32_t)left : 0;
|
||||
}
|
||||
|
||||
+28
-4
@@ -62,6 +62,9 @@
|
||||
⚠️ 与之配套的是**占空比下限** `T_THERMAL_DUTY_MIN`:占空比真掉到 0,
|
||||
导通时间永远攒不满,这条判据就变成了「永远不判」。所以闭环的下限不是 0;
|
||||
真要停风扇只能撤 `FAN_EN`(§22.6:0 % PWM 时四线风扇仍转 3300 RPM)。
|
||||
0.1.21 起凉的时候就是这么停的(`T_THERMAL_FAN_OFF_MC`):断电期间**不判停转**、窗口清零、
|
||||
转速报 0;`stalled` 保持断电前的结论(一只没插的风扇不会因为每次启停各记一条事件)。
|
||||
重新上电后照样先过启动宽限。
|
||||
|
||||
---- 事件:入队,不落盘 --------------------------------------------------
|
||||
`evlog_put()` 是几条指令的入队,任何任务都能调;`evlog_flush()` 是唯一碰 flash
|
||||
@@ -103,8 +106,18 @@ extern "C" {
|
||||
而新位置的温升曲线只有上板才有。这里给的是**形状**(分段线性 + 迟滞),
|
||||
数值先按常识填、单列在这里,上板实测之后只改这四行。 */
|
||||
|
||||
/* 低于它就按下限转(安静优先)。 */
|
||||
/* 曲线起点:这个温度按下限转,往上线性加到满速。 */
|
||||
#define T_THERMAL_TEMP_LO_MC 40000 /* 40.000 °C —— 待实测 */
|
||||
|
||||
/* **40 °C 以下风扇断电**(0.1.21,2026-10-06 用户定):撤 `FAN_EN`,不是把占空比压到 0 ——
|
||||
四线风扇 0 % PWM 仍转 3300 RPM(§22.6),要它停只能断 12V。
|
||||
到 `FAN_ON_MC` 开、掉到 `FAN_OFF_MC` 以下才关:两个数之间维持原状。
|
||||
⚠️ 这 2 °C 的迟滞不能省:板温就在 40 °C 上下晃(板 B 空闲实测 38~41 °C),
|
||||
没有它风扇每隔几秒启停一次,每次起转都是一声响,还各带 5 s 的停转判据盲区。
|
||||
**不受它管的三种情况,风扇一律开着**:两路温度都读不到(§10.10)、上位机给了下限(`fan` 命令)、
|
||||
以及启动后读到第一个温度之前的那一秒。 */
|
||||
#define T_THERMAL_FAN_ON_MC T_THERMAL_TEMP_LO_MC
|
||||
#define T_THERMAL_FAN_OFF_MC 38000
|
||||
/* 高于它满速。 */
|
||||
#define T_THERMAL_TEMP_HI_MC 65000 /* 65.000 °C —— 待实测 */
|
||||
/* 过温降载门限(FR-13「超阈值先降流再关路」)。本模块只**报**,不动电源 ——
|
||||
@@ -204,10 +217,11 @@ typedef struct {
|
||||
int32_t temp_max_mc; /* 两路里**高的那一个**:一点凉不代表另一点不烫 */
|
||||
bool temp_valid; /* 至少有一路读到了 */
|
||||
|
||||
uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径 */
|
||||
uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径;风扇断电时是 0 */
|
||||
uint16_t gpio_duty; /* 真写进 hal_pwm 的值(Q6 反相 ⇒ 1000 − duty) */
|
||||
|
||||
bool fan_en; /* `FAN_EN` 的命令值 */
|
||||
bool fan_en; /* `FAN_EN` 的命令值(凉了会撤,见 `T_THERMAL_FAN_OFF_MC`) */
|
||||
uint32_t fan_starts; /* 上过几次电(含启动那一次):启停太勤 = 迟滞不够 */
|
||||
uint32_t en_ms; /* 拉高的时刻,启动宽限从这里算 */
|
||||
|
||||
bool derate; /* 正在过温降载 */
|
||||
@@ -239,9 +253,19 @@ uint16_t t_thermal_duty_for(int32_t milli_c, bool valid);
|
||||
/* 上位机手动给的占空比**下限**(风扇侧千分比,A12 的 `fan` 命令)。0 = 全自动。
|
||||
⚠️ 只抬不压:实际占空比 = max(温度曲线, 下限)。「手动把风扇调小」这件事
|
||||
不提供 —— 那等于允许远程关掉散热,而温度读不到时满速(§10.10)的兜底也会被它盖掉。
|
||||
下一圈 `t_thermal_step()` 生效;不随 `t_thermal_init()` 清零(重启自然清零)。 */
|
||||
下限 > 0 时风扇一定开着(哪怕板温低于 40 °C):板上的 NTC 看不见手机 SoC 有多热,
|
||||
上位机(AppTask 按手机自报的温度)要风的时候就得有风。
|
||||
下一圈 `t_thermal_step()` 生效;不随 `t_thermal_init()` 清零(重启自然清零)。
|
||||
|
||||
`ttl_ms`(0.1.21):下限保持多久,0 = 一直保持。到期自动回 0(全自动)——
|
||||
给「程序定时续期」的上位机用:它挂了 / 断网了,风扇不会永远停在它最后要的那个转速上。
|
||||
`t_thermal_floor_left_ms()`:还剩多久,没设时限或已经没有下限时是 0。 */
|
||||
void t_thermal_set_floor(uint16_t permille);
|
||||
void t_thermal_set_floor_for(uint16_t permille, uint32_t ttl_ms);
|
||||
uint16_t t_thermal_floor(void);
|
||||
uint32_t t_thermal_floor_left_ms(void);
|
||||
|
||||
#define T_THERMAL_FLOOR_TTL_MAX_S 86400 /* `fan` 命令的 `sec` 上限:一天 */
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),手机首屏偏慢;改成按需引入可以再砍一半。
|
||||
- 没有细分权限:门户授权了这个应用的人都能操作;审计记着是谁。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,界面画成带状)。点太多时按时间桶再抽一次。"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 的命令形状;不模拟倒计时
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 按「字体 → 文字」缓存宽度、从不清空:
|
||||
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 | 随时 |
|
||||
|
||||
@@ -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)。
|
||||
|
||||
在新工单中引用
屏蔽一个用户