WebSocket 实时双向通信:让网页和 ESP32-S3 互相推消息
- 说清 WebSocket 和 HTTP 轮询、MQTT 的区别,知道什么场景该用哪个
- 用 esp_http_server 的 WS 支持(httpd_uri_t.is_websocket)把 ESP32-S3 跑成 WebSocket server
- 用 httpd_ws_recv_frame 收帧、httpd_ws_send_frame 回帧,并用 httpd_ws_send_frame_async 主动推送
- 做出一个不刷新就实时更新数据、点按钮即时响应的网页控制台
- 理解为什么 AI 语音助手(比如小智)的音频流走 WebSocket
你已经在上一篇用 ESP32-S3 跑起了一个网页,浏览器打开就能点按钮控灯。但很快你会撞上一个让人难受的问题:网页上显示的温度,是打开页面那一刻的值。想看最新数据,得手动刷新;想做个"每秒自动更新"的仪表盘,只能让网页每隔一秒发一次 HTTP 请求去问 ESP32-S3——这叫轮询。轮询很笨:大部分时候问到的都是没变化的数据,白白耗电耗带宽;而且想做到"传感器一变化网页立刻就动",轮询根本做不到,它最快也只能是"一秒后才知道"。
想做实时看数据、实时控设备,HTTP 这种一问一答的模式天生不合适。你需要的是一条一直开着的管子,两头随时能往里塞消息。这就是 WebSocket。
好消息是:你不用换库、不用起第二个服务器。上一篇用的 esp_http_server(httpd)本身就内置了 WebSocket 支持——给一条路由打个 .is_websocket = true 的标记,它就从普通的"请求-响应"路由变成长连接全双工通道。本篇就在上一篇那套 httpd 骨架上,加一条 /ws 路由:网页点按钮,ESP32-S3 立刻收到并控 LED;ESP32-S3 读到新的传感器值,立刻推给网页,网页不刷新就更新。整套代码可跑,跑通后你就有了做实时控制台的基本功——而这条管子,也正是小智这类 AI 语音助手传音频流用的同一种通道。
读这篇前,你得先把上一篇的 httpd 跑通(会 httpd_start、httpd_register_uri_handler、handler 写法),WiFi 连接复用 WiFi 那篇的 wifi_init_sta() 骨架。本篇只在 HTTP 服务器之上"长"出 WebSocket,地基没变。
一、WebSocket、HTTP 轮询、MQTT 到底差在哪
这三个名字初学时很容易混。用一句话各自概括:
- HTTP(轮询):一问一答。浏览器发一个请求,服务器回一个响应,连接就断了。要实时,只能让浏览器每隔一秒主动问一次。像写信,每次都要重新贴邮票,而且你只能定时去问,对方没法主动找你。
- WebSocket:先用一次 HTTP 请求"握手升级"成长连接,之后这条连接一直开着,两头谁有话谁就直接推,对方立刻收到。像打电话,接通后随时能说,不用每句话都重拨;而且对方能主动开口。
- MQTT:发布/订阅模型,中间有个 broker(消息服务器)。设备把消息发到某个"主题",所有订阅了这个主题的设备都能收到。适合很多设备互相解耦、一对多广播的场景。
什么时候用哪个,按这张表选:
| 需求 | 选谁 | 为什么 |
|---|---|---|
| 偶尔取一次配置/状态 | HTTP | 一问一答足够,最简单(就是上一篇的做法) |
| 网页实时看数据、实时控设备 | WebSocket | 和浏览器天生搭,全双工低延迟,服务器能主动推 |
| 多个设备互相收发、一对多广播 | MQTT | 发布订阅解耦,broker 帮你转发 |
| 流式音频/视频(如 AI 语音) | WebSocket | 长连接全双工,音频流持续推 |
简单说:和网页打交道、要做实时仪表盘或控制台,选 WebSocket;多设备组网、传感器各发各的、一处采集,选 MQTT(MQTT 的用法见 这篇)。两者不是对立的,大项目里经常并存:设备之间走 MQTT,最后给人看的网页控制台走 WebSocket。
ESP-IDF 里做 WebSocket 有两个方向,别搞混:本篇做的是服务器侧——ESP32-S3 自己当 WS 服务器,让浏览器连上来,用的是 esp_http_server 内置的 WS 支持。反过来,如果你要让 ESP32-S3 去连别人的 WS 服务器(比如连云端的 WebSocket 接口当客户端),那是 esp_websocket_client 组件,配置和用法不一样。本篇聚焦服务器侧,因为"设备开网页给手机看"是最常见的需求;客户端方向篇末指个路。
二、握手升级:WebSocket 怎么从 HTTP 长出来
讲代码前先理解一件事,后面看 .is_websocket = true 才不觉得是黑魔法。
WebSocket 连接的第一步其实是一个普通的 HTTP GET 请求,只是这个请求带了几个特殊头(Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key)。服务器一看这几个头,回一个 101 Switching Protocols,双方就把这条 TCP 连接从"HTTP 协议"切换成了"WebSocket 协议"——之后这条连接不再走请求-响应,而是变成一条全双工的管子,两头随时能往里塞帧(frame)。
这就是为什么 esp_http_server 能顺手支持它:握手本来就是 HTTP 请求,httpd 接得住。你给路由标 .is_websocket = true,httpd 就帮你把握手这一步全做完(识别 Upgrade 头、回 101),你的 handler 只需要管握手成功之后收发帧。浏览器端你写 new WebSocket('ws://.../ws'),浏览器自动发那个带 Upgrade 头的请求,你完全不用手写握手——两边都被框架/浏览器包好了。
记住这条主线:一次 HTTP 握手升级 → 之后是长连接全双工收发帧。下面的代码就是照这条线写的。
三、先把整段代码跑通
下面这段在上一篇 httpd 骨架基础上加了一条 /ws 路由。它做的事:连 WiFi(复用 wifi_init_sta())、起 httpd、注册 /(返回带 WebSocket 脚本的网页)和 /ws(WebSocket 通道),再起一个后台任务每秒把"温度"主动推给所有连着的网页。LED 接在板载灯脚(按你的板改),温度先用模拟值,跑通后换真传感器(比如 DHT11)。把你家 2.4GHz WiFi 的名字密码填进 WIFI_SSID / WIFI_PASS,放进工程的 main/main.c:
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_http_server.h"
#include "driver/gpio.h"
#include "nvs_flash.h"
#define WIFI_SSID "你的WiFi名"
#define WIFI_PASS "你的WiFi密码"
#define LED_GPIO GPIO_NUM_2 // 板载 LED;不同板子可能是 2 / 38 / 48,按你的板改
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
#define MAX_CLIENTS 4 // 同时记录的 WS 客户端 socket fd 上限
static const char *TAG = "ws";
static EventGroupHandle_t s_wifi_event_group;
static int s_retry = 0;
static httpd_handle_t s_server = NULL; // 异步推送要拿它 + socket fd
static int s_client_fds[MAX_CLIENTS]; // 存所有连上来的网页的 socket fd
static int s_client_count = 0;
/* ---------- WiFi:复用 l3-wifi 的 wifi_init_sta() 骨架,连上拿 IP ---------- */
static void wifi_event_handler(void* arg, esp_event_base_t base, int32_t id, void* data) {
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
if (s_retry < 5) { esp_wifi_connect(); s_retry++; ESP_LOGI(TAG, "重连中 %d/5", s_retry); }
else xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t* e = (ip_event_got_ip_t*) data;
ESP_LOGI(TAG, "拿到 IP: " IPSTR ",浏览器访问这个地址", IP2STR(&e->ip_info.ip));
s_retry = 0;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
}
}
static void wifi_init_sta(void) {
s_wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL));
wifi_config_t wc = { .sta = { .ssid = WIFI_SSID, .password = WIFI_PASS } };
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wc));
ESP_ERROR_CHECK(esp_wifi_start());
xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, portMAX_DELAY);
}
/* ---------- 网页:内嵌一段带 WebSocket 脚本的 HTML(/ 路由返回) ---------- */
static const char *PAGE =
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>ESP32-S3 实时控制台</title>"
"<style>body{font-family:sans-serif;text-align:center;padding:30px}"
".val{font-size:48px;margin:20px}button{font-size:20px;padding:12px 30px}</style>"
"</head><body>"
"<h2>ESP32-S3 实时控制台</h2>"
"<div>温度:<span class='val' id='temp'>--</span> °C</div>"
"<button onclick=\"send('on')\">开灯</button>"
"<button onclick=\"send('off')\">关灯</button>"
"<p id='status'>连接中...</p>"
"<script>"
"let ws;"
"function connect(){"
" ws = new WebSocket('ws://'+location.host+'/ws');" // 浏览器自动发握手升级请求
" ws.onopen = ()=>document.getElementById('status').innerText='已连接';"
" ws.onmessage = (e)=>{document.getElementById('temp').innerText=e.data;};" // 收到推送就改页面,不刷新
" ws.onclose = ()=>{document.getElementById('status').innerText='断开,重连中...';"
" setTimeout(connect,1000);};" // 断线 1 秒后自动重连
"}"
"function send(cmd){ if(ws&&ws.readyState===1) ws.send(cmd); }" // 点按钮把指令推给 ESP32-S3
"connect();"
"</script></body></html>";
static esp_err_t root_handler(httpd_req_t *req) {
httpd_resp_send(req, PAGE, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
/* ---------- 客户端 fd 表:异步推送要靠它 ---------- */
static void add_client(int fd) {
for (int i = 0; i < s_client_count; i++) if (s_client_fds[i] == fd) return; // 去重
if (s_client_count < MAX_CLIENTS) s_client_fds[s_client_count++] = fd;
}
/* ---------- WS handler:握手后每次收到帧都会被调一次 ---------- */
static esp_err_t ws_handler(httpd_req_t *req) {
if (req->method == HTTP_GET) {
// 第一次进来 method 是 GET = 握手刚完成,httpd 已替你回了 101,这里只记下这个客户端
int fd = httpd_req_to_sockfd(req);
add_client(fd);
ESP_LOGI(TAG, "网页连上来了 fd=%d", fd);
return ESP_OK;
}
// 之后每次网页 send(),这个 handler 被再次调用,req 里带着这一帧
httpd_ws_frame_t frame = { 0 };
frame.type = HTTPD_WS_TYPE_TEXT;
// 第一次调 recv 传 len=0,只问"这帧多长",不取 payload
esp_err_t ret = httpd_ws_recv_frame(req, &frame, 0);
if (ret != ESP_OK) return ret;
if (frame.len) {
uint8_t *buf = calloc(1, frame.len + 1); // +1 留给字符串结尾 '\0'
if (!buf) return ESP_ERR_NO_MEM;
frame.payload = buf;
ret = httpd_ws_recv_frame(req, &frame, frame.len); // 第二次真正把 payload 收进来
if (ret == ESP_OK && frame.type == HTTPD_WS_TYPE_TEXT) {
ESP_LOGI(TAG, "收到指令: %s", (char *)frame.payload);
if (strcmp((char *)frame.payload, "on") == 0) gpio_set_level(LED_GPIO, 1);
if (strcmp((char *)frame.payload, "off") == 0) gpio_set_level(LED_GPIO, 0);
}
free(buf);
}
return ret;
}
/* ---------- 主动推送:把一段文字异步推给某个客户端 ---------- */
static void push_to_client(int fd, const char *msg) {
httpd_ws_frame_t frame = {
.type = HTTPD_WS_TYPE_TEXT,
.payload = (uint8_t *)msg,
.len = strlen(msg),
};
// 注意:不是普通 send,而是 async——因为这是在别的任务里推,不在 ws_handler 的请求上下文里
httpd_ws_send_frame_async(s_server, fd, &frame);
}
/* ---------- 后台任务:每秒读温度,推给所有连着的网页 ---------- */
static void push_task(void *arg) {
char buf[16];
while (1) {
// 模拟温度,跑通后换成真传感器读数
float temp = 25.0f + (esp_log_timestamp() % 5000) / 1000.0f;
snprintf(buf, sizeof(buf), "%.1f", temp);
for (int i = 0; i < s_client_count; i++) {
push_to_client(s_client_fds[i], buf); // 服务器主动推,不用网页来问
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
static void start_webserver(void) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
if (httpd_start(&s_server, &config) == ESP_OK) {
httpd_uri_t root = { .uri = "/", .method = HTTP_GET, .handler = root_handler };
// 关键就这一行:.is_websocket = true 让这条路由变成 WebSocket 通道
httpd_uri_t ws = { .uri = "/ws", .method = HTTP_GET, .handler = ws_handler, .is_websocket = true };
httpd_register_uri_handler(s_server, &root);
httpd_register_uri_handler(s_server, &ws);
ESP_LOGI(TAG, "HTTP + WebSocket 服务器已启动");
}
}
void app_main(void) {
ESP_ERROR_CHECK(nvs_flash_init());
gpio_reset_pin(LED_GPIO);
gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);
wifi_init_sta(); // 连上 WiFi、拿到 IP 才往下走
start_webserver(); // 开 HTTP + WS 服务器
xTaskCreate(push_task, "push", 4096, NULL, 5, NULL); // 起后台推送任务
}
在工程目录下一条命令编译、烧录、看日志:
idf.py build flash monitor
(第一次用要先 idf.py set-target esp32s3 选好芯片型号。按 Ctrl + ] 退出监视。)
WebSocket 是 esp_http_server 的可选特性,默认关着。如果编译报 is_websocket 不是 httpd_uri_t 的成员、或链接时找不到 httpd_ws_recv_frame,去 idf.py menuconfig → Component config → HTTP Server 里把 WebSocket support(CONFIG_HTTPD_WS_SUPPORT)勾上,重新编译即可。这是头号坑,先排掉。
四、你应该看到什么
手机或电脑(要和 ESP32-S3 在同一个 2.4G WiFi 下)浏览器打开日志里那个 IP,比如 http://192.168.1.50:
- 串口日志先滚出
拿到 IP: ...和HTTP + WebSocket 服务器已启动,浏览器打开后还会打网页连上来了 fd=54之类——这条fd=就是握手成功的标志,httpd 已经把那次 HTTP 请求升级成了 WebSocket 长连接。 - 页面顶部状态从"连接中..."变成"已连接"——
ws.onopen触发了。 - 温度数字每秒自己跳动一次(25.0 到 30.0 之间循环),你没有刷新页面,它自己在变。这就是 ESP32-S3 在后台任务里
httpd_ws_send_frame_async主动推、网页onmessage被动收的效果——和轮询最大的区别是:网页一个请求都没发,是服务器主动喊它。 - 点"开灯",板载 LED 立刻亮;点"关灯"立刻灭,几乎没延迟。指令是浏览器
ws.send('on')直接走长连接推过去的,ESP32-S3 的ws_handler被叫醒、httpd_ws_recv_frame收到on、gpio_set_level拉高。不用每次重新建连接。 - 开两个浏览器同时连,温度会同步更新——因为后台任务遍历
s_client_fds给每个 fd 都推了一遍。
如果温度在动、按钮即时响应,恭喜,你的实时双向通道通了。这条管子两头都能随时说话,这就是"全双工"。
五、把这套 WS 骨架讲透
例子能跑只是起点,看懂这几个关键点你才能改成自己要的样子。WiFi 和 httpd 那两半上两篇讲透了,这里只拆 WebSocket 这部分。
.is_websocket = true:一个标记搞定握手
httpd_uri_t ws = { .uri = "/ws", .method = HTTP_GET, .handler = ws_handler, .is_websocket = true };
普通路由(上一篇的 /on)是"收到 GET → 跑 handler → 回响应 → 完事"。加了 .is_websocket = true 后,httpd 对这条路由的行为变了:它先识别请求里的 Upgrade 头、自动回 101 Switching Protocols 完成握手(第二节讲的那一步),这些你一行都不用写。握手成功后,这条 TCP 连接被它留着当长连接用,之后每收到一帧就调一次你的 ws_handler。一句话:这个标记把"握手"外包给了 httpd,你只管收发帧。
ws_handler 被调用两类时机
这个 handler 容易绕晕的地方在于:它被调用的"语境"有两种,靠 req->method 区分。
- 握手刚完成那一次:
req->method == HTTP_GET。此时还没有数据帧,你能做的就是"记一笔这个客户端连上了"——httpd_req_to_sockfd(req)拿到这条连接的 socket fd 存起来(后面异步推送要用它定位是哪条连接)。 - 之后每次网页
send():handler 被再次调用,req->method不再是 GET,req里带着这一帧,你走收帧逻辑。
收帧:为什么要 httpd_ws_recv_frame 两次
httpd_ws_frame_t frame = { 0 };
frame.type = HTTPD_WS_TYPE_TEXT;
httpd_ws_recv_frame(req, &frame, 0); // ① len=0:只问长度,填进 frame.len
// ... 按 frame.len 分配 buffer ...
httpd_ws_recv_frame(req, &frame, frame.len); // ② 真正把 payload 收进 buffer
这是 httpd WS API 的标准两段式,新手最容易写错。第一次调用传 max_len = 0,它不读 payload,只把这帧的真实长度填进 frame.len,让你知道该开多大的 buffer——因为帧长是变的,你没法预先固定缓冲区。拿到长度后 calloc(frame.len + 1)(+1 是给文本帧留 '\0' 结尾,好当 C 字符串用),把 frame.payload 指过去,第二次调用传 frame.len 才真正把数据收进来。收完 free 掉。省掉第一次直接收,你既不知道开多大 buffer、也容易截断。
回帧 vs 异步推送:两个不同的函数,别用错
这是本篇最关键的一个区分,对应"双向"的两个方向:
- 在
ws_handler里回应这次请求:用httpd_ws_send_frame(req, &frame)——它需要req(当前请求上下文),只能在 handler 内部、响应网页刚发来的那一帧时用。本篇的按钮指令收完没回内容(前端不需要),所以没显式调它;你要给个"已收到"回执就在ws_handler里加一行httpd_ws_send_frame。 - 在别的任务里主动推:用
httpd_ws_send_frame_async(server, fd, &frame)。后台push_task推温度时不在任何请求上下文里(没有req),所以不能用httpd_ws_send_frame。异步版要你提供两样东西:服务器句柄s_server和目标连接的fd(就是握手时存下的那个)。这就是为什么前面非要把 socket fd 存进表里——没有 fd,你无从知道该往哪条连接推。
记住这条对应关系:被动回应用 send_frame(有 req),主动推送用 send_frame_async(要 server + fd)。想明白这一点,WebSocket 服务器侧的代码就通了。
六、为什么 AI 语音助手用 WebSocket
理解了上面的效果,就能理解为什么小智这类 AI 语音助手的音频流走 WebSocket。语音交互的链路是这样的:你说话 → 设备把麦克风采到的音频持续往上推 → 云端做语音识别和大模型 → 云端把合成的回复语音持续往下推 → 设备播放。
这个链路有两个硬性要求,正好是 WebSocket 的强项:
- 全双工:你说话的同时,云端可能已经开始往下传回复(边听边想边说)。上行音频和下行音频要能同时跑,HTTP 一问一答做不到,WebSocket 一条连接两头同时推没问题——就像本篇网页"按钮推上去"和"温度推下来"能同时发生。
- 低延迟、流式:音频是一小段一小段连续推的,不能等整段说完再发。WebSocket 长连接建好后,每一小段音频几乎零额外开销地推出去(不像 HTTP 每段都要重新握手、带一堆头),延迟低,体感才"跟得上"。
不同的是:本篇推的是文本帧(HTTPD_WS_TYPE_TEXT),推音频流时用的是二进制帧(HTTPD_WS_TYPE_BINARY)——音频是字节流,不能当文本传。httpd_ws_frame_t.type 改成 HTTPD_WS_TYPE_BINARY、payload 指向音频缓冲区即可,骨架完全一样。另外 ESP32-S3 当语音设备时往往是去连云端的 WS 服务器(用 esp_websocket_client 当客户端),方向和本篇相反,但帧的概念是同一套。
小智整体怎么搭,见 小智项目总览;语音识别和合成这一层见 ASR/TTS 这篇,麦克风采音频见 I2S 音频。你这篇跑通的"网页推数据、ESP32-S3 推回应",和小智推音频流骨架是同一个,只是帧类型从文本变成了二进制。
七、故障排查表
| 现象 | 可能原因 | 怎么办 |
|---|---|---|
编译报 is_websocket 不是成员 / 链接找不到 httpd_ws_* |
menuconfig 里没开 WS 支持 |
idf.py menuconfig → HTTP Server → 勾上 WebSocket support,重编 |
| 网页状态一直"连接中..." | /ws 路由没注册 / URL 路径写错 |
确认 httpd_register_uri_handler 注册了 /ws,网页连的也是 /ws |
| 控制台报 1006、连不上 | 手机和 ESP32-S3 不在同一 2.4G 网 | 两者连同一个路由器的 2.4G;ESP32-S3 不支持 5G(见 WiFi 那篇排查) |
| 连上后温度不更新 | push_task 没起 / 客户端没记进 fd 表 |
确认 xTaskCreate(push_task...) 调了;确认握手分支里 add_client 调了 |
| 收到指令乱码 / 后半截没了 | 只调了一次 httpd_ws_recv_frame |
必须两段式:先 len=0 问长度,再按 frame.len 收 |
推送时崩溃 / assert 失败 |
拿 req 在别的任务里推了 |
后台任务里必须用 httpd_ws_send_frame_async(要 server+fd),不能用 httpd_ws_send_frame |
| 网页关了再开,几个连接后推送报错 | fd 表存了已关闭的死连接 | 见下方变体,推送失败时把该 fd 从表里剔除 |
八、变体与进阶玩法
跑通基础版后,往实用、健壮方向各扩一步。
推 JSON,一条帧传多个字段。 实际控制台不会只显示一个温度。把推送内容换成 JSON:
char buf[96];
snprintf(buf, sizeof(buf),
"{\"temp\":%.1f,\"hum\":%.1f,\"led\":%d}",
temp, hum, gpio_get_level(LED_GPIO));
// 然后照常 push_to_client(fd, buf)
网页端 onmessage 里 let d = JSON.parse(e.data);,然后 d.temp、d.hum、d.led 分别填到各自位置。温度、湿度、灯状态一条帧全更新。
清理死连接(产品级必做)。 基础例子的 fd 表只增不减——网页一关,那条 fd 失效,下次 httpd_ws_send_frame_async 往它推就返回错误。生产里要在推送返回非 ESP_OK 时把这个 fd 从 s_client_fds 里剔除:
esp_err_t err = httpd_ws_send_frame_async(s_server, fd, &frame);
if (err != ESP_OK) {
// 推失败说明这条连接已死,从表里移除(用最后一个元素覆盖当前位置)
s_client_fds[i] = s_client_fds[--s_client_count];
}
更稳的做法是注册 httpd 的连接关闭回调(config.close_fn),连接一关就清理。并发连接管理是 WS 服务器最容易埋雷的地方——fd 表必须和真实连接同步,否则推送会越来越多地撞到死连接。
心跳保活。 WebSocket 协议有内置的 ping/pong 帧(HTTPD_WS_TYPE_PING / HTTPD_WS_TYPE_PONG),用来探测连接是否还活着。esp_http_server 可以开启自动 ping(config.enable_so_linger 等相关配置),或你在 push_task 里定期推一个 ping、收不到 pong 就判死。家用局域网一般不用操心,但设备挂久了、或经过会"静默掐断空闲连接"的路由器时,心跳能让你及时发现断线。前端那个 onclose 里的 setTimeout(connect, 1000) 是另一半——断线重连,网页侧已经写好了。
九、动手挑战
把本篇代码改成一个真正能用、还扛得住反复开关网页的"温湿度控制台":
- 接上 DHT11,把模拟温度换成真实读数,用上面的 JSON 写法同时推温度和湿度。
- 网页上把温度、湿度分两行大字显示,下面放"开灯/关灯"两个按钮,再加一个小绿点/灰点表示灯的当前状态——这个状态也由 ESP32-S3 推的
led字段决定,做到网页和设备状态永远一致。 - 关键一步:实现上面的"死连接清理",然后反复开关浏览器标签十几次,看串口推送有没有报错、设备会不会越来越卡。这一步能不能扛住,是"demo"和"产品"的分水岭。
做完你会发现,加传感器、加字段、加按钮都只是在同一条 WebSocket 管子上多塞内容,骨架没变。难的从来不是收发,是把并发连接管干净。
小结 · 你现在掌握了什么
- 你理解了 WebSocket 的本质:一次 HTTP 握手升级成长连接,之后两头随时双向推帧,延迟低;也想清了它和 HTTP 轮询、MQTT 各自的适用场景。
- 你会用
esp_http_server内置的 WS 支持——给路由打.is_websocket = true让 httpd 替你完成握手,在ws_handler里用两段式httpd_ws_recv_frame收帧。 - 你分清了双向的两个方向:被动回应用
httpd_ws_send_frame(有req),后台主动推用httpd_ws_send_frame_async(要server+ 握手时httpd_req_to_sockfd存的fd)。 - 你知道了 WS 服务器真正的难点是并发连接管理(fd 表要和真实连接同步、死连接要清理)、帧类型(文本/二进制/ping/pong)的区别,以及前端断线重连怎么写。
本篇代码为参考实现,需结合你所用的最新 ESP-IDF 文档自校——尤其
esp_http_server的 WS API(httpd_ws_recv_frame/httpd_ws_send_frame_async的签名、is_websocket字段、menuconfig里的WebSocket support开关)随版本可能微调,以官方文档和examples/protocols/http_server/ws_echo_server为准。
本篇是 ESP32-S3 和网页/云端之间的实时通信。如果你要让多个 ESP32-S3 之间直接低延迟互通、连路由器都不用,那是另一条路——下一篇看 ESP-NOW,ESP32 设备点对点直连通信。