ESP32 综合教程:用 WebSocket 做手机实时仪表盘与远程控制
本文由 AI 协助整理,示例运行在可信局域网内,不包含公网级身份认证和 TLS。不同 Arduino-ESP32 与 arduinoWebSockets 版本可能存在接口差异,请参考已安装版本示例。继电器、电机等真实负载必须增加电气隔离、默认安全状态和独立保护。

为什么不用网页每秒请求一次
ESP32 做网页仪表盘,最容易想到的方法是浏览器每秒调用一次 /status 接口。这在低频项目里完全能用,但当数据刷新更快、页面需要双向控制、多个手机同时连接时,频繁创建 HTTP 请求会显得笨重。
WebSocket 在一次握手后保持连接。ESP32 有新数据时可以主动推送给浏览器,浏览器也能立即发送控制命令。它适合实时温湿度、功率、转速、姿态、按钮状态等小消息,不适合不加限制地传巨大数据。
本项目让 ESP32 在 80 端口提供网页,在 81 端口提供 WebSocket。手机连接同一 WiFi,打开 ESP32 的 IP,就能看到实时数据并控制一个 LED。网页全部存放在固件里,不需要额外服务器或手机 App。
项目结构与库
需要一块 ESP32 DevKit。传感器可以先用模拟数值,跑通后再换成 BME280、SHT30 或其他模块。输出先使用 LED,验证稳定后才考虑继电器。
在 Arduino IDE 中安装:
WebSocketsby Markus Sattler,也就是 Links2004/arduinoWebSockets。ArduinoJson。
Arduino-ESP32 自带 WiFi.h 和 WebServer.h。
程序包含四个部分:
- HTTP 服务把 HTML 页面发给手机。
- WebSocket 服务管理长连接。
- 设备周期采样,并广播 JSON 状态。
- 收到控制命令后校验、执行,再回报最终状态。
先准备网页
网页使用一张轻量仪表板,状态颜色明确,不依赖外部 CDN。这样路由器能工作但互联网断开时,页面仍然能完整打开。
const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32 实时仪表盘</title>
<style>
body{font-family:system-ui;margin:0;background:#f3f4f6;color:#172033}
main{max-width:680px;margin:auto;padding:24px}
header{display:flex;justify-content:space-between;align-items:center}
#online{font-weight:700;color:#b42318}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:24px 0}
.panel{background:white;border:1px solid #d8dee9;padding:20px;border-radius:8px}
.label{color:#667085;font-size:14px}.value{font-size:36px;margin-top:8px}
button{border:0;border-radius:6px;padding:12px 20px;background:#1769aa;color:white;font-size:16px}
button:disabled{background:#98a2b3}
@media(max-width:520px){.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<main>
<header><h1>ESP32 仪表盘</h1><span id="online">离线</span></header>
<div class="grid">
<section class="panel"><div class="label">温度</div><div class="value"><span id="temp">--</span> °C</div></section>
<section class="panel"><div class="label">湿度</div><div class="value"><span id="hum">--</span> %</div></section>
</div>
<section class="panel">
<div class="label">LED 状态:<strong id="led">--</strong></div><br>
<button id="toggle" disabled>切换 LED</button>
</section>
</main>
<script>
let ws;
let ledOn = false;
let retryTimer;
function connect() {
clearTimeout(retryTimer);
ws = new WebSocket(`ws://${location.hostname}:81/`);
ws.onopen = () => {
document.querySelector('#online').textContent = '在线';
document.querySelector('#online').style.color = '#067647';
document.querySelector('#toggle').disabled = false;
ws.send(JSON.stringify({type:'get_state'}));
};
ws.onmessage = event => {
try {
const data = JSON.parse(event.data);
if (data.type !== 'state') return;
document.querySelector('#temp').textContent = Number(data.temperature).toFixed(1);
document.querySelector('#hum').textContent = Number(data.humidity).toFixed(1);
ledOn = Boolean(data.led);
document.querySelector('#led').textContent = ledOn ? '开启' : '关闭';
} catch (error) {
console.error('Bad message', error);
}
};
ws.onclose = () => {
document.querySelector('#online').textContent = '离线';
document.querySelector('#online').style.color = '#b42318';
document.querySelector('#toggle').disabled = true;
retryTimer = setTimeout(connect, 3000);
};
}
document.querySelector('#toggle').addEventListener('click', () => {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({type:'set_led', value:!ledOn}));
});
connect();
</script>
</body>
</html>
)rawliteral";
页面断线后等待三秒重连,而不是立即创建无限次连接。控制按钮离线时禁用,恢复连接后先请求当前状态,避免页面拿着旧值继续发命令。
建立 HTTP 与 WebSocket 服务
#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <ArduinoJson.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_PASSWORD";
constexpr uint8_t LED_PIN = 2; // 按开发板修改
WebServer httpServer(80);
WebSocketsServer webSocket(81);
bool ledOn = false;
float temperature = 24.0f;
float humidity = 50.0f;
unsigned long lastSampleAt = 0;
HTTP 根路径返回存储在程序空间中的页面:
void setupHttp() {
httpServer.on("/", HTTP_GET, []() {
httpServer.send_P(200, "text/html; charset=utf-8", INDEX_HTML);
});
httpServer.onNotFound([]() {
httpServer.send(404, "text/plain", "Not found");
});
httpServer.begin();
}
把网页和 WebSocket 分成两个端口,是为了让学习代码直观。正式项目也可以采用支持同端口升级的服务器实现,但需要根据所选库重新组织代码。
统一状态消息
设备给浏览器发送固定 JSON:
String buildStateJson() {
JsonDocument doc;
doc["type"] = "state";
doc["temperature"] = temperature;
doc["humidity"] = humidity;
doc["led"] = ledOn;
doc["uptime_ms"] = millis();
String output;
serializeJson(doc, output);
return output;
}
void sendState(uint8_t clientNum) {
webSocket.sendTXT(clientNum, buildStateJson());
}
void broadcastState() {
webSocket.broadcastTXT(buildStateJson());
}
发送 JSON 比用逗号拼接字符串略多一点开销,却能避免字段顺序变化后浏览器解析错位。消息还应保持小而明确,不要每次把历史曲线的全部数据重新发送一遍。
接收和校验控制命令
void handleTextCommand(uint8_t clientNum, uint8_t* payload, size_t length) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload, length);
if (error) {
webSocket.sendTXT(clientNum, "{\"type\":\"error\",\"message\":\"bad_json\"}");
return;
}
const char* type = doc["type"] | "";
if (strcmp(type, "get_state") == 0) {
sendState(clientNum);
return;
}
if (strcmp(type, "set_led") == 0 && doc["value"].is<bool>()) {
ledOn = doc["value"].as<bool>();
digitalWrite(LED_PIN, ledOn ? HIGH : LOW);
broadcastState();
return;
}
webSocket.sendTXT(clientNum,
"{\"type\":\"error\",\"message\":\"unknown_command\"}");
}
命令不是收到什么就执行什么。先限制消息长度、确认 JSON 能解析、检查命令类型和值的类型,最后只执行白名单动作。如果控制 PWM,还应限制范围;控制继电器,则要增加互锁、超时和上电安全状态。
WebSocket 事件回调:
void onWebSocketEvent(uint8_t clientNum, WStype_t type,
uint8_t* payload, size_t length) {
switch (type) {
case WStype_CONNECTED:
Serial.printf("WS client %u connected\n", clientNum);
sendState(clientNum);
break;
case WStype_TEXT:
if (length <= 256) {
handleTextCommand(clientNum, payload, length);
}
break;
case WStype_DISCONNECTED:
Serial.printf("WS client %u disconnected\n", clientNum);
break;
default:
break;
}
}
限制消息长度能避免一个客户端发来巨大文本耗尽内存。可信局域网也不等于所有设备都可信,基本边界仍然要有。
完成 setup 与 loop
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
unsigned long started = millis();
while (WiFi.status() != WL_CONNECTED && millis() - started < 15000) {
delay(200);
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println("WiFi connect failed");
return;
}
Serial.print("Open http://");
Serial.println(WiFi.localIP());
setupHttp();
webSocket.begin();
webSocket.onEvent(onWebSocketEvent);
}
void loop() {
httpServer.handleClient();
webSocket.loop();
if (millis() - lastSampleAt >= 1000) {
lastSampleAt = millis();
// 替换为真实传感器读取。
temperature += 0.1f;
if (temperature > 28.0f) temperature = 24.0f;
humidity = 50.0f + (millis() / 1000 % 10);
if (isfinite(temperature) && isfinite(humidity)) {
broadcastState();
}
}
}
httpServer.handleClient() 和 webSocket.loop() 必须频繁执行,主循环中不能放几秒钟的 delay(),也不要直接做耗时传感器校准或文件操作。复杂任务可交给 FreeRTOS 任务,通过队列把最新状态送给网络层。

接入真实传感器
把示例数值替换为传感器读取函数,并遵循三条规则:
- 传感器采样周期按器件要求设置,不要网页刷新多快就读多快。
- 检查
NaN、超范围和通信错误,无效数据不广播成正常值。 - 浏览器刷新频率不必等于采样频率;数据没变化时可以减少广播。
例如温湿度每两秒采样一次,页面动画仍可由浏览器平滑显示。让 ESP32 每 20 毫秒读取一只本来就两秒更新一次的传感器,只是在非常勤奋地重复问同一个问题。
多客户端与命令冲突
WebSocket 允许多个手机同时连接。状态广播很自然,但控制会出现冲突:A 手机打开 LED,B 手机马上关闭。设备应以最终执行状态为准,并广播给所有页面。
更复杂的控制可以增加:
- 命令编号和确认消息。
- 控制者身份与权限。
- 操作时间戳和日志。
- 参数上下限。
- 设备自动模式与手动模式互锁。
不要相信按钮点击以后一定执行成功。页面应等待设备回报状态,而不是本地先把“关闭”改成“开启”就算完成。
局域网安全边界
本文使用明文 HTTP 和 ws://,没有登录认证,只适合可信实验网络。任何能访问设备 IP 的客户端都可能发送命令。
不要通过路由器端口映射把它直接暴露到互联网。需要远程访问时,优先通过 VPN、安全网关或成熟的物联网平台;如果设备本身提供公网服务,还要处理 TLS、证书、认证、速率限制、固件更新和漏洞响应,这已经超出一个入门仪表盘的边界。
分阶段调试
- ESP32 只连接 WiFi,确认手机能 ping 或访问 IP。
- 只启动 HTTP,确认网页完整显示。
- 启动 WebSocket,在串口观察客户端连接和断开。
- 发送固定 JSON,确认页面数值变化。
- 加入 LED 命令,检查设备执行后是否广播状态。
- 替换真实传感器,并拔掉传感器测试错误路径。
- 重启路由器、关闭手机 WiFi、锁屏后再打开,观察重连。
- 同时打开两个手机页面,验证状态一致。
常见问题
| 现象 | 排查方向 |
|---|---|
| 网页打不开 | ESP32 IP、手机是否同一网络、HTTP 服务是否启动 |
| 网页有内容但一直离线 | 81 端口、WebSocket 服务、浏览器控制台 |
| 一段时间后断线 | 主循环阻塞、WiFi 信号、供电或内存问题 |
| 页面数值不变化 | JSON 字段、消息类型、采样周期和有效性检查 |
| LED 状态相反 | 板载 LED 可能低电平点亮 |
| 两个页面显示不同 | 执行命令后没有广播最终状态 |
| 浏览器不断重连 | 地址错误、服务未启动或主循环没有调用 webSocket.loop() |
后续扩展
可以加入 PWM 滑块、历史曲线、设备配置、OTA 进度和日志窗口。图表数据建议只保留有限窗口,例如最近 60 个点,长期历史交给服务器或 SD 卡保存。
还可以让 ESP32 同时作为 WiFi 热点,手机直接连接设备使用。不过 STA 与 AP 模式、配网、固定地址和安全策略需要重新设计。无论增加多少功能,都应保留连接状态、命令确认和错误边界。
总结
WebSocket 让 ESP32 网页从“定时刷新数据”升级成真正的双向实时界面。HTTP 负责交付页面,WebSocket 负责状态推送与命令,设备则对每条命令做校验并广播最终结果。
这个项目最重要的不是页面颜色,而是连接生命周期:上线后同步状态,断线时禁用控制,延迟重连,命令执行后以设备状态为准。把这些细节处理好,手机仪表盘才不只是能演示,而是能在网络偶尔不听话时仍然表现得有条理。
参考资料
打赏
如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏支持:王庆岩
浙公网安备 33010602011771号