ESP32 综合教程:用 WebSocket 做手机实时仪表盘与远程控制

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

ESP32 WebSocket 实时仪表盘

为什么不用网页每秒请求一次

ESP32 做网页仪表盘,最容易想到的方法是浏览器每秒调用一次 /status 接口。这在低频项目里完全能用,但当数据刷新更快、页面需要双向控制、多个手机同时连接时,频繁创建 HTTP 请求会显得笨重。

WebSocket 在一次握手后保持连接。ESP32 有新数据时可以主动推送给浏览器,浏览器也能立即发送控制命令。它适合实时温湿度、功率、转速、姿态、按钮状态等小消息,不适合不加限制地传巨大数据。

本项目让 ESP32 在 80 端口提供网页,在 81 端口提供 WebSocket。手机连接同一 WiFi,打开 ESP32 的 IP,就能看到实时数据并控制一个 LED。网页全部存放在固件里,不需要额外服务器或手机 App。

项目结构与库

需要一块 ESP32 DevKit。传感器可以先用模拟数值,跑通后再换成 BME280、SHT30 或其他模块。输出先使用 LED,验证稳定后才考虑继电器。

在 Arduino IDE 中安装:

  • WebSockets by Markus Sattler,也就是 Links2004/arduinoWebSockets。
  • ArduinoJson。

Arduino-ESP32 自带 WiFi.h 和 WebServer.h。

程序包含四个部分:

  1. HTTP 服务把 HTML 页面发给手机。
  2. WebSocket 服务管理长连接。
  3. 设备周期采样,并广播 JSON 状态。
  4. 收到控制命令后校验、执行,再回报最终状态。

先准备网页

网页使用一张轻量仪表板,状态颜色明确,不依赖外部 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 任务,通过队列把最新状态送给网络层。

WebSocket 客户端状态流程

接入真实传感器

把示例数值替换为传感器读取函数,并遵循三条规则:

  1. 传感器采样周期按器件要求设置,不要网页刷新多快就读多快。
  2. 检查 NaN、超范围和通信错误,无效数据不广播成正常值。
  3. 浏览器刷新频率不必等于采样频率;数据没变化时可以减少广播。

例如温湿度每两秒采样一次,页面动画仍可由浏览器平滑显示。让 ESP32 每 20 毫秒读取一只本来就两秒更新一次的传感器,只是在非常勤奋地重复问同一个问题。

多客户端与命令冲突

WebSocket 允许多个手机同时连接。状态广播很自然,但控制会出现冲突:A 手机打开 LED,B 手机马上关闭。设备应以最终执行状态为准,并广播给所有页面。

更复杂的控制可以增加:

  • 命令编号和确认消息。
  • 控制者身份与权限。
  • 操作时间戳和日志。
  • 参数上下限。
  • 设备自动模式与手动模式互锁。

不要相信按钮点击以后一定执行成功。页面应等待设备回报状态,而不是本地先把“关闭”改成“开启”就算完成。

局域网安全边界

本文使用明文 HTTP 和 ws://,没有登录认证,只适合可信实验网络。任何能访问设备 IP 的客户端都可能发送命令。

不要通过路由器端口映射把它直接暴露到互联网。需要远程访问时,优先通过 VPN、安全网关或成熟的物联网平台;如果设备本身提供公网服务,还要处理 TLS、证书、认证、速率限制、固件更新和漏洞响应,这已经超出一个入门仪表盘的边界。

分阶段调试

  1. ESP32 只连接 WiFi,确认手机能 ping 或访问 IP。
  2. 只启动 HTTP,确认网页完整显示。
  3. 启动 WebSocket,在串口观察客户端连接和断开。
  4. 发送固定 JSON,确认页面数值变化。
  5. 加入 LED 命令,检查设备执行后是否广播状态。
  6. 替换真实传感器,并拔掉传感器测试错误路径。
  7. 重启路由器、关闭手机 WiFi、锁屏后再打开,观察重连。
  8. 同时打开两个手机页面,验证状态一致。

常见问题

现象 排查方向
网页打不开 ESP32 IP、手机是否同一网络、HTTP 服务是否启动
网页有内容但一直离线 81 端口、WebSocket 服务、浏览器控制台
一段时间后断线 主循环阻塞、WiFi 信号、供电或内存问题
页面数值不变化 JSON 字段、消息类型、采样周期和有效性检查
LED 状态相反 板载 LED 可能低电平点亮
两个页面显示不同 执行命令后没有广播最终状态
浏览器不断重连 地址错误、服务未启动或主循环没有调用 webSocket.loop()

后续扩展

可以加入 PWM 滑块、历史曲线、设备配置、OTA 进度和日志窗口。图表数据建议只保留有限窗口,例如最近 60 个点,长期历史交给服务器或 SD 卡保存。

还可以让 ESP32 同时作为 WiFi 热点,手机直接连接设备使用。不过 STA 与 AP 模式、配网、固定地址和安全策略需要重新设计。无论增加多少功能,都应保留连接状态、命令确认和错误边界。

总结

WebSocket 让 ESP32 网页从“定时刷新数据”升级成真正的双向实时界面。HTTP 负责交付页面,WebSocket 负责状态推送与命令,设备则对每条命令做校验并广播最终结果。

这个项目最重要的不是页面颜色,而是连接生命周期:上线后同步状态,断线时禁用控制,延迟重连,命令执行后以设备状态为准。把这些细节处理好,手机仪表盘才不只是能演示,而是能在网络偶尔不听话时仍然表现得有条理。

参考资料

打赏

如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏码

打赏支持:王庆岩

posted on 2026-09-21 00:02  潇洒石头  阅读(25)  评论(0)    收藏  举报

导航