- 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]>
417 行
16 KiB
C
417 行
16 KiB
C
/* 配网热点的 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; }
|