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

- 0.1.19 配网热点:没配 WiFi / 连续 60 s 连不上时自己开 SoftAP,192.168.4.1 网页里加删 WiFi、改 MQTT;
  配置拼成 wifi / mqtt 命令走唯一的命令处理器(新链路号 3 CMD_LINK_AP),MQTT 仍不收配置命令
- 0.1.20 热点口令固定 88888888
- 0.1.21 风扇:板温 >=40 ℃ 上电、<38 ℃ 断电,40~65 ℃ 线性调速;fan 命令加可选 sec(下限到点自动交回)
- mqtt 命令加 keep_pass;net 回应加 ap;status 的 fan 加 left
- 新增 test_prov(13 例),test_t_thermal +3;29 ctest 全绿

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-10-06 14:35:44 +08:00
共同撰写人 Claude Opus 5.5
父节点 3bf4548c62
当前提交 ece65f7c19
共修改 24 个文件,包含 1925 行新增和 324 行删除
+6 -3
查看文件
@@ -1,5 +1,5 @@
# ESP-IDF 侧的 main 组件。
# app_main.c / ulink_esp.c / mlink_esp.c / thermal_esp.c / ota_esp.c / warm_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过
# app_main.c / ulink_esp.c / mlink_esp.c / prov_esp.c / thermal_esp.c / ota_esp.c / warm_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过
# (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。
idf_component_register(
SRCS "app_main.c"
@@ -27,6 +27,8 @@ idf_component_register(
"cmd_net.c"
"netcfg.c"
"mlink.c"
"prov.c"
"prov_esp.c"
"bringup.c"
"ntc.c"
"thermal_esp.c"
@@ -34,11 +36,12 @@ idf_component_register(
"ota_esp.c"
"warm_esp.c"
INCLUDE_DIRS "."
EMBED_TXTFILES "certs/evse_ca.pem"
EMBED_TXTFILES "certs/evse_ca.pem" "prov.html"
REQUIRES phonerail_hal drv esp_driver_usb_serial_jtag
esp_wifi esp_netif esp_event esp_timer nvs_flash mqtt
esp_adc esp_driver_pcnt
app_update esp_http_client mbedtls)
app_update esp_http_client mbedtls
esp_http_server lwip)
# warm_esp.c 的能力标记(.rodata_custom_desc,镜像第 288 字节)没有代码引用它,
# 不加 -u 会被 --gc-sections 丢掉 —— 那样每个新固件都会被当成「不认接管」,EN 永远不锁(安全,但功能白做)。
+7 -4
查看文件
@@ -76,17 +76,20 @@ typedef enum {
CMD_ERR_STATE = 8, /* 当前状态下不许(例:eFuse 关着时开 VBUS) */
CMD_ERR_IO = 9, /* 下发到器件失败(I²C) */
CMD_ERR_OVERFLOW = 10, /* 回应装不进缓冲 —— 本模块的 bug,不该出现 */
CMD_ERR_LINK = 11, /* 这条命令不许从这条链路来(经 WiFi 改 WiFi 配置) */
CMD_ERR_LINK = 11, /* 这条命令不许从这条链路来(经 MQTT 改 WiFi 配置) */
CMD_ERR_COUNT,
} cmd_err_t;
/* 哪条链路来的。进 `LOG_EV_REBOOT` 的 arg 高 4 位。§10.8:两条链路的命令语义完全一致,
**唯一的例外**是 `wifi` / `mqtt` 两条配置命令只收 USB(netcfg.h:经 WiFi 改 WiFi 配置,
一次手误就把唯一的远程通道弄断了)。 */
**唯一的例外**是 `wifi` / `mqtt` 两条配置命令不收 MQTT(netcfg.h:经 WiFi 改 WiFi 配置,
一次手误就把唯一的远程通道弄断了)。
`CMD_LINK_AP`(0.1.19)= 配网热点的网页(prov.h):人就在板子旁边,和 USB 一样收配置命令;
这条链路上只会出现 `wifi` / `mqtt` / `net` 三条(prov_esp.c 只拼这三条)。 */
typedef enum {
CMD_LINK_TEST = 0,
CMD_LINK_USB = 1,
CMD_LINK_WIFI = 2,
CMD_LINK_WIFI = 2, /* MQTT */
CMD_LINK_AP = 3,
} cmd_link_t;
/* `LOG_EV_REBOOT` 的 arg:bit0 = 1 硬(断电)/ 0 软(长按电源键),bit4~7 = 链路。 */
+14 -1
查看文件
@@ -401,8 +401,21 @@ cmd_err_t do_fan(ctx_t *c)
if (duty < 0 || duty > 1000) {
return reject(c, CMD_ERR_RANGE, duty);
}
/* `sec`(0.1.21,可选):这个下限保持多少秒,到期自动回 0。不给 / 0 = 一直保持(老行为)。
定时续期的上位机(AppTask)用它:它挂了,风扇不会永远停在最后那个转速上。 */
int64_t sec = 0;
const int gs = cj_get_int(c->o, "sec", &sec);
if (gs < 0) {
return CMD_ERR_ARG;
}
if (sec < 0 || sec > T_THERMAL_FLOOR_TTL_MAX_S) {
return reject(c, CMD_ERR_RANGE, sec);
}
/* 手动值只是**下限**:温度曲线要得更高时照曲线来(t_thermal.h)。0 = 交回自动。 */
t_thermal_set_floor((uint16_t)duty);
t_thermal_set_floor_for((uint16_t)duty, (uint32_t)sec * 1000u);
cj_w(c->w, ",\"floor\":%u", (unsigned)t_thermal_floor());
if (gs == 1) {
cj_w(c->w, ",\"sec\":%lu", (unsigned long)((t_thermal_floor_left_ms() + 999u) / 1000u));
}
return CMD_OK;
}
+28 -6
查看文件
@@ -1,4 +1,4 @@
/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB**),`net` 读状态;
/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB 与配网热点**),`net` 读状态;
外加 C2 的 `ota`(下载固件,两条链路都收)。
`net` 的回应里**永远没有密码**,只有 `pass_set` / `mpass_set`(netcfg.h「密码永不外泄」)。 */
@@ -7,9 +7,11 @@
#include "cmd_priv.h"
#include "netcfg.h"
#include "ota.h"
#include "prov.h"
/* 配置命令只收 USB(cmd.h 链路那一段)。宿主机测试用的 CMD_LINK_TEST 视同 USB。 */
static bool usb_only_ok(const ctx_t *c) { return c->link != CMD_LINK_WIFI; }
/* 配置命令不收 MQTT(cmd.h 链路那一段):USB 与配网热点都是「人在板子旁边」。
宿主机测试用的 CMD_LINK_TEST 视同 USB。 */
static bool local_ok(const ctx_t *c) { return c->link != CMD_LINK_WIFI; }
static cmd_err_t get_str(const ctx_t *c, const char *key, bool required, const char **out)
{
@@ -27,7 +29,7 @@ static cmd_err_t get_str(const ctx_t *c, const char *key, bool required, const c
cmd_err_t do_wifi(ctx_t *c)
{
if (!usb_only_ok(c)) {
if (!local_ok(c)) {
c->bad = CMD_WIFI;
return CMD_ERR_LINK;
}
@@ -69,7 +71,7 @@ cmd_err_t do_wifi(ctx_t *c)
cmd_err_t do_mqtt(ctx_t *c)
{
if (!usb_only_ok(c)) {
if (!local_ok(c)) {
c->bad = CMD_MQTT;
return CMD_ERR_LINK;
}
@@ -77,11 +79,23 @@ cmd_err_t do_mqtt(ctx_t *c)
const char *user = NULL;
const char *mpass = NULL;
int64_t tele = 0;
bool keep = false;
const int gt = cj_get_int(c->o, "tele_ms", &tele);
if (get_str(c, "uri", true, &uri) != CMD_OK || get_str(c, "user", false, &user) != CMD_OK ||
get_str(c, "pass", false, &mpass) != CMD_OK || gt < 0) {
get_str(c, "pass", false, &mpass) != CMD_OK || gt < 0 ||
cj_get_bool(c->o, "keep_pass", &keep) < 0) {
return CMD_ERR_ARG;
}
/* `"keep_pass":true`(0.1.19)= 口令沿用已存的那份:配网网页看不到旧口令(永不回显),
只想改地址时不该逼人重输。同时又给了 `pass` 是自相矛盾,拒。 */
static netcfg_t cur; /* ≈560 B,不放栈上(只有执行器任务进来) */
if (keep) {
if (mpass[0] != '\0') {
return CMD_ERR_ARG;
}
netcfg_get(&cur);
mpass = cur.mpass;
}
if (tele < 0 || tele > NETCFG_TELE_MAX_MS ||
!netcfg_mqtt_ok(uri, user, mpass, (uint32_t)tele)) {
c->bad = tele;
@@ -139,6 +153,14 @@ cmd_err_t do_net(ctx_t *c)
cfg.mpass[0] ? "true" : "false", (unsigned long)cfg.tele_ms,
(unsigned long)st.wifi_drops, (unsigned long)st.mqtt_drops,
(unsigned long)st.tele_sent, (unsigned long)st.tele_skipped);
/* 配网热点(prov.h)。`pass` 是**热点自己的**口令,不是上面那两个「永不外泄」的:
人得先知道它才连得上热点,而读到它的前提是已经在 USB / MQTT 上了。 */
cj_w(w, ",\"ap\":{\"on\":%s,\"why\":\"%s\",\"sta\":%u,\"ssid\":", st.ap_on ? "true" : "false",
prov_why_str((prov_why_t)st.ap_why), (unsigned)st.ap_sta);
w_str(w, st.ap_ssid);
cj_w(w, ",\"pass\":");
w_str(w, st.ap_pass);
cj_w(w, "}");
return CMD_OK;
}
+3 -2
查看文件
@@ -88,8 +88,9 @@ static void thermal_obj(cj_writer_t *w, const thermal_snap_t *t)
cj_w(w, "%snull", i ? "," : "");
}
}
cj_w(w, "],\"fan\":{\"duty\":%u,\"floor\":%u,\"en\":%s,\"rpm\":[", (unsigned)t->duty,
(unsigned)t_thermal_floor(), b(t->fan_en));
cj_w(w, "],\"fan\":{\"duty\":%u,\"floor\":%u,\"left\":%lu,\"en\":%s,\"rpm\":[", (unsigned)t->duty,
(unsigned)t_thermal_floor(), (unsigned long)((t_thermal_floor_left_ms() + 999u) / 1000u),
b(t->fan_en));
for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) {
if (t->fan[i].rpm_valid) {
cj_w(w, "%s%lu", i ? "," : "", (unsigned long)t->fan[i].rpm);
+64 -5
查看文件
@@ -18,7 +18,9 @@
---- 失联不动作(§10.2 / §10.8) -----------------------------------------
WiFi 断、broker 断,本文件只做一件事:**重连**(WiFi 指数退避,MQTT 由 esp-mqtt 每 10 s 重试)。
没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。 */
没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。
0.1.19 起多一件:WiFi 连续 60 s 连不上就**开配网热点**(prov.h),STA 照常在后台重连,
连上了热点自己关。热点同样不碰输出。 */
#include <string.h>
#include "cmd_queue.h"
@@ -39,6 +41,8 @@
#include "nvs.h"
#include "nvs_flash.h"
#include "phonerail.h"
#include "prov.h"
#include "prov_esp.h"
#include "tasks.h"
#include "ulink.h"
@@ -91,6 +95,9 @@ static uint32_t s_attempt;
static mlink_tele_t s_tele;
static volatile bool s_tele_inflight;
static bool s_up;
static volatile bool s_slow; /* 有人在配网网页上:重连间隔不短于 PROV_SLOW_MS(prov.h) */
static uint32_t s_ap_try_ms; /* 上次开 / 关热点是什么时候(没成功时别每拍都试) */
static bool s_ap_tried;
/* 发件队列(见文件头):执行器 / esp-mqtt 任务往里拷,t_link 取出来交给 esp-mqtt。
深度 4 × 1.5 KB:遥测 1 s 一条、回应偶尔一条,t_link 至少每 100 ms 来取一次;
@@ -343,6 +350,16 @@ static void wifi_set_sta(uint8_t idx)
ST_EDIT(s_st.wifi_idx = idx);
}
/* 发起一次连接。驱动当场拒了(配网网页正在扫描时会)就不会有断开事件来接着排下一次 ——
重连链在这里断掉 = 板子永远不回网,所以自己排一次。 */
static void sta_connect(void)
{
if (esp_wifi_connect() != ESP_OK) {
esp_timer_stop(s_retry);
esp_timer_start_once(s_retry, 3000u * 1000u);
}
}
static void retry_cb(void *arg)
{
(void)arg;
@@ -350,7 +367,7 @@ static void retry_cb(void *arg)
if (s_st.wifi != NET_UP && n > 0) {
const uint8_t i = s_idx < n ? s_idx : 0;
wifi_set_sta(i);
esp_wifi_connect();
sta_connect();
}
}
@@ -374,7 +391,10 @@ static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data)
if (!was_up && n > 1) {
s_idx = (uint8_t)((s_idx + 1) % n);
}
const uint32_t ms = mlink_backoff_ms(s_attempt++ / (uint32_t)n);
uint32_t ms = mlink_backoff_ms(s_attempt++ / (uint32_t)n);
if (s_slow && ms < PROV_SLOW_MS) {
ms = PROV_SLOW_MS; /* 每试一次热点都要离开信道扫一圈,试密了网页就卡(prov.h) */
}
ESP_LOGW(TAG, "WiFi 断开,原因 %u,%u ms 后连「%s」", (unsigned)d->reason, (unsigned)ms,
s_cur.wifi[s_idx < n ? s_idx : 0].ssid);
esp_timer_stop(s_retry);
@@ -407,10 +427,33 @@ static void wifi_apply(void)
}
wifi_set_sta(s_idx);
ST_EDIT(s_st.wifi = NET_CONNECTING);
esp_wifi_connect();
sta_connect();
ESP_LOGI(TAG, "连 WiFi「%s」(%u/%d)", s_cur.wifi[s_idx].ssid, (unsigned)s_idx + 1, n); /* 只打 SSID,不打口令 */
}
/* ---- 配网热点(t_link 里调):判据在 prov.c,开关在 prov_esp.c ------------- */
static void prov_poll(void)
{
const uint32_t now = hal_time_now_ms();
const prov_in_t in = { .n_wifi = netcfg_wifi_count(&s_cur), .wifi_up = s_st.wifi == NET_UP,
.ap_sta = prov_esp_sta_count(), .touched = prov_esp_take_touch() };
const prov_out_t o = prov_step(&in, now);
s_slow = o.hold;
if (o.ap != prov_esp_on() && (!s_ap_tried || (uint32_t)(now - s_ap_try_ms) >= 10000u)) {
s_ap_tried = true;
s_ap_try_ms = now;
prov_esp_set(o.ap); /* 没成功就 10 s 后再来,不是每拍都试 */
}
const bool on = prov_esp_on();
if (on == o.ap) {
s_ap_tried = false;
}
if (on != s_st.ap_on || (uint8_t)in.ap_sta != s_st.ap_sta || (uint8_t)o.why != s_st.ap_why) {
ST_EDIT(s_st.ap_on = on; s_st.ap_sta = (uint8_t)in.ap_sta; s_st.ap_why = (uint8_t)o.why);
}
}
/* ---- 对外 ---------------------------------------------------------------- */
hal_err_t mlink_esp_init(void)
@@ -444,6 +487,7 @@ hal_err_t mlink_esp_init(void)
ESP_LOGE(TAG, "网络栈起不来 —— WiFi 链路不可用");
return HAL_ERR_STATE;
}
esp_netif_t *ap_netif = esp_netif_create_default_wifi_ap(); /* 配网热点用;平时模式是 STA,它闲着 */
const wifi_init_config_t wi = WIFI_INIT_CONFIG_DEFAULT();
if (esp_wifi_init(&wi) != ESP_OK) {
ESP_LOGE(TAG, "WiFi 驱动起不来 —— WiFi 链路不可用");
@@ -454,10 +498,14 @@ hal_err_t mlink_esp_init(void)
esp_wifi_set_storage(WIFI_STORAGE_RAM); /* 凭据只存我们自己的那份,驱动别再存一份 */
esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_start();
prov_init(hal_time_now_ms());
prov_esp_init(ap_netif, mlink_id());
ST_EDIT(snprintf(s_st.ap_ssid, sizeof(s_st.ap_ssid), "%s", prov_esp_ssid());
snprintf(s_st.ap_pass, sizeof(s_st.ap_pass), "%s", prov_esp_pass()));
s_up = true;
ESP_LOGI(TAG, "板子 ID %s,topic %s/…", mlink_id(), mlink_topic(MLINK_T_CMD));
if (!have) {
ESP_LOGW(TAG, "还没配网:经 USB 发 wifi / mqtt 两条命令(tools/prlink.py)");
ESP_LOGW(TAG, "还没配网:连热点「%s」开网页,或经 USB 发 wifi / mqtt(tools/prlink.py)", prov_esp_ssid());
}
wifi_apply();
return HAL_OK;
@@ -481,14 +529,25 @@ void mlink_esp_poll(void)
if (!(wifi_same && mqtt_same)) {
mqtt_stop();
}
/* 没连着的时候:刚加 / 刚改口令的那一组先试 —— 人正等着看它连不连得上(配网网页尤其)。 */
uint8_t first = s_idx;
for (int i = 0; i < netcfg_wifi_count(&next); i++) {
const int k = netcfg_wifi_find(&s_cur, next.wifi[i].ssid);
if (k < 0 || strcmp(s_cur.wifi[k].pass, next.wifi[i].pass) != 0) {
first = (uint8_t)i;
break;
}
}
s_cur = next;
if (wifi_same) {
s_idx = (uint8_t)j;
ST_EDIT(s_st.wifi_idx = s_idx);
} else {
s_idx = first;
wifi_apply();
}
}
prov_poll();
/* MQTT 的生命周期只在这里管:WiFi 有地址、配了 broker、还没建客户端 ⇒ 建。
之后掉线重连由 esp-mqtt 自己做(network.reconnect_timeout_ms,默认 10 s)。 */
if (s_mqtt == NULL && s_st.wifi == NET_UP && s_cur.uri[0] != '\0') {
+2 -2
查看文件
@@ -6,10 +6,10 @@
/* NVS → 配置 → WiFi 驱动 → 按配置连网。要在命令队列建好之后、任务起来之前调。
失败不 abort:WiFi 链路不可用,USB 链路与五路供电照常(§10.2)。
没配过网时只起驱动、不连,等 USB 下发 `wifi` / `mqtt`。 */
没配过网时只起驱动、不连,开配网热点(prov.h),等网页或 USB 下发 `wifi` / `mqtt`。 */
hal_err_t mlink_esp_init(void);
/* 链路任务每圈调一次(≤100 ms 一次):应用配置变更、按需建 MQTT 客户端、推遥测。 */
/* 链路任务每圈调一次(≤100 ms 一次):应用配置变更、开关配网热点、按需建 MQTT 客户端、推遥测。 */
void mlink_esp_poll(void);
#endif /* MLINK_ESP_H */
+9 -2
查看文件
@@ -4,8 +4,9 @@
USB 链路配网」。所以:
· 配置由两条命令写入:`wifi`(SSID / 密码,**最多存 NETCFG_WIFI_N 组**,0.1.9)与
`mqtt`(broker 地址 / 账号 / 遥测周期),
**只收 USB 链路来的**(cmd.c 回 `link`)—— 经 WiFi 改 WiFi 配置,一次手误就把
唯一的远程通道弄断了,而那时只能拆机插 USB;
**不收 MQTT 来的**(cmd.c 回 `link`)—— 经 WiFi 改 WiFi 配置,一次手误就把
唯一的远程通道弄断了。收的是两条「人在板子旁边」的链路:USB,和 0.1.19 起
连不上 WiFi 时自己开的配网热点(prov.h —— 所以弄断了也不用再拆机插 USB);
· 本模块只管「校验 + 在内存里持有一份 + 调存储后端 + 通知网络那一侧」,
NVS 与 WiFi/MQTT 都在 ESP 胶水里(mlink_esp.c),这一半宿主机可测。
@@ -110,6 +111,12 @@ typedef struct {
202 = 认证失败,15 = 四次握手超时 ≈ 口令错),0 = 没断过 */
uint8_t wifi_idx; /* 正在连 / 已连上的是表里第几组(net 回应的 ssid 取它) */
char id[13]; /* 板子 ID = STA MAC 的 12 位小写十六进制,topic 用它 */
/* 配网热点(prov.h,0.1.19)。ap_pass 是热点自己的口令,`net` 会报(见 cmd_net.c)。 */
bool ap_on;
uint8_t ap_why; /* prov_why_t */
uint8_t ap_sta; /* 热点上连着几台 */
char ap_ssid[20];
char ap_pass[12];
} net_status_t;
void net_status_set(const net_status_t *st);
+1 -1
查看文件
@@ -3,7 +3,7 @@
#define PHONERAIL_H
/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */
#define PHONERAIL_FW_VERSION "0.1.18-c5"
#define PHONERAIL_FW_VERSION "0.1.21-c5"
/* 通道数。五路直供,见设计文档 §2。 */
#define PHONERAIL_RAIL_COUNT 5
+206
查看文件
@@ -0,0 +1,206 @@
/* 配网热点的平台无关部分。判据表与表单格式在 prov.h。 */
#include "prov.h"
#include <stdio.h>
#include <string.h>
#include "cmd.h"
#include "netcfg.h"
static bool s_ap;
static bool s_was_up;
static uint32_t s_since; /* STA 上一次「有地址 ↔ 没地址」翻转的时刻(启动算一次) */
static bool s_aged; /* 翻转之后已经过了门限(过了就锁住:32 位时钟 49.7 天回绕,放着不管会「变年轻」) */
static bool s_touch; /* hold 窗口还开着(过期即清,同上) */
static uint32_t s_touch_at;
static int s_prev_sta;
void prov_init(uint32_t now)
{
s_ap = false;
s_was_up = false;
s_since = now;
s_aged = false;
s_touch = false;
s_touch_at = now;
s_prev_sta = 0;
}
prov_out_t prov_step(const prov_in_t *in, uint32_t now)
{
if (in->wifi_up != s_was_up) {
s_was_up = in->wifi_up;
s_since = now;
s_aged = false;
}
if (!s_aged && (uint32_t)(now - s_since) >= (in->wifi_up ? PROV_LINGER_MS : PROV_FAIL_MS)) {
s_aged = true;
}
if (in->touched || in->ap_sta > s_prev_sta) { /* 有人刚连上热点也算一次 */
s_touch = true;
s_touch_at = now;
}
s_prev_sta = in->ap_sta;
if (s_touch && (uint32_t)(now - s_touch_at) >= PROV_HOLD_MS) {
s_touch = false;
}
prov_out_t o = { .ap = false, .hold = false, .why = PROV_WHY_OFF };
if (in->wifi_up) {
if (s_ap && in->ap_sta > 0 && !s_aged) {
o.ap = true;
o.why = PROV_WHY_LINGER;
}
} else if (in->n_wifi <= 0) {
o.ap = true;
o.why = PROV_WHY_NOCFG;
} else if (s_aged || s_ap) {
/* `|| s_ap`:开着就不因为「刚配了一组、还没满 60 s」而关掉 —— 那正是人在网页上点完保存的那一刻。 */
o.ap = true;
o.why = PROV_WHY_FAIL;
}
o.hold = o.ap && !in->wifi_up && in->ap_sta > 0 && s_touch;
s_ap = o.ap;
return o;
}
const char *prov_why_str(prov_why_t w)
{
switch (w) {
case PROV_WHY_OFF: return "off";
case PROV_WHY_NOCFG: return "nocfg";
case PROV_WHY_FAIL: return "fail";
case PROV_WHY_LINGER: return "linger";
}
return "?";
}
/* ---- 名字与口令 ------------------------------------------------------------ */
void prov_ssid(const char *id, char out[PROV_SSID_MAX])
{
const size_t n = strlen(id);
snprintf(out, PROV_SSID_MAX, "PhoneRail-%s", n > 6 ? id + n - 6 : id);
}
/* ---- 表单 → 命令行 --------------------------------------------------------- */
#define FIELD_MAX 64 /* = CMD_JSON_STR_MAX − 1:再长解析器也不收 */
#define FIELDS 5
/* 把正文按 \n 切成至多 FIELDS 段(行尾的 \r 去掉),拷进 f[]。返回段数;−1 = 太长 / 段太多,−2 = 有坏字符。 */
static int split(const char *body, char f[FIELDS][FIELD_MAX + 1])
{
int n = 0;
size_t len = 0;
f[0][0] = '\0';
for (const char *p = body;; p++) {
if (*p == '\n' || *p == '\0') {
if (len > 0 && f[n][len - 1] == '\r') {
len--;
}
f[n][len] = '\0';
for (size_t i = 0; i < len; i++) {
const unsigned char c = (unsigned char)f[n][i];
if (c < 0x20 || c == 0x7F || c == '"' || c == '\\') {
return -2;
}
}
n++;
if (*p == '\0') {
return n;
}
if (n >= FIELDS) {
return -1;
}
len = 0;
continue;
}
if (len >= FIELD_MAX) {
return -1;
}
f[n][len++] = *p;
}
}
size_t prov_build_line(const char *body, uint32_t seq, char *out, size_t cap, const char **err)
{
static const char *dummy;
if (err == NULL) {
err = &dummy;
}
*err = "bad_form";
if (body == NULL || out == NULL || cap == 0 || seq == 0) {
return 0;
}
char f[FIELDS][FIELD_MAX + 1];
const int n = split(body, f);
if (n < 0) {
*err = (n == -2) ? "bad_char" : "too_long";
return 0;
}
for (int i = n; i < FIELDS; i++) {
f[i][0] = '\0';
}
int w;
if (strcmp(f[0], "wifi") == 0 && n >= 2 && n <= 3 && f[1][0] != '\0') {
w = snprintf(out, cap, "{\"seq\":%lu,\"cmd\":\"wifi\",\"ssid\":\"%s\",\"pass\":\"%s\"}",
(unsigned long)seq, f[1], f[2]);
} else if (strcmp(f[0], "del") == 0 && n == 2 && f[1][0] != '\0') {
w = snprintf(out, cap, "{\"seq\":%lu,\"cmd\":\"wifi\",\"ssid\":\"%s\",\"del\":true}",
(unsigned long)seq, f[1]);
} else if (strcmp(f[0], "mqtt") == 0 && n >= 2 && f[1][0] != '\0') {
unsigned long tele = 0;
for (const char *p = f[4]; *p != '\0'; p++) {
if (*p < '0' || *p > '9' || tele > NETCFG_TELE_MAX_MS) {
return 0;
}
tele = tele * 10u + (unsigned long)(*p - '0');
}
/* 口令留空 = 沿用已存的那份:网页上看不到旧口令(永不回显),不该逼人每次重输。 */
if (f[3][0] == '\0') {
w = snprintf(out, cap,
"{\"seq\":%lu,\"cmd\":\"mqtt\",\"uri\":\"%s\",\"user\":\"%s\",\"keep_pass\":true,"
"\"tele_ms\":%lu}",
(unsigned long)seq, f[1], f[2], tele);
} else {
w = snprintf(out, cap,
"{\"seq\":%lu,\"cmd\":\"mqtt\",\"uri\":\"%s\",\"user\":\"%s\",\"pass\":\"%s\","
"\"tele_ms\":%lu}",
(unsigned long)seq, f[1], f[2], f[3], tele);
}
} else {
return 0;
}
if (w <= 0 || (size_t)w >= cap || w >= CMD_LINE_MAX) {
*err = "too_long";
out[0] = '\0';
return 0;
}
*err = NULL;
return (size_t)w;
}
size_t prov_json_esc(const char *s, char *out, size_t cap)
{
size_t n = 0;
for (; *s != '\0'; s++) {
const unsigned char c = (unsigned char)*s;
const bool esc = c < 0x20 || c == 0x7F || c == '"' || c == '\\';
if (n + (esc ? 6u : 1u) >= cap) {
if (cap > 0) {
out[0] = '\0';
}
return 0;
}
if (esc) {
n += (size_t)snprintf(out + n, cap - n, "\\u%04x", (unsigned)c);
} else {
out[n++] = (char)c;
}
}
if (cap > 0) {
out[n] = '\0';
}
return n;
}
+104
查看文件
@@ -0,0 +1,104 @@
/* 配网热点(SoftAP + 网页,0.1.19,2026-10-06 用户定)的平台无关部分。
ESP 胶水(热点、HTTP 服务、DNS 劫持、热点口令的 NVS)在 prov_esp.c。
---- 为什么要有 ------------------------------------------------------------
0.1.18 之前改网络配置只有 USB 一条路(netcfg.h):路由器换了口令 / 板子搬了地方,
就得拆机插线,而 Hub 休眠之后连 COM 口都不在。现在:**连不上 WiFi 就自己开一个热点**,
手机连上去、在 192.168.4.1 的网页里改。
---- 什么时候开、什么时候关(`prov_step`)---------------------------------
| 情形 | 热点 |
|---|---|
| 一组 WiFi 都没配 | 立刻开(`nocfg`) |
| 配了,但连续 `PROV_FAIL_MS` 没拿到地址(含启动后一直连不上、连上过又掉) | 开(`fail`) |
| 开着、STA 仍没连上 | **一直开着**(不设超时:超时关掉 = 又只剩拆机) |
| STA 连上了,热点上没人 | 立刻关 |
| STA 连上了,热点上还有人 | 再留 `PROV_LINGER_MS` 让网页看到「连上了」,然后关(`linger`) |
⚠️ 路由器重启这类短暂断网也会让热点冒出来一会儿,网一恢复它就自己关。这是有意的:
「等多久才算真的连不上」没有对的数,而热点开着不碰任何输出 —— 五台手机照常供电(§10.2)。
---- 热点开着时 STA 还在重连 ----------------------------------------------
STA 每试一次都要离开热点的信道去扫一圈(≈2~3 s),试得太密网页就卡。所以
**热点上有人、且最近 `PROV_HOLD_MS` 内动过网页**时,重连间隔不短于 `PROV_SLOW_MS`
(`hold`,mlink_esp.c 的 on_wifi 里用)。只是放慢、**从不停**:停掉的话,
一台连着热点忘了断的手机就能让板子永远不回网。
---- 安全 ------------------------------------------------------------------
· 热点是 WPA2,口令**所有板子都是 `PROV_PASS`(88888888,0.1.20,2026-10-06 用户定)**,编在固件里。
0.1.19 是每板一份随机口令存 NVS —— 断网时人手边未必有那张纸条,用户选了好记的。
`net` 命令的 `ap.pass` 照样报它;它不是 netcfg.h 说的那「两个密码」:知道它只能**写**配置,
网页与 `net` 一样从不回显 WiFi / MQTT 口令。
· 网页只做三件事:读 `net`、扫描、提交 `wifi` / `mqtt`。别的命令从这条链路进不来
(`prov_build_line` 只会拼出这两条)。
· 能连上热点的人可以把板子指到另一个 broker —— 等同于拿到这块板。口令是公开的固定值,
所以这道门实际只靠两条:热点只在「板子没网」时才开,而且人必须在无线范围内。 */
#ifndef PROV_H
#define PROV_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define PROV_FAIL_MS 60000u /* STA 连续这么久没地址 ⇒ 开热点 */
#define PROV_LINGER_MS 60000u /* STA 连上后,热点上还有人时再留多久 */
#define PROV_HOLD_MS 300000u /* 最后一次动网页之后,放慢重连持续多久 */
#define PROV_SLOW_MS 20000u /* 放慢时两次重连之间至少隔多久 */
typedef enum {
PROV_WHY_OFF = 0,
PROV_WHY_NOCFG,
PROV_WHY_FAIL,
PROV_WHY_LINGER,
} prov_why_t;
typedef struct {
int n_wifi; /* 表里有几组 WiFi */
bool wifi_up; /* STA 有地址 */
int ap_sta; /* 热点上连着几台 */
bool touched; /* 上一拍以来网页有过「人的动作」(打开页面 / 扫描 / 提交;状态轮询不算) */
} prov_in_t;
typedef struct {
bool ap; /* 热点该开着 */
bool hold; /* STA 重连要放慢 */
prov_why_t why;
} prov_out_t;
void prov_init(uint32_t now);
prov_out_t prov_step(const prov_in_t *in, uint32_t now);
const char *prov_why_str(prov_why_t w);
/* ---- 热点的名字与口令 ------------------------------------------------------ */
#define PROV_SSID_MAX 20 /* "PhoneRail-" + id 末 6 位 + 0 */
#define PROV_PASS "88888888" /* 热点口令,所有板子一样(见文件头「安全」)。WPA2 要 8~63 位 */
/* `PhoneRail-<id 末 6 位>`。 */
void prov_ssid(const char *id, char out[PROV_SSID_MAX]);
/* ---- 网页表单 → 一行命令 ---------------------------------------------------
网页 POST 的正文是按行分的纯文本(免得在固件里做 URL 解码):
wifi\n<ssid>\n<口令> 加一组 / 改同名那组的口令
del\n<ssid> 删一组
mqtt\n<uri>\n<账号>\n<口令>\n<tele_ms> 口令留空 = 不改(`keep_pass`);tele_ms 留空 = 默认
拼出来的就是 USB 上那两条命令,照样进唯一的命令处理器(cmd.h),校验一处都不重写。
命令的 JSON 解析器不认转义(cmd_json.h),所以字段里有引号 / 反斜杠 / 控制字符一律拒。
返回行长;0 = 拒,`*err` 是原因(`bad_form` / `bad_char` / `too_long`)。 */
size_t prov_build_line(const char *body, uint32_t seq, char *out, size_t cap, const char **err);
/* 一段不可信的字符串(扫描到的 SSID)→ JSON 字符串的内容:引号 / 反斜杠 / 控制字符写成 \u00XX,
其余字节原样(按 UTF-8 理解)。返回写了几个字符;装不下返回 0 且 out 为空串。 */
size_t prov_json_esc(const char *s, char *out, size_t cap);
#ifdef __cplusplus
}
#endif
#endif /* PROV_H */
+92
查看文件
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="zh">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PhoneRail 配网</title>
<style>
body{font:16px/1.5 system-ui,sans-serif;margin:0;padding:16px;max-width:520px;background:#f4f5f7;color:#1c1e21}
h1{font-size:20px;margin:0 0 4px}h2{font-size:16px;margin:0 0 8px}
section{background:#fff;border-radius:8px;padding:12px 14px;margin:12px 0}
input[type=text],input[type=password]{width:100%;box-sizing:border-box;font-size:16px;padding:9px;margin:4px 0;border:1px solid #c5c8ce;border-radius:6px}
button{font-size:16px;padding:9px 14px;margin:6px 6px 0 0;border:0;border-radius:6px;background:#1668dc;color:#fff}
button.s{background:#e6e8eb;color:#1c1e21;padding:4px 10px;font-size:14px;margin:0}
.row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #eceef0}
.row:last-child{border:0}.dim{color:#6b7078;font-size:14px}.ok{color:#18794e}.bad{color:#c0362c}
.msg{min-height:1.5em;margin-top:6px}
</style>
<h1>PhoneRail 配网</h1>
<div class="dim" id="hd">读取中…</div>
<section><h2>状态</h2><div id="st">读取中…</div></section>
<section><h2>WiFi</h2>
<div id="saved"></div>
<button class="s" id="scan" type="button" style="margin-top:8px">扫描附近的 WiFi</button>
<div id="aps"></div>
<form id="fw">
<input type="text" id="ssid" placeholder="WiFi 名称" autocapitalize="off" autocomplete="off" required>
<input type="password" id="pass" placeholder="口令(开放网络留空)" autocomplete="off">
<label class="dim"><input type="checkbox" id="show"> 显示口令</label><br>
<button>保存并连接</button>
</form>
<div class="msg" id="mw"></div>
</section>
<section><h2>服务器(MQTT)</h2>
<form id="fm">
<input type="text" id="uri" placeholder="mqtts://主机:8883" autocapitalize="off" autocomplete="off" required>
<input type="text" id="user" placeholder="账号" autocapitalize="off" autocomplete="off">
<input type="password" id="mp" placeholder="口令(留空 = 不改)" autocomplete="off">
<button>保存</button>
</form>
<div class="msg" id="mm"></div>
</section>
<p class="dim">底板连上 WiFi 后约 1 分钟会自动关掉这个热点。口令只能写入,页面上不会显示已存的口令。</p>
<script>
var $=function(i){return document.getElementById(i)},tele='',filled=false;
var ERR={range:'长度不对:WiFi 口令要 8~63 位(开放网络留空),服务器地址以 mqtt:// 或 mqtts:// 开头',
state:'已经存满 4 组,先删掉一组',bad_char:'不支持引号、反斜杠和换行',too_long:'太长了',
bad_form:'没填完整',io:'写入闪存失败',arg:'参数不对',busy:'底板正忙,过几秒再试',timeout:'底板没有回应,再试一次'};
var WHY={201:'找不到这个 WiFi(名字不对 / 太远 / 只有 5G)',202:'口令不对',15:'口令不对(握手超时)',204:'口令不对(握手失败)',2:'认证过期',200:'信号太弱,掉线'};
var ST={off:'未配置',connecting:'连接中',up:'已连上'};
function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!=null)e.textContent=x;return e}
function row(k,v,c){var r=el('div','row');r.appendChild(el('span','dim',k));r.appendChild(el('span',c||'',v));return r}
function post(body){return fetch('/c',{method:'POST',body:body}).then(function(r){return r.json()})}
function say(id,j,okText){var m=$(id);m.className='msg '+(j.ok?'ok':'bad');m.textContent=j.ok?okText:('没保存:'+(ERR[j.err]||j.err))}
function fail(id){return function(){var m=$(id);m.className='msg bad';m.textContent='没连上底板,确认手机还连着 PhoneRail 热点'}}
function draw(d){
var n=d.net;$('hd').textContent='板子 '+n.id+' · 固件 '+d.fw;
var s=$('st');s.textContent='';
var w=ST[n.wifi]||n.wifi;
if(n.wifi=='up')w+='「'+n.ssid+'」 '+n.ip+'('+n.rssi+' dBm)';
else if(n.wifi=='connecting')w+='「'+n.ssid+'」';
s.appendChild(row('WiFi',w,n.wifi=='up'?'ok':''));
if(n.wifi!='up'&&n.wifi_reason)s.appendChild(row('上次失败',WHY[n.wifi_reason]||('原因码 '+n.wifi_reason),'bad'));
s.appendChild(row('服务器',n.uri?(ST[n.mqtt]||n.mqtt):'未配置',n.mqtt=='up'?'ok':''));
var v=$('saved');v.textContent='';
if(!n.ssids.length)v.appendChild(el('div','dim','还没有保存任何 WiFi'));
n.ssids.forEach(function(x){var r=el('div','row');r.appendChild(el('span','',x));
var b=el('button','s','删除');b.type='button';
b.onclick=function(){if(confirm('删除「'+x+'」?'))post('del\n'+x).then(function(j){say('mw',j,'已删除');poll()}).catch(fail('mw'))};
r.appendChild(b);v.appendChild(r)});
tele=n.tele_ms||'';
if(!filled){filled=true;$('uri').value=n.uri||'';$('user').value=n.user||'';
if(n.mpass_set)$('mp').placeholder='口令(已设置,留空 = 不改)'}
}
function poll(){fetch('/s').then(function(r){return r.json()}).then(draw).catch(function(){
$('st').textContent='读不到状态:热点可能已经关了(底板连上 WiFi 后会关)'})}
$('show').onchange=function(){$('pass').type=this.checked?'text':'password'};
$('scan').onclick=function(){var a=$('aps');a.textContent='扫描中,约 3 秒…';
fetch('/scan').then(function(r){return r.json()}).then(function(j){a.textContent='';
if(j.err){a.textContent='底板正在尝试连接,过几秒再点一次';return}
if(!j.aps.length)a.textContent='没扫到(底板只支持 2.4G)';
j.aps.forEach(function(p){var r=el('div','row');r.appendChild(el('span','',p.ssid));
r.appendChild(el('span','dim',p.rssi+' dBm'+(p.open?' · 开放':'')));
r.onclick=function(){$('ssid').value=p.ssid;$('pass').focus()};a.appendChild(r)})
}).catch(function(){a.textContent='扫描失败,再点一次'})};
$('fw').onsubmit=function(e){e.preventDefault();
post('wifi\n'+$('ssid').value+'\n'+$('pass').value).then(function(j){
say('mw',j,'已保存,正在连接 —— 看上面的状态');if(j.ok)$('pass').value='';poll()}).catch(fail('mw'))};
$('fm').onsubmit=function(e){e.preventDefault();
post('mqtt\n'+$('uri').value.trim()+'\n'+$('user').value.trim()+'\n'+$('mp').value+'\n'+tele).then(function(j){
say('mm',j,'已保存');if(j.ok)$('mp').value='';poll()}).catch(fail('mm'))};
poll();setInterval(poll,3000);
</script>
</html>
+416
查看文件
@@ -0,0 +1,416 @@
/* 配网热点的 ESP 胶水(0.1.19)。**只进 IDF 目标**;什么时候开关、为什么安全在 prov.h。
---- 三样东西,跟着热点一起开关 ---------------------------------------------
| 东西 | 说明 |
|---|---|
| SoftAP | `WIFI_MODE_STA` ↔ `WIFI_MODE_APSTA`。STA 那一半照常由 mlink_esp.c 重连 |
| HTTP 服务(esp_http_server,自己一个任务) | `/` 网页、`/s` 状态、`/scan` 扫描、`/c` 提交;其余一律 302 回 `/` |
| DNS 劫持(自己一个任务,起了就不退) | 所有 A 查询都答热点地址 ⇒ 手机连上热点自动弹出网页(captive portal) |
---- 配置怎么写进去 ---------------------------------------------------------
网页 POST 的正文经 `prov_build_line()` 拼成一行命令,**投进命令队列**(`CMD_LINK_AP`),
由执行器任务里那个唯一的命令处理器执行(cmd.h)—— 校验、存 NVS、通知 mlink_esp 重连,
走的是和 USB 完全相同的路。HTTP 任务只是等回应(最多 3 s)再原样转给浏览器。
---- 只认热点那一侧 ---------------------------------------------------------
HTTP 服务绑的是所有接口,而热点在 STA 连上之后还会留一会儿(`linger`)——
那段时间路由器那一侧也能访问到 80 端口。所以每个处理函数先看**这条连接的本地地址**
是不是热点地址,不是就 403;DNS 那边看来源网段。 */
#include "prov_esp.h"
#include <string.h>
#include "cmd_queue.h"
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_random.h" /* 表单命令的 seq */
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "lwip/sockets.h"
#include "phonerail.h"
#include "prov.h"
#include "tasks.h"
static const char *TAG = "prov";
#define AP_CHANNEL 1 /* STA 一旦连上别的信道,热点会跟过去(单射频) */
#define AP_MAX_STA 2
#define SCAN_MAX 16
#define CMD_WAIT_MS 3000
extern const char prov_html_start[] asm("_binary_prov_html_start");
extern const char prov_html_end[] asm("_binary_prov_html_end");
static esp_netif_t *s_netif;
static char s_ssid[PROV_SSID_MAX];
static httpd_handle_t s_httpd;
static volatile bool s_on;
static volatile int s_sta;
static volatile bool s_touch;
static uint32_t s_ip; /* 热点地址,网络字节序 */
static bool s_dns_started;
/* 等命令回应用的。回应是执行器任务送来的(cmd_queue.h),HTTP 任务在等:
`s_rgen` 对不上 = 这条回应来晚了(HTTP 那边已经超时走人),丢掉。 */
static SemaphoreHandle_t s_rmtx;
static SemaphoreHandle_t s_rdone;
static char s_rbuf[CMD_OUT_MAX];
static size_t s_rlen;
static uint32_t s_rgen;
/* ---- 命令队列 ---------------------------------------------------------------- */
static void on_reply(void *ctx, const char *line, size_t len)
{
xSemaphoreTake(s_rmtx, portMAX_DELAY); /* 持锁期间只有一次 memcpy:不会把执行器拖住 */
if ((uint32_t)(uintptr_t)ctx == s_rgen && len < sizeof(s_rbuf)) {
memcpy(s_rbuf, line, len);
s_rbuf[len] = '\0';
s_rlen = len;
xSemaphoreGive(s_rdone);
}
xSemaphoreGive(s_rmtx);
}
/* 投一行命令、等回应。回应在 s_rbuf(只有 HTTP 任务读)。返回长度,0 = 队列满 / 超时。 */
static size_t run_cmd(const char *line)
{
xSemaphoreTake(s_rmtx, portMAX_DELAY);
const uint32_t gen = ++s_rgen;
s_rlen = 0;
xSemaphoreGive(s_rmtx);
(void)xSemaphoreTake(s_rdone, 0);
bool ok = cmd_queue_submit(CMD_LINK_AP, line, on_reply, (void *)(uintptr_t)gen);
ok = ok && xSemaphoreTake(s_rdone, pdMS_TO_TICKS(CMD_WAIT_MS)) == pdTRUE;
xSemaphoreTake(s_rmtx, portMAX_DELAY);
s_rgen++; /* 从这里起,迟到的回应不许再碰 s_rbuf */
const size_t n = ok ? s_rlen : 0;
xSemaphoreGive(s_rmtx);
return n;
}
/* ---- HTTP ------------------------------------------------------------------ */
/* 这条连接是不是从热点那一侧进来的(文件头「只认热点那一侧」)。 */
static bool from_ap(httpd_req_t *r)
{
struct sockaddr_storage a;
socklen_t n = sizeof(a);
if (getsockname(httpd_req_to_sockfd(r), (struct sockaddr *)&a, &n) != 0) {
return false;
}
uint32_t ip = 0;
if (a.ss_family == AF_INET) {
ip = ((const struct sockaddr_in *)&a)->sin_addr.s_addr;
#if CONFIG_LWIP_IPV6
} else if (a.ss_family == AF_INET6) { /* 监听口是 v6 的,v4 连接以 ::ffff:a.b.c.d 出现 */
const struct sockaddr_in6 *a6 = (const struct sockaddr_in6 *)&a;
static const uint8_t mapped[12] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF };
if (memcmp(a6->sin6_addr.s6_addr, mapped, sizeof(mapped)) != 0) {
return false;
}
memcpy(&ip, &a6->sin6_addr.s6_addr[12], sizeof(ip));
#endif
}
return s_on && ip != 0 && ip == s_ip;
}
static esp_err_t deny(httpd_req_t *r)
{
httpd_resp_set_status(r, "403 Forbidden");
return httpd_resp_send(r, "403", HTTPD_RESP_USE_STRLEN);
}
static void json_hdr(httpd_req_t *r)
{
httpd_resp_set_type(r, "application/json");
httpd_resp_set_hdr(r, "Cache-Control", "no-store");
}
static esp_err_t h_root(httpd_req_t *r)
{
if (!from_ap(r)) {
return deny(r);
}
s_touch = true;
httpd_resp_set_type(r, "text/html; charset=utf-8");
httpd_resp_set_hdr(r, "Cache-Control", "no-store");
/* EMBED_TXTFILES 结尾补了一个 0,不发出去。 */
return httpd_resp_send(r, prov_html_start, (ssize_t)(prov_html_end - prov_html_start - 1));
}
/* 状态轮询(3 s 一次):不算「人的动作」,否则页面开着就永远在放慢重连。 */
static esp_err_t h_status(httpd_req_t *r)
{
if (!from_ap(r)) {
return deny(r);
}
json_hdr(r);
const size_t n = run_cmd("{\"cmd\":\"net\"}");
if (n == 0) {
httpd_resp_set_status(r, "503 Service Unavailable");
return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"timeout\"}");
}
httpd_resp_sendstr_chunk(r, "{\"fw\":\"" PHONERAIL_FW_VERSION "\",\"net\":");
httpd_resp_send_chunk(r, s_rbuf, (ssize_t)n);
httpd_resp_sendstr_chunk(r, "}");
return httpd_resp_sendstr_chunk(r, NULL);
}
static esp_err_t h_scan(httpd_req_t *r)
{
if (!from_ap(r)) {
return deny(r);
}
s_touch = true;
json_hdr(r);
/* 阻塞扫描 ≈2~3 s,卡的是 HTTP 任务自己。STA 正在连的那一两秒里驱动会拒(WIFI_STATE)——
如实告诉网页「过几秒再点」,不在这里重试。 */
if (esp_wifi_scan_start(NULL, true) != ESP_OK) {
return httpd_resp_sendstr(r, "{\"err\":\"busy\"}");
}
static wifi_ap_record_t rec[SCAN_MAX]; /* 只有 HTTP 任务进来 */
uint16_t n = SCAN_MAX;
if (esp_wifi_scan_get_ap_records(&n, rec) != ESP_OK) { /* 必须取:不取驱动不放那块内存 */
n = 0;
}
httpd_resp_sendstr_chunk(r, "{\"aps\":[");
bool first = true;
for (int i = 0; i < n; i++) { /* 驱动已按信号强弱排好 */
const char *ssid = (const char *)rec[i].ssid;
bool dup = ssid[0] == '\0'; /* 隐藏的不列 */
for (int k = 0; k < i && !dup; k++) {
dup = strcmp((const char *)rec[k].ssid, ssid) == 0; /* 同名的多个 AP 只列最强的 */
}
char esc[32 * 6 + 1];
if (dup || prov_json_esc(ssid, esc, sizeof(esc)) == 0) {
continue;
}
char item[sizeof(esc) + 48];
snprintf(item, sizeof(item), "%s{\"ssid\":\"%s\",\"rssi\":%d,\"open\":%s}", first ? "" : ",", esc,
(int)rec[i].rssi, rec[i].authmode == WIFI_AUTH_OPEN ? "true" : "false");
httpd_resp_sendstr_chunk(r, item);
first = false;
}
httpd_resp_sendstr_chunk(r, "]}");
return httpd_resp_sendstr_chunk(r, NULL);
}
static esp_err_t h_cfg(httpd_req_t *r)
{
if (!from_ap(r)) {
return deny(r);
}
s_touch = true;
json_hdr(r);
char body[CMD_LINE_MAX];
if (r->content_len == 0 || r->content_len >= sizeof(body)) {
return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"too_long\"}");
}
size_t got = 0;
while (got < r->content_len) {
const int k = httpd_req_recv(r, body + got, r->content_len - got);
if (k <= 0) {
return ESP_FAIL; /* 连接断了 / 超时:关掉这条连接 */
}
got += (size_t)k;
}
body[got] = '\0';
if (strlen(body) != got) {
return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"bad_char\"}"); /* 正文里有 NUL */
}
/* 会动东西的命令必须带 seq,而判重窗口是全局按值比的(cmd.h):用随机数,
和服务器那边的序号撞上的机会是 32 条 / 2^32。 */
uint32_t seq = esp_random();
if (seq == 0) {
seq = 1;
}
char line[CMD_LINE_MAX];
const char *err = NULL;
if (prov_build_line(body, seq, line, sizeof(line), &err) == 0) {
char out[48];
snprintf(out, sizeof(out), "{\"ok\":false,\"err\":\"%s\"}", err);
return httpd_resp_sendstr(r, out);
}
const size_t n = run_cmd(line);
memset(body, 0, sizeof(body)); /* 口令别留在栈上 */
memset(line, 0, sizeof(line));
if (n == 0) {
return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"timeout\"}");
}
return httpd_resp_send(r, s_rbuf, (ssize_t)n);
}
/* 别的路径(手机的联网探测 generate_204 / hotspot-detect.html 之类)一律引回网页。 */
static esp_err_t h_404(httpd_req_t *r, httpd_err_code_t e)
{
(void)e;
httpd_resp_set_status(r, "302 Found");
httpd_resp_set_hdr(r, "Location", "http://192.168.4.1/");
httpd_resp_set_hdr(r, "Cache-Control", "no-store");
return httpd_resp_send(r, "PhoneRail", HTTPD_RESP_USE_STRLEN);
}
static void http_start(void)
{
httpd_config_t hc = HTTPD_DEFAULT_CONFIG();
hc.task_priority = task_desc(TASK_LINK)->prio; /* §10.3:链路最低 */
hc.stack_size = 6144;
hc.max_open_sockets = 3; /* lwIP 一共 10 个 socket,MQTT / OTA / DNS 也要用 */
hc.lru_purge_enable = true;
hc.max_uri_handlers = 4;
if (httpd_start(&s_httpd, &hc) != ESP_OK) {
s_httpd = NULL;
ESP_LOGE(TAG, "HTTP 服务起不来 —— 热点开着但没有网页,只能走 USB");
return;
}
static const httpd_uri_t k_uris[] = {
{ .uri = "/", .method = HTTP_GET, .handler = h_root },
{ .uri = "/s", .method = HTTP_GET, .handler = h_status },
{ .uri = "/scan", .method = HTTP_GET, .handler = h_scan },
{ .uri = "/c", .method = HTTP_POST, .handler = h_cfg },
};
for (size_t i = 0; i < sizeof(k_uris) / sizeof(k_uris[0]); i++) {
httpd_register_uri_handler(s_httpd, &k_uris[i]);
}
httpd_register_err_handler(s_httpd, HTTPD_404_NOT_FOUND, h_404);
}
/* ---- DNS 劫持 --------------------------------------------------------------- */
/* 任务起了就不退(热点关着时收到的包直接丢):省掉「关热点时怎么把它从 recvfrom 里叫出来」。 */
static void dns_task(void *arg)
{
(void)arg;
int fd = -1;
for (;;) {
if (fd < 0) {
fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
struct sockaddr_in me = { .sin_family = AF_INET, .sin_port = htons(53),
.sin_addr.s_addr = htonl(INADDR_ANY) };
const struct timeval tv = { .tv_sec = 1, .tv_usec = 0 };
if (fd < 0 || bind(fd, (struct sockaddr *)&me, sizeof(me)) != 0 ||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) {
if (fd >= 0) {
close(fd);
fd = -1;
}
vTaskDelay(pdMS_TO_TICKS(5000)); /* socket 用光了:没有自动弹窗,网页照样能手输地址打开 */
continue;
}
}
uint8_t q[256 + 16];
struct sockaddr_in from;
socklen_t fl = sizeof(from);
const int n = recvfrom(fd, q, 256, 0, (struct sockaddr *)&from, &fl);
if (n < 0) {
continue; /* 1 s 超时 */
}
/* 只答热点网段来的、热点开着时的、单问题的标准查询。 */
if (!s_on || n < 17 || ((from.sin_addr.s_addr ^ s_ip) & htonl(0xFFFFFF00u)) != 0 ||
(q[2] & 0xF8) != 0 || q[4] != 0 || q[5] != 1) {
continue;
}
int p = 12;
while (p < n && q[p] != 0) {
p += q[p] + 1;
}
p += 5; /* 结尾的 0 + QTYPE + QCLASS */
if (p > n) {
continue;
}
const bool is_a = q[p - 4] == 0 && q[p - 3] == 1;
q[2] = 0x81; /* 回应 + 递归可用 */
q[3] = 0x80;
q[6] = 0;
q[7] = is_a ? 1 : 0; /* 别的类型(AAAA / HTTPS):没有记录 */
q[8] = q[9] = q[10] = q[11] = 0;
if (is_a) {
static const uint8_t ans[12] = { 0xC0, 0x0C, 0, 1, 0, 1, 0, 0, 0, 10, 0, 4 }; /* TTL 10 s */
memcpy(q + p, ans, sizeof(ans));
memcpy(q + p + sizeof(ans), &s_ip, 4);
p += (int)sizeof(ans) + 4;
}
(void)sendto(fd, q, (size_t)p, 0, (struct sockaddr *)&from, fl);
}
}
/* ---- 热点 ------------------------------------------------------------------ */
static void on_ap(void *arg, esp_event_base_t base, int32_t id, void *data)
{
(void)arg;
(void)base;
(void)data;
if (id == WIFI_EVENT_AP_STACONNECTED) {
s_sta = s_sta + 1;
} else if (id == WIFI_EVENT_AP_STADISCONNECTED && s_sta > 0) {
s_sta = s_sta - 1;
}
}
void prov_esp_init(esp_netif_t *ap, const char *id)
{
s_netif = ap;
s_rmtx = xSemaphoreCreateMutex();
s_rdone = xSemaphoreCreateBinary();
prov_ssid(id, s_ssid);
esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_AP_STACONNECTED, on_ap, NULL);
esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_AP_STADISCONNECTED, on_ap, NULL);
}
void prov_esp_set(bool on)
{
if (on == s_on || s_netif == NULL || s_rmtx == NULL || s_rdone == NULL) {
return;
}
if (!on) {
s_on = false; /* 先置:正在处理的请求下一步就 403 */
if (s_httpd != NULL) {
httpd_stop(s_httpd); /* 等 HTTP 任务退出(最多等完一次扫描 / 一条命令) */
s_httpd = NULL;
}
esp_wifi_set_mode(WIFI_MODE_STA);
s_sta = 0;
ESP_LOGI(TAG, "配网热点已关");
return;
}
wifi_config_t ac = { 0 };
memcpy(ac.ap.ssid, s_ssid, strlen(s_ssid));
ac.ap.ssid_len = (uint8_t)strlen(s_ssid);
memcpy(ac.ap.password, PROV_PASS, strlen(PROV_PASS));
ac.ap.channel = AP_CHANNEL;
ac.ap.authmode = WIFI_AUTH_WPA2_PSK;
ac.ap.max_connection = AP_MAX_STA;
if (esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK || esp_wifi_set_config(WIFI_IF_AP, &ac) != ESP_OK) {
ESP_LOGE(TAG, "配网热点开不起来 —— 只能走 USB");
esp_wifi_set_mode(WIFI_MODE_STA);
return; /* s_on 仍是 false:t_link 过 10 s 再试(mlink_esp.c 的 prov_poll) */
}
esp_netif_ip_info_t ip;
s_ip = (esp_netif_get_ip_info(s_netif, &ip) == ESP_OK) ? ip.ip.addr : 0;
s_sta = 0;
s_touch = false;
s_on = true;
http_start();
if (!s_dns_started) {
s_dns_started = xTaskCreate(dns_task, "prov_dns", 3072, NULL, task_desc(TASK_LINK)->prio, NULL) == pdPASS;
}
ESP_LOGW(TAG, "配网热点已开:「%s」,连上后开 http://192.168.4.1/", s_ssid); /* 只打名字,不打口令 */
}
bool prov_esp_on(void) { return s_on; }
int prov_esp_sta_count(void) { return s_sta; }
bool prov_esp_take_touch(void)
{
const bool t = s_touch;
s_touch = false;
return t;
}
const char *prov_esp_ssid(void) { return s_ssid; }
const char *prov_esp_pass(void) { return PROV_PASS; }
+22
查看文件
@@ -0,0 +1,22 @@
/* 配网热点的 ESP 胶水(0.1.19)。只在 IDF 目标里有;判据与安全说明在 prov.h,实现说明在 prov_esp.c 文件头。 */
#ifndef PROV_ESP_H
#define PROV_ESP_H
#include <stdbool.h>
#include "esp_netif.h"
/* 定热点名字、挂热点事件。要在 `esp_wifi_start()` 之后、链路任务起来之前调一次。
`ap` 是 `esp_netif_create_default_wifi_ap()` 的返回值,`id` 是板子 ID(热点名取它的末 6 位)。 */
void prov_esp_init(esp_netif_t *ap, const char *id);
/* 开 / 关热点(连同网页与 DNS 劫持)。**只在 t_link 里调**(关的时候要等 HTTP 任务退出)。幂等。 */
void prov_esp_set(bool on);
bool prov_esp_on(void); /* 热点现在真的开着吗(开失败时是 false) */
int prov_esp_sta_count(void); /* 热点上连着几台 */
bool prov_esp_take_touch(void); /* 上次问过之后网页有没有「人的动作」(读了就清) */
const char *prov_esp_ssid(void);
const char *prov_esp_pass(void);
#endif /* PROV_ESP_H */
+60 -2
查看文件
@@ -25,6 +25,10 @@ static bool s_inited;
/* 上位机 `fan` 命令给的下限(A12)。**跨任务**:命令处理器在执行器任务里写,
本模块在热管理任务里读。一个对齐的 16 位量,Xtensa 上读写是原子的,不加锁。 */
static volatile uint16_t s_floor;
/* 下限的时限(`t_thermal_set_floor_for`)。写的顺序是「先 until、再 timed、最后 floor」,
读的一侧最坏只会晚一圈(1 s)才认到新值。 */
static volatile uint32_t s_floor_until;
static volatile bool s_floor_timed;
/* 风扇侧占空比 → 真正写进 `hal_pwm` 的值。Q6 是低边管,栅极高 = 把风扇的
PWM 线拉低 ⇒ 反相(`t_thermal.h` 硬件那三条的第 2 条)。 */
@@ -107,6 +111,7 @@ hal_err_t t_thermal_init(thermal_snap_t *s, therm_temp_fn temp, therm_tach_fn ta
return e;
}
s->fan_en = true;
s->fan_starts = 1;
s->en_ms = hal_time_now_ms();
s->last_ms = s->en_ms;
@@ -133,6 +138,17 @@ static void tach_step(thermal_snap_t *s, int idx, uint32_t dt_ms, uint16_t duty)
const uint32_t delta = now_edges - t->last_edges; /* uint32 相减,回绕安全 */
t->last_edges = now_edges;
/* 风扇断着电:不累加、不判(惯性滑行那几圈的脉冲也不算数),转速如实报 0。
`stalled` 不动 —— 见头文件「断电期间不判停转」。 */
if (!s->fan_en) {
t->win_edges = 0;
t->win_on_ms = 0;
t->win_ms = 0;
t->rpm = 0;
t->rpm_valid = true;
return;
}
/* 启动宽限期内一直重置窗口:这时候风扇还在起转,脉冲本来就没有或很稀。 */
if (s->fan_en && hal_time_elapsed_ms(s->en_ms) < T_THERMAL_SPINUP_MS) {
t->win_edges = 0;
@@ -216,11 +232,37 @@ hal_err_t t_thermal_step(thermal_snap_t *s)
s->temp_errors++;
}
uint16_t duty = t_thermal_duty_for(s->temp_max_mc, s->temp_valid);
if (s_floor_timed && (int32_t)(now_ms - s_floor_until) >= 0) {
s_floor = 0; /* 上位机给的时限到了:交回自动 */
s_floor_timed = false;
}
const uint16_t floor_ = s_floor;
if (floor_ > duty) {
duty = floor_; /* 手动值只抬不压:曲线要得更高时照曲线来 */
}
(void)apply_duty(s, duty);
/* ---- 开还是关(`T_THERMAL_FAN_OFF_MC` 上的说明)----
读不到温度、上位机要风:一律开。否则按迟滞:开着的掉到 OFF 以下才关,关着的到 ON 才开。 */
bool want_on = true;
if (s->temp_valid && floor_ == 0) {
want_on = s->fan_en ? (s->temp_max_mc >= T_THERMAL_FAN_OFF_MC)
: (s->temp_max_mc >= T_THERMAL_FAN_ON_MC);
}
if (want_on != s->fan_en) {
/* 开:先把占空比写好再给 12V(同 init 的顺序);关:先断 12V。写失败就维持原状,下一圈再来。 */
if (want_on) {
(void)apply_duty(s, duty);
}
if (hal_gpio_write(BOARD_PIN_FAN_EN, want_on ? 1 : 0) == HAL_OK) {
s->fan_en = want_on;
if (want_on) {
s->en_ms = now_ms; /* 启动宽限从这里重新算 */
s->fan_starts++;
}
}
}
/* 断着电时占空比报 0:遥测里「duty 300、en false」会让人以为它在转。 */
(void)apply_duty(s, s->fan_en ? duty : 0);
/* ---- 过温降载:本模块只**报**,关路是限功率那一包的事 ---- */
if (s->temp_valid) {
@@ -241,9 +283,25 @@ hal_err_t t_thermal_step(thermal_snap_t *s)
return HAL_OK;
}
void t_thermal_set_floor(uint16_t permille)
void t_thermal_set_floor(uint16_t permille) { t_thermal_set_floor_for(permille, 0); }
void t_thermal_set_floor_for(uint16_t permille, uint32_t ttl_ms)
{
const bool timed = ttl_ms > 0 && permille > 0;
if (timed) {
s_floor_until = hal_time_now_ms() + ttl_ms;
}
s_floor_timed = timed;
s_floor = (permille > 1000u) ? 1000u : permille;
}
uint16_t t_thermal_floor(void) { return s_floor; }
uint32_t t_thermal_floor_left_ms(void)
{
if (!s_floor_timed || s_floor == 0) {
return 0;
}
const int32_t left = (int32_t)(s_floor_until - hal_time_now_ms());
return left > 0 ? (uint32_t)left : 0;
}
+28 -4
查看文件
@@ -62,6 +62,9 @@
⚠️ 与之配套的是**占空比下限** `T_THERMAL_DUTY_MIN`:占空比真掉到 0,
导通时间永远攒不满,这条判据就变成了「永远不判」。所以闭环的下限不是 0;
真要停风扇只能撤 `FAN_EN`(§22.6:0 % PWM 时四线风扇仍转 3300 RPM)。
0.1.21 起凉的时候就是这么停的(`T_THERMAL_FAN_OFF_MC`):断电期间**不判停转**、窗口清零、
转速报 0;`stalled` 保持断电前的结论(一只没插的风扇不会因为每次启停各记一条事件)。
重新上电后照样先过启动宽限。
---- 事件:入队,不落盘 --------------------------------------------------
`evlog_put()` 是几条指令的入队,任何任务都能调;`evlog_flush()` 是唯一碰 flash
@@ -103,8 +106,18 @@ extern "C" {
而新位置的温升曲线只有上板才有。这里给的是**形状**(分段线性 + 迟滞),
数值先按常识填、单列在这里,上板实测之后只改这四行。 */
/* 低于它就按下限转(安静优先)。 */
/* 曲线起点:这个温度按下限转,往上线性加到满速。 */
#define T_THERMAL_TEMP_LO_MC 40000 /* 40.000 °C —— 待实测 */
/* **40 °C 以下风扇断电**(0.1.21,2026-10-06 用户定):撤 `FAN_EN`,不是把占空比压到 0 ——
四线风扇 0 % PWM 仍转 3300 RPM(§22.6),要它停只能断 12V。
到 `FAN_ON_MC` 开、掉到 `FAN_OFF_MC` 以下才关:两个数之间维持原状。
⚠️ 这 2 °C 的迟滞不能省:板温就在 40 °C 上下晃(板 B 空闲实测 38~41 °C),
没有它风扇每隔几秒启停一次,每次起转都是一声响,还各带 5 s 的停转判据盲区。
**不受它管的三种情况,风扇一律开着**:两路温度都读不到(§10.10)、上位机给了下限(`fan` 命令)、
以及启动后读到第一个温度之前的那一秒。 */
#define T_THERMAL_FAN_ON_MC T_THERMAL_TEMP_LO_MC
#define T_THERMAL_FAN_OFF_MC 38000
/* 高于它满速。 */
#define T_THERMAL_TEMP_HI_MC 65000 /* 65.000 °C —— 待实测 */
/* 过温降载门限(FR-13「超阈值先降流再关路」)。本模块只**报**,不动电源 ——
@@ -204,10 +217,11 @@ typedef struct {
int32_t temp_max_mc; /* 两路里**高的那一个**:一点凉不代表另一点不烫 */
bool temp_valid; /* 至少有一路读到了 */
uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径 */
uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径;风扇断电时是 0 */
uint16_t gpio_duty; /* 真写进 hal_pwm 的值(Q6 反相 ⇒ 1000 − duty) */
bool fan_en; /* `FAN_EN` 的命令值 */
bool fan_en; /* `FAN_EN` 的命令值(凉了会撤,见 `T_THERMAL_FAN_OFF_MC`) */
uint32_t fan_starts; /* 上过几次电(含启动那一次):启停太勤 = 迟滞不够 */
uint32_t en_ms; /* 拉高的时刻,启动宽限从这里算 */
bool derate; /* 正在过温降载 */
@@ -239,9 +253,19 @@ uint16_t t_thermal_duty_for(int32_t milli_c, bool valid);
/* 上位机手动给的占空比**下限**(风扇侧千分比,A12 的 `fan` 命令)。0 = 全自动。
⚠️ 只抬不压:实际占空比 = max(温度曲线, 下限)。「手动把风扇调小」这件事
不提供 —— 那等于允许远程关掉散热,而温度读不到时满速(§10.10)的兜底也会被它盖掉。
下一圈 `t_thermal_step()` 生效;不随 `t_thermal_init()` 清零(重启自然清零)。 */
下限 > 0 时风扇一定开着(哪怕板温低于 40 °C):板上的 NTC 看不见手机 SoC 有多热,
上位机(AppTask 按手机自报的温度)要风的时候就得有风。
下一圈 `t_thermal_step()` 生效;不随 `t_thermal_init()` 清零(重启自然清零)。
`ttl_ms`(0.1.21):下限保持多久,0 = 一直保持。到期自动回 0(全自动)——
给「程序定时续期」的上位机用:它挂了 / 断网了,风扇不会永远停在它最后要的那个转速上。
`t_thermal_floor_left_ms()`:还剩多久,没设时限或已经没有下限时是 0。 */
void t_thermal_set_floor(uint16_t permille);
void t_thermal_set_floor_for(uint16_t permille, uint32_t ttl_ms);
uint16_t t_thermal_floor(void);
uint32_t t_thermal_floor_left_ms(void);
#define T_THERMAL_FLOOR_TTL_MAX_S 86400 /* `fan` 命令的 `sec` 上限:一天 */
#ifdef __cplusplus
}