如何在 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。
备注:内容仅供参考。
