PAYATHON 2026

如何在 ESP32 网页中用持续接收的数据实时更新 Canvas 图表?

数据小许

可以实现,无需重新加载页面。保留从机通过 UDP 向主机发送数据的流程,由主机通过 WebSocket 将最新数值发给浏览器。浏览器更新 JavaScript 数组后,调用 Canvas 绘图函数即可。

当前代码已经能够重绘 Canvas,但主机接收到数据后,还需要把数据持续传给已打开的网页。ESP32 中的 C++ 变量和浏览器中的 JavaScript 变量位于不同设备上,修改前者不会自动更新后者。

数据应该怎样流动?

数据可以按以下流程传递:

从机 ESP32 采集数据
        ↓ UDP
主机 ESP32 解析数据,更新各通道的最新值
        ↓ WebSocket
浏览器接收消息,更新 JavaScript 数组
        ↓ requestAnimationFrame
清除 Canvas,按最新数组重新绘图

网页的 HTML 和 JavaScript 通常在打开页面时下载一次。后续只需传输传感器数值,无需反复发送整份 HTML。

Canvas 的绘图在访问网页的电脑上执行,主机主要负责接收、解析和转发数据。

GET、POST 和 fetch() 能否完成这件事?

可以,但需要调整原问题中对它们的理解:

  • GET 可以读取服务器上的最新数据,例如请求 /data 并获取 JSON。
  • POST 可以把数据提交给服务器,服务器可以据此修改共享状态,不要求使用 PHP。
  • fetch() 是浏览器发起 HTTP 请求的接口,可以获取 JSON、文本等内容,不局限于嵌入网页。
  • WebSocket 提供持续连接,服务器可以主动发送新数据。收到消息后,仍需要 JavaScript 调用绘图函数。

例如,客户端 A 通过 POST 更新服务器保存的数据后,客户端 B 可以通过 GET 读到更新后的值。但 B 已经打开的页面不会自动变化,需要主动请求,或通过 WebSocket 等机制接收通知。

这里希望尽量实时更新,可以采用 WebSocket。普通浏览器页面中的 JavaScript 无法直接使用原代码中的原始 UDP 套接字,需要由主机在 UDP 和浏览器通信之间进行转接。

先确定通道与消息格式

原网页绘制 47 个通道,每台从机的示例代码发送 8 个逗号分隔的整数。首先需要明确每台从机的这 8 个值分别对应图中的哪些通道。

建议在从机数据包中增加设备标识和递增序号。下面是一个可自行定义的协议示例:

nodeId,sequence,sensor1,sensor2,sensor3,sensor4,sensor5,sensor6,sensor7,sensor8

主机维护一份通道映射表,将各从机的数据写入对应位置。UDP 不保证送达和顺序。如果要避免旧包覆盖新值,可以按设备检查序号,同时处理序号回绕和设备重启的情况。

主机可以向浏览器统一发送完整快照。下面是仅有 8 个通道时的消息示例,数值仅用于说明格式:

{"type":"samples","values":[120,80,-15,90,110,75,60,105]}

如果页面配置为 47 个通道,values 就应包含 47 项。新浏览器连接时也应立即收到当前快照,不必等所有从机重新上报。

尚未收到数据的通道,正式使用时可以用 null 表示,并在页面上显示“无数据”。下面的绘图示例先用 0 初始化,以便集中说明数据接收和重绘过程。

浏览器端如何接收数据并重绘?

下面的页面假设主机已经在同一地址和端口提供 /ws WebSocket 端点,并按上述格式发送数据。它不能单独与原有 WiFiServer 代码配合运行,还需要完成下一节的服务器端改造。

