固件 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 行删除
+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; }