这阵子在做一个基于 ESP32S3 的物联网网关项目,卡在了一个看起来简单、实际很考验细节的环节——给设备配网。项目里没有屏幕、没有按键,用户拿到设备后第一步就是让它联网,而 AP 配网是我最终选定的方案。这篇是 ESP32S3 实战系列的第三篇,重点拆一下 AP 配网里 HTTP 和 WebSocket 两种实现路径,包括协议设计、状态机、代码落地和我在实测中踩过的坑。
1. 为什么在ESP32S3上还要用AP配网
1.1 配网本质与主流方案取舍
配网的本质,是把用户家里的 WiFi SSID 和密码"塞"进一个还没有入网的设备里。ESP32S3 支持三种常见路径:
| 方案 | 原理 | 优点 | 痛点 |
|---|---|---|---|
| SmartConfig | 手机 App 用 UDP 广播编码后的 WiFi 信息,设备监听空中报文并解码 | 用户体验快 | Android 需要位置权限;部分路由器隔离组播,成功率不稳定;依赖特定 App |
| BLE 配网 | 设备开启 BLE GATT 服务,手机通过小程序或 App 写入 WiFi 信息 | 交互可控、支持带外认证 | 必须额外开发蓝牙服务与手机端;纯 WiFi 产品硬塞 BLE 增加成本和功耗 |
| AP 配网 | 设备开启 softAP,手机连接热点后通过浏览器页面完成配置 | 零额外 App、跨平台兼容性最好 | 流程较长;需要处理"连接热点后无互联网"的边界体验 |
我一开始倾向 SmartConfig,因为听起来最"极客"。但实测发现它高度依赖手机端实现,不同 ROM 对组播报文的处理差异很大,用户 A 能配上、用户 B 配不上,排查成本极高。BLE 配网很稳,但如果产品本身没有蓝牙需求,强行引入 BLE 开发量并不小。最终选择 AP 配网,核心原因是浏览器是每台手机都有的东西,用户体验路径是"连热点-打开网页-填密码",这套逻辑对用户几乎零学习成本。
1.2 ESP32S3做AP配网的硬件底子
ESP32S3 的 softAP 能力在 ESP-IDF 里非常成熟,802.11 b/g/n 协议,最大支持 4 个客户端连接。做配网场景,这个容量完全够用,因为实际场景基本只有一个手机同时连上来。S3 这颗芯片本身是双核 Xtensa LX7,主频 240MHz,跑 HTTP 服务器、WebSocket 服务器、WiFi 协议栈并行处理时,CPU 余量很充足,不会因为配网逻辑拖累主业务。
AP 配网最常见的硬件形态是设备上电后直接进入 AP 模式,热点名带设备唯一标识,例如ESP32S3-Config-XXXX。用户在手机 WiFi 列表里看到这个名字,就知道这是待配网设备。硬件上的核心约束是:设备只有一个 2.4GHz 射频,AP 模式和 STA 模式会共享射频资源,后续做"AP 保持 + STA 连接目标路由器"时,信道规划要提前想清楚,这点我在第 5 节展开。
2. 配网状态机先行:从设备上电到STA成功的完整路径
2.1 状态定义与转换逻辑
很多人写配网代码喜欢"想到哪写到哪",结果就是上电后 AP 开没开、配置收没收到、STA 连没连上,全靠 log 猜。我在项目里先把状态机用枚举定义出来,再围绕状态机写代码,后期排查问题只需要看当前处于哪个状态。
typedef enum { WIFI_CONFIG_STATE_IDLE = 0, WIFI_CONFIG_STATE_CHECK_SAVED, WIFI_CONFIG_STATE_START_AP, WIFI_CONFIG_STATE_WAIT_CLIENT, WIFI_CONFIG_STATE_SCANNING, WIFI_CONFIG_STATE_RECEIVED, WIFI_CONFIG_STATE_CONNECTING, WIFI_CONFIG_STATE_SUCCESS, WIFI_CONFIG_STATE_FAILED } wifi_config_state_t;状态流转的核心逻辑是:上电进入CHECK_SAVED,检查 NVS 里有没有保存过的 WiFi 配置;如果有,先尝试直接用这套配置连接路由器,连接成功就直接进入正常工作模式,不打扰用户。只有"没有配置"或者"连接失败"时才启动 AP 配网流程。这个设计非常关键——如果每次上电都无脑开 AP,用户重启一次就要重新配一次网,产品体验就毁了。
从START_AP开始,设备进入 AP 模式,同时启动 HTTP 服务器。WAIT_CLIENT表示等待手机连入。用户打开配置页面后会触发扫描 Wi-Fi 列表或提交配置;收到合法配置后进入CONNECTING,这时候设备同时保持 AP 与 STA 模式,尝试连接目标路由器。最终要么进入SUCCESS关闭 AP、回到 STA 模式,要么超时进入FAILED兜底。
2.2 配置存储与合法性校验
WiFi 配置存到 NVS 时,我建议用固定的 namespace 和 key,并且保存一份 CRC 校验值,防止读取到半包脏数据。ESP-IDF 的 NVS 读写在断电场景下有掉电保护,但一次写入 64 字节密码加 32 字节 SSID 时仍然要封装成结构体,不要一个字段一个 key 去写,避免多次写导致 flash 磨损。
合法性校验放在进入CONNECTING之前:
- SSID 非空,长度 1 到 32 字节
- 密码长度允许 0(开放网络),但 1 到 7 字节的密码在 WPA2 下必然是错的,直接判非法
- 密码里如果有特殊字符如
&、=、%,URL 解码后要正确处理,不能只做字符串截断 - 如果用户提交的 WiFi 是 WPA2-Enterprise 企业级认证,AP 配网方案默认不处理,这种场景应该走 BLE 或以太网配网
校验不通过时,HTTP 接口或 WebSocket 消息要返回明确的错误码,例如{"code":400,"message":"invalid password length"},前端页面根据错误码显示提示,而不是笼统弹一个"配置失败"。
2.3 配网超时与自恢复机制
AP 配网不能无限等下去。实际产品中我设置了两个超时:
第一,从START_AP开始 10 分钟没有任何客户端连接,判定配网超时。处理策略是关闭 AP 和 HTTP 服务器,进入深度休眠或重启回 STA 模式尝试已保存配置。这个机制能避免设备在仓库里一直开着热点耗电。
第二,从CONNECTING开始 20 秒内没有收到 STA 连接成功事件,判定本次配网失败。此时读取 NVS 里保存的旧配置,如果能连就退回正常模式;如果没有旧配置或旧配置也连不上,就留在 AP 模式允许用户重新配网,同时把失败次数累加,连续失败 3 次后重启设备回到初始 AP 状态。
3. HTTP配网落地:服务器搭建、配置页与轮询妥协
3.1 内置HTTP服务器与URI设计
ESP-IDF 自带的esp_http_server组件足够支撑配网需求,不需要引入 nginx 之类的重型方案。初始化时只需要配置httpd_config_t,设置最大 open sockets 数和 URI 匹配方式,然后启动:
httpd_handle_t server = NULL; httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = 8192; config.max_uri_handlers = 8; config.lru_purge_enable = true; esp_http_server_start(&server);stack_size=8192是实测后的经验值。默认 4096 在 HTTPS 或复杂页面渲染时容易出现栈溢出导致重启,而 8192 能稳定覆盖 JSON 解析和字符串拼接的场景。lru_purge_enable必须开启,否则客户端断开后连接不主动释放,连续配两次网就会报HTTPD_ERR_OPEN_FAILED。
URI 设计遵循"页面、数据、动作分离"的原则:
| URI | Method | 作用 |
|---|---|---|
/ | GET | 返回配网配置页面 HTML |
/scan | GET | 扫描周围 WiFi 列表,返回 JSON 数组 |
/config | POST | 接收 SSID 和密码,触发 STA 连接 |
/status | GET | 返回当前配网状态的 JSON |
不建议把所有逻辑都塞进一个/里做 GET/POST 分发,后面的 WebSocket 升级也要复用/scan和/config的数据处理函数,URI 分开之后能直接复用逻辑。
3.2 配置页面的交互设计与表单处理
配网页面我用的是单页 HTML + 原生 JavaScript,不依赖任何前端框架。原因很简单:配网页面运行在手机浏览器里,用户可能用微信内置浏览器打开,可能用 iOS Safari,也可能用 Android Chrome,这些浏览器对 ES6 以上的语法支持参差不齐,原生 ES5 写法兼容性最稳。
页面核心交互是三段式:
- 页面加载时自动调用
/scan接口,把附近 WiFi 列表渲染成一个下拉框,用户不用手动输入 SSID,减少输错的概率 - 用户选择 WiFi 后输入密码,点"连接"按钮,表单通过 fetch 发 POST 请求到
/config - 之后页面开始轮询
/status,实时显示"正在连接路由器-连接成功-保存配置-配网完成"的步骤
HTML 里的表单提交,我做了两次安全处理。第一次是前端 JS 的字符串长度校验,第二次是服务端esp_http_server接收 POST body 后统一做长度校验和 URL 解码。这里有个容易遗漏的点:httpd_req_recv拿到的 body 可能是分块的,接收循环必须处理返回值HTTPD_SOCK_ERR_TIMEOUT,否则大 body 传到一半会超时断开。
httpd_resp_set_type(req, "application/json"); httpd_resp_set_hdr(req, "Cache-Control", "no-cache"); httpd_resp_send(req, json_buf, strlen(json_buf));Cache-Control: no-cache这个 Header 不是随便加的。实测中 Android Chrome 会缓存/status的响应,如果不加这个 Header,轮询拿到的永远是第一次的结果,页面永远显示"连接中",非常坑。
3.3 轮询实现配网进度:体验可以接受但不是最优
HTTP 是典型的请求-响应模型,服务端无法主动向客户端推送状态。所以用纯 HTTP 方案时,前端只能轮询/status接口。我实测下来的经验是:轮询间隔 2 秒比较合理,太频繁会增加 AP 模式下设备的负载,太慢则用户体验拖沓。
轮询接口返回的 JSON 结构要稳定:
{ "state": "CONNECTING", "step": 3, "message": "connecting to router", "ssid": "MyWiFi" }前端拿到state后渲染对应文案,step用来驱动步骤条动画。HTTP 方案的优点是实现非常简单,服务端只需维护一个全局状态变量,不需要管理任何连接上下文;缺点也很明显——配网过程中用户盯着页面,状态不是即时更新的,最多 2 秒一次,而且用户如果在 WiFi 切换的过程中点了刷新,可能刚好赶上 STA 连接导致的 AP 短暂延迟,直接请求超时。
纯 HTTP 方案适合对交互要求不高的场景,比如工程现场用电脑连着网线调试设备,或者产品团队明确不想维护 WebSocket 逻辑。但如果你做的是面向家庭用户的消费级产品,用户拿着手机配网时,你希望他看到的是一串流畅的状态动画,而不是一个每 2 秒跳一下的进度条,那 WebSocket 方案就是必选项。
4. WebSocket升级:把配网从"提交-刷新"变成实时会话
4.1 为什么配网场景需要实时通道
配网过程最大的不确定性在"设备连接目标路由器"这个阶段。SSID 填对了、密码填对了,路由器握手加 DHCP 拿 IP,整个过程快则 2 秒,慢则 10 秒甚至更久。HTTP 轮询方案下,用户点完"连接"之后看到的是静止的页面,不知道设备是不是已经死了,经常连点三次"连接",产生并发请求把设备状态搞乱。
WebSocket 的优势是建立一条全双工通道后,服务端主动推送状态。用户点一次"连接"按钮,设备侧每进入一个新状态,就推一条消息到浏览器,前端收到消息再更新 UI。整个配网过程变成了一场实时对话,用户的等待焦虑大幅降低。
4.2 WebSocket服务端设计与消息协议
ESP-IDF 从 v4.4 开始提供esp_websocket_server组件,v5.x 版本已经比较稳定。初始化方式与 HTTP server 类似:
esp_websocket_server_config_t ws_config = { .port = 80, .uri = "/ws", .max_open_sockets = 4, .task_stack = 8192, }; esp_websocket_server_t *ws_server = NULL; esp_websocket_server_start(&ws_server, &ws_config);注意一个细节:WebSocket 监听端口我复用 80,而不是另开 8080。原因是配网场景下手机连着 AP 访问页面,如果用非标准端口,部分浏览器或网络环境会拦截 ws 连接。复用 80 端口意味着 HTTP 服务和 WebSocket 服务在同一个端口上共存,ESP-IDF 的esp_http_server和esp_websocket_server能否绑定同一端口需要看版本实现,实测在 IDF 5.1 之后可以通过 URI 路径区分协议,/走 HTTP,/ws走 WebSocket。如果你用的旧版本,可以把 WebSocket 服务放在 8080,但前端连接地址要写全端口。
消息协议我定义成 JSON 格式,服务端到客户端的事件如下:
| 事件 | 方向 | 示例 |
|---|---|---|
status_change | 服务端 -> 客户端 | {"type":"status_change","state":"SCANNING"} |
scan_result | 服务端 -> 客户端 | {"type":"scan_result","list":[{"ssid":"MyWiFi","rssi":-45}]} |
config_ack | 服务端 -> 客户端 | {"type":"config_ack","code":0} |
client_hello | 客户端 -> 服务端 | {"type":"hello","device":"phone"} |
req_scan | 客户端 -> 服务端 | {"type":"req_scan"} |
req_config | 客户端 -> 服务端 | {"type":"req_config","ssid":"MyWiFi","password":"12345678"} |
client_hello是客户端连接建立后的第一条消息,作用有两个:一是让服务端知道当前有活跃配置端,可以把扫描结果主动推过来,省去客户端再发req_scan;二是用来做连接保活,服务端如果在 30 秒内没收到任何消息,主动关闭连接释放资源。
4.3 配网状态实时推送的完整流程
WebSocket 方案下,完整的配网流程变成了这样:
- 手机连上 AP 热点,浏览器打开
192.168.4.1,页面加载时同时建立ws://192.168.4.1/ws连接 - 服务端收到
client_hello,马上推送status_change,当前状态是WAIT_CLIENT - 前端发
req_scan,服务端扫描附近 WiFi,2 秒后推送scan_result - 用户选择 WiFi、输入密码、点连接,前端发
req_config - 服务端校验通过后,回
config_ack,同时启动 STA 连接流程 - 连接过程中,凡是发生状态变化,立即推
status_change——SCANNING、CONNECTING、SUCCESS - 前端收到
SUCCESS后,提示"配网完成,设备即将重启",2 秒后设备关闭 AP,WebSocket 断开
WebSocket 的 handler 注册方式比 HTTP 的 handler 更简单,因为连接建立之后可以持有客户端 fd,后续任何位置都能通过 fd 发送消息:
static void ws_handler(esp_websocket_event_data_t *data, char *payload) { if (data->opcode == WS_OPCODE_TEXT) { cJSON *root = cJSON_Parse(payload); // 解析 req_config / req_scan 等指令 } }关键点在于:esp_websocket_server_send这个接口,必须在发送前确认 fd 仍有效。配网过程中设备尝试连接路由器时,AP 模式会短暂抖动,某些手机上 WebSocket 连接会先断开。我实测最常见的场景是:STA 连接成功、DHCP 获取 IP 后,射频切换导致 AP 信道调整,手机短暂掉线。所以服务端发送SUCCESS之前,我会先重试三次发送,每次间隔 200ms,确保客户端能收到成功事件,否则用户会看到"配网失败",实际上设备已经联网了。
5. 实测踩过的坑:稳定性、兼容性与异常恢复
5.1 手机连AP后"无互联网"的页面加载问题
这是 AP 配网里最容易让用户困惑的问题,没有之一。手机连上设备开的 AP 后,系统检测不到外网,Android 会弹窗提示"当前网络无法访问互联网,是否继续连接",iOS 则会在锁屏后自动断开该 WiFi。如果用户不理解这个提示,点"取消"或"不使用",配网页面根本打不开。
我的处理方式有三层:
- 热点 SSID 设置为
ESP32S3-Config-XXXX,并且广播时不给 SSID 加特殊字符,用户一看就知道是设备热点,连之前有心理预期 - 配置页面顶部加了一行浅色提示条:"连接此热点时,手机可能提示'无互联网连接',请选择继续使用"
- Android 的弹窗文字虽然是系统级的,但设备侧可以通过 HTTP 响应的状态码和 Header 做引导。实测发现有些手机会在浏览器加载前发一个
captive_portal检测请求去访问connectivitycheck.gstatic.com,如果设备在 AP 模式下拦截所有 DNS 并统一返回 HTTP 204,部分 Android 设备就不会弹"无互联网"提示
需要注意的是,不要为了实现这个检测而在 AP 模式下把 DNS 劫持到本地,否则用户连完 WiFi 后出现 DNS 缓存问题,反而会造成"配网成功但上不了网"的假象。比较稳妥的做法是只在192.168.4.1这个地址上提供 HTTP 服务,对其它域名的请求返回空白页,不主动劫持。
5.2 WebSocket异常断连与内存水位
WebSocket 比 HTTP 复杂的地方在于连接是有状态的。配网场景下,用户可能中途关掉页面、切到别的 App、或者手机锁屏,WebSocket 连接会以各种姿势断开。服务端如果不做清理,时间一长就会把连接池占满,新的连接进不来。
esp_websocket_server提供了连接断开的事件回调,务必要在里面做资源清理:
if (data->type == WS_EVENT_DISCONNECTED) { // 清除对应客户端的状态缓存 memset(&client_state[client_id], 0, sizeof(client_state[client_id])); }另一个坑是内存水位。配网 WebSocket 服务即使只有 1 个客户端,也需要为它分配发送缓冲区。如果一次推送的 JSON 内容较大(比如扫描结果包含 20 个 WiFi),发送缓冲区要在栈上开足够大,但我建议用堆上的动态缓冲区,扫描结果列表可能因为附近路由器多而变大,超出栈空间直接造成崩溃。我后来统一用cJSON动态构造 JSON,发送完立即释放,实测内存在配网全流程中波动不超过 3KB,不会触发碎片化问题。
还有一个容易忽略的场景:用户在配网成功前不断刷新页面,会导致浏览器旧 WebSocket 连接没有正常关闭,设备上残留半开连接。我加了一条规则:同一设备只保留最新 2 个客户端连接,旧的强制断开。这个逻辑在 NFC 配网、多手机同时连 AP 的极端场景下也适用。
5.3 配网中途断电/输错密码的兜底逻辑
用户输错密码是家常便饭,而且用户不会觉得是自己输错了,只会觉得设备坏了。我在配置页面就做了一层预判:密码长度不足 8 位时,前端直接提示"WiFi 密码至少 8 位",不发请求;如果密码长度合法但实际错,设备会在 STA 连接阶段收到WIFI_EVENT_STA_DISCONNECTED且 reason 是WIFI_REASON_AUTH_EXPIRE或WIFI_REASON_MIC_FAILURE,此时要立即推送"密码错误"而不是等 20 秒超时。
配网中途断电,是最难处理但必须面对的场景。可能用户刚点完"连接",手一抖把设备电源拔了。等设备重新上电,如果没保存过合法配置,就会重新进入 AP 配网模式,这是兜底逻辑的最后防线。我在RECEIVED状态收到合法配置后,第一步就写入 NVS,而不是等 STA 连接成功后才写。这样即使中途断电,重新上电时设备能直接用已保存的配置去连路由器,用户不会觉得自己白配了一次。
写在 NVS 之后、尝试连接之前的状态是CONFIG_SAVED,这个状态不会因为断电丢失。实际测试中,我在 STA 连接阶段拔电再上电,设备能自动用已保存的配置连上路由器,整个过程用户不需要任何额外操作——这个体验细节,直接决定了用户对产品可靠性的评价。
如果 STA 连接失败,设备在 20 秒后自动关闭连接流程,回到 AP 模式等待重试,同时通过 WebSocket 推送详细失败原因。页面上会显示类似"认证失败,请检查密码"的清晰文案。这样用户不用反复拔电,直接把页面上的密码改掉再点一次连接即可。
讨一句实在话
做了这么多配网项目,我最大的体会是:协议层面的 HTTP 和 WebSocket 之争,本质是产品体验策略之争。HTTP 方案代码量少,适合快速出活的工程调试场景,但交互粒度太粗;WebSocket 方案代码量翻倍,换来的是用户配网过程中的实时反馈和信心。如果你问我下一次做新品会选哪个——只要面向的是普通消费者,我无脑选 WebSocket,因为配置状态实时推送这个能力,是用户感知产品智能化程度最直观的一环。
另外一个建议是,无论用哪种协议,配网状态机一定要单独画出来给团队里每个人看。代码会重构,状态机不会。只要状态转换逻辑是清晰的,哪怕某天你要从 HTTP 迁移到 WebSocket,改动也仅限于协议层,底层的状态流转完全不用动。这次在 ESP32S3 上把 AP 配网跑通后,我顺手把状态机模块抽了出来,后续接 BLE 配网或者二维码配网,都是新增入口的问题,不用再从头写一遍。