示例保留 47 个通道;若先用一台从机测试,可以把 CHANNEL_COUNT 改为 8,并让主机发送 8 项数组。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Planter Air Balancer</title>
</head>
<body>
  <h2>Planter Air Balancer</h2>
  <canvas id="myCanvas" width="800" height="400"
          style="border:2px solid #000000;">
    当前浏览器不支持 Canvas。
  </canvas>
  <p id="status">正在连接……</p>

  <script>
    const CHANNEL_COUNT = 47;

    // 示例显示范围,必须按实际测量数据调整。
    const DISPLAY_MIN = -1000;
    const DISPLAY_MAX = 1000;

    const canvas = document.getElementById("myCanvas");
    const ctx = canvas.getContext("2d");
    const status = document.getElementById("status");

    let values = Array(CHANNEL_COUNT).fill(0);
    let framePending = false;

    function valueToY(value) {
      const top = 20;
      const bottom = canvas.height - 30;
      const limited = Math.max(
        DISPLAY_MIN,
        Math.min(DISPLAY_MAX, value)
      );
      const ratio = (limited - DISPLAY_MIN) /
                    (DISPLAY_MAX - DISPLAY_MIN);
      return bottom - ratio * (bottom - top);
    }

    function draw() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.font = "10px Arial";
      ctx.textAlign = "center";

      const left = 20;
      const plotWidth = canvas.width - 40;
      const step = plotWidth / CHANNEL_COUNT;
      const zeroY = valueToY(0);

      ctx.strokeStyle = "#aaaaaa";
      ctx.beginPath();
      ctx.moveTo(left, zeroY);
      ctx.lineTo(left + plotWidth, zeroY);
      ctx.stroke();

      ctx.strokeStyle = "#2463eb";
      ctx.fillStyle = "#222222";

      values.forEach((value, index) => {
        const x = left + step * (index + 0.5);
        ctx.fillText(index + 1, x, canvas.height - 10);

        ctx.beginPath();
        ctx.moveTo(x, zeroY);
        ctx.lineTo(x, valueToY(value));
        ctx.stroke();
      });
    }

    function scheduleDraw() {
      if (framePending) return;

      framePending = true;
      requestAnimationFrame(() => {
        framePending = false;
        draw();
      });
    }

    function connect() {
      const protocol = location.protocol === "https:" ? "wss:" : "ws:";
      const socket = new WebSocket(`${protocol}//${location.host}/ws`);

      socket.onopen = () => {
        status.textContent = "已连接,等待数据";
      };

      socket.onmessage = (event) => {
        let message;

        try {
          message = JSON.parse(event.data);
        } catch {
          status.textContent = "收到无法解析的 JSON";
          return;
        }

        if (
          message === null ||
          message.type !== "samples" ||
          !Array.isArray(message.values) ||
          message.values.length !== CHANNEL_COUNT ||
          !message.values.every(Number.isFinite)
        ) {
          status.textContent = "收到的数据格式不符合约定";
          return;
        }

        values = message.values.slice();
        status.textContent = "已收到最新数据";
        scheduleDraw();
      };

      socket.onerror = () => {
        status.textContent = "连接发生错误";
        socket.close();
      };

      socket.onclose = () => {
        status.textContent = "连接断开,稍后重试";
        setTimeout(connect, 2000);
      };
    }

    scheduleDraw();
    connect();
  </script>
</body>
</html>

这里用 values[index] 替代了原来的 Math.random()。每次收到消息后,先替换数据,再安排一次绘图。如果同一显示帧到来前收到多条消息,只绘制最新状态,避免重复绘制。

示例中的 -1000 到 1000 是显示范围,不代表传感器的实际量程。超出范围的数值会被截到图表边缘。由于原代码有减去初始值的操作,示例将正负值分别画在零线两侧;如果实际数据只有正值,可以调整范围和基线。

requestAnimationFrame() 不保证每条采样都被显示,后台标签页也可能降低绘制频率。若需要保存完整采样历史,应单独记录数据。

主机 ESP32 需要怎样改造?

原代码使用 WiFiServer 手动读取 HTTP 请求、输出 HTML,然后调用 client.stop() 关闭连接。目前它没有实现 WebSocket 的握手和消息帧处理,不能直接接受前面的 /ws 连接。

需要选用与项目环境兼容的 WebSocket 服务端实现,或使用同时支持 HTTP 和 WebSocket 的服务器库。原材料没有给出相关库和版本,因此不能确定可直接调用的类名和方法。主机处理逻辑的流程伪代码如下:

初始化:
    提供网页
    启动 UDP 接收
    注册 WebSocket 连接处理

