← 返回教程库

WebSocket 实时双向通信:让网页和 ESP32-S3 互相推消息

最后更新 2026-06-22
L3 · 联网与 IoT ⏱ 约 22 分钟 🟢 软件/低风险
你将学到
  • 说清 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_starthttpd_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: websocketConnection: UpgradeSec-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> &deg;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 menuconfigComponent configHTTP Server 里把 WebSocket supportCONFIG_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 收到 ongpio_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_BINARYpayload 指向音频缓冲区即可,骨架完全一样。另外 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)

网页端 onmessagelet d = JSON.parse(e.data);,然后 d.tempd.humd.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) 是另一半——断线重连,网页侧已经写好了。


九、动手挑战

把本篇代码改成一个真正能用、还扛得住反复开关网页的"温湿度控制台":

  1. 接上 DHT11,把模拟温度换成真实读数,用上面的 JSON 写法同时推温度和湿度。
  2. 网页上把温度、湿度分两行大字显示,下面放"开灯/关灯"两个按钮,再加一个小绿点/灰点表示灯的当前状态——这个状态也由 ESP32-S3 推的 led 字段决定,做到网页和设备状态永远一致。
  3. 关键一步:实现上面的"死连接清理",然后反复开关浏览器标签十几次,看串口推送有没有报错、设备会不会越来越卡。这一步能不能扛住,是"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 设备点对点直连通信。

更多路线安排见 学习地图,全部教程在 指南首页

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明