浏览器建立 WebSocket 连接时:
    向该浏览器发送当前通道快照

主循环或对应事件回调中:
    处理网络连接

    收到 UDP 数据包时:
        检查包长度
        解析设备标识、序号和传感器数值
        验证字段数量及数值格式
        根据通道映射更新当前快照
        标记快照已变化

    快照已变化且到达发送时机时:
        将当前快照编码为 JSON
        发送给所有已连接浏览器
        清除变化标记

多个从机可能接连发来数据。可以先将数据合并到最新快照,再按选定的发送间隔广播,避免每收到一个包就重复发送整个数组。间隔需要根据采样需求、通道数量和实测延迟决定,不能仅凭现有代码承诺具体刷新率。

如果 WebSocket 使用独立端口,浏览器中的连接地址也要明确写入该端口。前面的 location.host 写法只适用于页面与 WebSocket 使用同一主机和端口的情况。

原代码中有哪些地方会影响更新?

UDP 缓冲区需要限制读取长度

当前代码直接使用数据包长度读取:

char packetBuffer[150] = {0};
udp.read(packetBuffer, packetSize);

如果 packetSize 超过缓冲区容量,就可能越界;即使正好填满,也没有空间保留字符串结尾的 \0,之后的 Serial.println(packetBuffer) 可能继续读取缓冲区外的内容。

下面的示例可以替换接收部分,但仍需在校验成功后添加实际的数据解析:

int packetSize = udp.parsePacket();

if (packetSize > 0) {
  char packetBuffer[150];

  if (packetSize >= static_cast<int>(sizeof(packetBuffer))) {
    // 丢弃超过当前文本缓冲区容量的数据包。
    while (udp.available() > 0) {
      udp.read();
    }
  } else {
    int received = udp.read(packetBuffer, sizeof(packetBuffer) - 1);

    if (received == packetSize) {
      packetBuffer[received] = '\0';
      Serial.println(packetBuffer);
      // 在这里解析并校验数据,再更新通道快照。
    }
  }
}

这里的 150 字节沿用现有缓冲区大小。修改数据协议后,应重新确认最大包长。

HTTP 处理不能长期占住主循环

现有 while (client.connected()) 没有超时。如果浏览器建立连接后迟迟没有发完请求,主循环可能一直留在这里,无法及时继续执行外层的 UDP 接收。

使用轮询式网络库时,需要频繁调用它的处理函数,并限制单次处理时间;使用回调式实现时,也应避免在回调中长时间等待。网络发送、串口输出和客户端积压都可能影响延迟。

UDP 与 HTTP 同用数字 80 并非直接冲突

HTTP 在这里使用 TCP,传感器数据使用 UDP。TCP 和 UDP 的端口空间独立,二者使用相同端口数字本身不会导致图表无法更新。为便于调试,可以使用不同数字,但修改 UDP 端口时要同步修改主机和从机。

能否先用 fetch() 验证数据链路?

可以。主机增加 /data 路由,返回当前快照,并设置响应类型为 application/json;浏览器定期获取 JSON 后调用同一个绘图函数即可,无需重新加载整个网页。

例如,在前面的页面中保留绘图相关代码,用下面的逻辑替代 connect() 及其调用:

async function poll() {
  try {
    const response = await fetch("/data", { cache: "no-store" });
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const message = await response.json();
    if (
      message === null ||
      message.type !== "samples" ||
      !Array.isArray(message.values) ||
      message.values.length !== CHANNEL_COUNT ||
      !message.values.every(Number.isFinite)
    ) {
      throw new Error("数据格式错误");
    }

    values = message.values.slice();
    status.textContent = "已收到最新数据";
    scheduleDraw();
  } catch (error) {
    status.textContent = `获取数据失败:${error.message}`;
  } finally {
    // 示例间隔;实际频率按需求与主机负载调整。
    setTimeout(poll, 200);
  }
}

poll();

这里等上一次请求完成后再安排下一次请求,避免请求持续重叠。可以先用轮询确认“UDP 接收 → 主机快照 → 浏览器数据 → Canvas”是否连通;需要由主机及时推送更新时,再使用 WebSocket。

备注:内容仅供参考。