数据上报与可视化:图表与仪表盘
- 搞清楚四条传感器数据可视化路线各自适合什么场景,能照需求选型
- 用 esp_http_server(httpd)开一个 /data JSON 接口,cJSON 组装传感器读数,前端 Chart.js 定时 fetch 拉取刷新画曲线
- 理解时序数据库为什么适合传感器数据,知道 MQTT→Grafana 这条进阶链怎么搭
接了个 DHT11 或 DHT22,代码里一行 ESP_LOGI(TAG, "%.1f", temp) 一跑,串口监视器里数字哗哗往下滚。温度 24.3、24.4、24.3、24.5……你盯了三分钟,除了"大概二十四度"啥也看不出来。想知道今天屋里是越来越热还是越来越冷?想知道昨晚最低到了几度?串口给不了你答案——它只会把数字冲走,一条不留。
传感器的价值从来不在"此刻是多少",而在"它怎么变"。要看变化,你得做三件事:把数据存下来、画成曲线、摆进仪表盘。这一节就讲怎么把刷屏的数字变成一张能看趋势的图。前提是你已经会让 ESP32-S3 用 esp_http_server 开网页(让 ESP32-S3 当网页服务器那节)、最好也跑通过 MQTT——进阶那条线会用到它。读完你手里会有一个能跑的网页,打开浏览器就是一条实时更新的温湿度曲线。
四条路线,先选对再动手
可视化不是只有一种做法。先把四条主流路线摆开,对照你的场景选,别一上来就钻进最复杂的那条。
路线一:设备自带网页 + 图表(入门首选)
ESP32-S3 自己当 Web 服务器,根路径 / 返回一个内嵌 Chart.js 的网页,再提供一个 /data 接口吐 JSON;网页定时来拉数据、画曲线。
- 好在哪:零外部依赖,不用云、不用数据库、不用装任何后端。板子开机就能用,局域网里任何设备打开浏览器输 IP 就看图。
- 限在哪:数据存在板子内存里,断电就没;存不了多久的历史(内存就那么点);只适合单设备、局域网看实时。
- 适合:一个传感器、家里看、入门练手。本节主讲这条,httpd 服务器和路由注册的细节见让 ESP32-S3 当网页服务器那节,本篇直接复用它的
start_webserver()骨架。
路线二:时序数据库 + Grafana(进阶推荐)
设备把数据上报到 InfluxDB 这类时序数据库,再用 Grafana 画各种专业图表和告警。
- 好在哪:专业、强大,历史数据想存多久存多久;多设备、多指标都能汇到一处;Grafana 的图表、告警、看板能力是工业级的。
- 限在哪:要部署数据库和 Grafana(一台小服务器或树莓派即可),有一定运维成本。
- 适合:要长期记录、多个传感器、想做告警的正式项目。这是我最推荐的进阶方向,后面给数据流思路。
路线三:现成 IoT 平台仪表盘(最省事)
巴法云、ThingsBoard 这类平台自带可视化面板,设备按它的协议上报,面板自动出图。
- 好在哪:最省心,不用自己写一行前端,注册账号、配好上报就有图。
- 限在哪:图表样式受平台限制,数据在别人服务器上。
- 适合:不想碰前端、只要快速有个能看的面板。设备侧把读数上报到平台,可走
esp_http_client(见 HTTP 请求那节)按平台的 REST 接口 POST,或用 MQTT publish。接巴法云的做法见 l3-cloud-bemfa。
路线四:Node-RED(拖拽流式)
Node-RED 是个拖拽式的流程编排工具,用节点连线就能搭"接收 MQTT → 处理 → 显示在仪表盘"的流程,自带 dashboard 插件出图。
- 好在哪:几乎不写代码,拖拽连线就成,搭原型极快;改流程直观。
- 限在哪:要单独跑一个 Node-RED 服务;复杂逻辑反而不如写代码清爽。
- 适合:想快速验证想法、不爱写前端、喜欢可视化编程的人。
选型一句话:自己一个人在家看实时,走路线一;要长期记录和告警,走路线二;懒得碰代码,走三或四。 本节以路线一给完整代码带你跑通,再把路线二的数据流讲清楚,这两条覆盖了从入门到进阶的主线。
动手:ESP32-S3 + Chart.js 画实时温湿度曲线(路线一)
目标很具体:ESP32-S3 读 DHT 传感器,自己开个 httpd 服务器;浏览器打开它的 IP,看到一张每两秒自动刷新一格的温湿度双曲线图。
思路是前后端分离的微缩版,和网页服务器那节的路由模型一模一样,只是这次有两条路由各司其职:
/这条路由的 handler 返回 HTML 页面(内嵌 Chart.js 和拉取逻辑);/data这条路由的 handler 用 cJSON 把最新读数组成一段 JSON,httpd_resp_set_type(req, "application/json")标好类型再httpd_resp_send发出去。- 网页加载后,用 JavaScript 每两秒去
fetch('/data')拉一次,把新数据点推进 Chart.js 的曲线里。
这里和 Arduino 那套最大的区别有两处,先点明你才不会拿旧习惯套:其一,没有 loop 里的 server.handleClient()——httpd_start 起的 server 任务在后台自己接客,传感器读数由一个独立的 FreeRTOS 任务定时刷新(详见 网页服务器那节对 httpd 后台任务的讲解)。其二,JSON 不靠字符串拼接——用 cJSON 这个 ESP-IDF 自带的库组对象,比 snprintf 手拼 {\"t\":...} 安全得多,字段一多更不会写错引号。
下面是完整可烧录的程序。它复用网页服务器那节的 wifi_init_sta()(连 WiFi、拿 IP)和 start_webserver()(开 httpd)骨架,DHT 接在 GPIO4(换成你的接法),放进工程的 main/main.c:
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_http_server.h"
#include "nvs_flash.h"
#include "cJSON.h"
#include "dht.h" // 用你工程里的 DHT 驱动组件,readTemperature/Humidity 见该组件文档
#define DHT_GPIO GPIO_NUM_4 // DHT 数据脚,换成你的接法
static const char *TAG = "dash";
static float s_temp = 0, s_humi = 0; // 缓存最新一次读数(被读任务写、被 handler 读)
extern void wifi_init_sta(void); // 复用网页服务器那节的 WiFi 骨架
/* ---------- 网页本体:内嵌 Chart.js,定时 fetch('/data') 画曲线 ---------- */
static const char PAGE[] =
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>ESP32-S3 温湿度</title>"
"<script src='https://cdn.jsdelivr.net/npm/chart.js'></script>"
"<style>body{font-family:sans-serif;max-width:720px;margin:24px auto;padding:0 12px}</style>"
"</head><body><h2>实时温湿度</h2><canvas id='chart'></canvas><script>"
"const MAX=60,labels=[],tD=[],hD=[];" // 最多显示 60 个点(约 2 分钟)
"const chart=new Chart(document.getElementById('chart'),{type:'line',data:{labels,datasets:["
"{label:'温度 °C',data:tD,borderColor:'#e74c3c',tension:.3},"
"{label:'湿度 %',data:hD,borderColor:'#3498db',tension:.3}"
"]},options:{animation:false,scales:{y:{beginAtZero:false}}}});"
"async function poll(){try{"
"const r=await fetch('/data');const d=await r.json();" // { "t":24.3, "h":55 }
"const now=new Date().toLocaleTimeString();"
"labels.push(now);tD.push(d.t);hD.push(d.h);"
"if(labels.length>MAX){labels.shift();tD.shift();hD.shift();}"
"chart.update();}catch(e){console.log('拉取失败',e);}}"
"setInterval(poll,2000);poll();" // 每 2 秒拉一次
"</script></body></html>";
/* ---------- 路由一:/ 返回带图表的网页 ---------- */
static esp_err_t root_handler(httpd_req_t *req) {
httpd_resp_send(req, PAGE, HTTPD_RESP_USE_STRLEN); // 长度交给它自己用 strlen 算
return ESP_OK;
}
/* ---------- 路由二:/data 用 cJSON 组装最新读数的 JSON ---------- */
static esp_err_t data_handler(httpd_req_t *req) {
cJSON *root = cJSON_CreateObject(); // {}
cJSON_AddNumberToObject(root, "t", s_temp); // {"t":24.3}
cJSON_AddNumberToObject(root, "h", s_humi); // {"t":24.3,"h":55}
char *json = cJSON_PrintUnformatted(root); // 序列化成紧凑字符串(malloc 出来的)
httpd_resp_set_type(req, "application/json"); // 关键:声明返回类型,前端 r.json() 才认
httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
cJSON_free(json); // PrintUnformatted 的返回值要手动释放
cJSON_Delete(root); // 整棵对象树释放,漏了就内存泄漏
return ESP_OK;
}
static httpd_handle_t start_webserver(void) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); // 端口默认 80
httpd_handle_t server = NULL;
if (httpd_start(&server, &config) == ESP_OK) { // 起后台 server 任务
httpd_uri_t root = { .uri = "/", .method = HTTP_GET, .handler = root_handler };
httpd_uri_t data = { .uri = "/data", .method = HTTP_GET, .handler = data_handler };
httpd_register_uri_handler(server, &root);
httpd_register_uri_handler(server, &data);
ESP_LOGI(TAG, "HTTP 服务器已启动");
}
return server;
}
/* ---------- 传感器读取任务:每 2 秒刷新一次缓存 ---------- */
static void sensor_task(void *arg) {
while (1) {
float t = 0, h = 0;
// 读 DHT:用你工程里的 DHT 组件接口,读失败返回非 ESP_OK 就跳过、保留上一次值
if (dht_read_float_data(DHT_TYPE_DHT11, DHT_GPIO, &h, &t) == ESP_OK) {
s_temp = t;
s_humi = h;
} else {
ESP_LOGW(TAG, "DHT 读取失败,沿用上次读数"); // 读失败别把脏值写进缓存
}
vTaskDelay(pdMS_TO_TICKS(2000)); // 每 2 秒读一次
}
}
void app_main(void) {
ESP_ERROR_CHECK(nvs_flash_init()); // WiFi 射频校准数据存 NVS,连网前必做
wifi_init_sta(); // 连上 WiFi、拿到 IP 才往下走(复用网页服务器那节)
start_webserver(); // 开 httpd,之后后台任务自己接客
xTaskCreate(sensor_task, "sensor", 4096, NULL, 5, NULL); // 起读传感器的独立任务
// app_main 到这就可以返回了;server 和 sensor_task 各在自己的任务里跑,不需要 while(1) 轮询
}
代码里的 dht_read_float_data(...) 是示意 DHT 读取接口——ESP-IDF 没有官方 DHT 组件,社区常用的是 esp-idf-lib 里的 dht 组件(在 idf_component.yml 里加依赖 esp-idf-lib/dht)。不同组件的函数签名不一样,照你装的那个组件文档对一下参数顺序。重点不在这个函数怎么叫,而在读传感器放进独立任务、用全局变量缓存最新值,handler 只管把缓存拿去组 JSON——这套"读和发分离"的结构才是产品级写法。
DHT 组件依赖装好后,在工程目录下一条命令编译、烧录、看日志(第一次先 idf.py set-target esp32s3):
idf.py build flash monitor
你应该看到什么
烧录后 idf.py monitor(按 Ctrl + ] 退出),按顺序对一下,一半看串口、一半看浏览器:
日志里先滚出连 WiFi 的过程,然后是关键的一行(来自网页服务器那节复用的 WiFi 骨架):
I (3210) web: 拿到 IP: 192.168.1.50,浏览器访问这个地址 I (3215) dash: HTTP 服务器已启动这串
192.168.1.50就是你要访问的地址。用同一个 WiFi 下(注意是 2.4G 那个)的手机或电脑浏览器输入这个地址,回车——页面上出现一张折线图标题"实时温湿度"。
等几秒,图上开始一格一格往右长出两条线:红的是温度、蓝的是湿度。每 2 秒长一格——这正是前端
setInterval(poll,2000)在拉/data。想单独验证接口,浏览器直接访问
http://192.168.1.50/data,应该看到一行{"t":24.3,"h":55}之类的 JSON。
对着 DHT 哈口气,几秒后蓝线(湿度)会明显往上拱一下,松手又慢慢落回来。那条会动的曲线,就是你这一节真正要的东西——它把"此刻多少"变成了"怎么变"。
进阶:MQTT → InfluxDB + Grafana(路线二的数据流)
路线一够你看实时,但数据断电就没、存不久、单设备。要长期记录、多设备汇总、做告警,就上路线二。这里不给逐行代码(部署超出本节范围),但把数据怎么流讲清楚,你照着这条链就能搭起来。
整条链是这样接力的:
ESP32-S3 ──MQTT publish──► MQTT Broker ──► Telegraf ──写入──► InfluxDB ──查询──► Grafana 画图
- ESP32-S3 端:你已经会的事。每隔几秒把读数 publish 到一个 topic,比如往
home/sensor/temp发24.3、往home/sensor/humi发55。代码就是 MQTT 那节的程序,把发的内容从状态字符串换成传感器读数即可——同样可以用 cJSON 把多个字段组成一段 JSON 一次发出去。设备这端完全不变,这正是 MQTT 解耦的好处。 - Telegraf(数据采集中转):一个轻量采集器,订阅那些 topic,把收到的数据按时序格式写进 InfluxDB。配置文件里写明"订阅哪些 topic、写到哪个数据库",跑起来就自动搬数据,不用你写代码。
- InfluxDB(时序数据库):专门存"带时间戳的数字"的数据库,每条数据自动记下"什么时间、哪个指标、值多少"。它替你做了路线一做不到的事——把每一个读数都永久存下来。
- Grafana(看板):连上 InfluxDB,拖几下就能配出温度曲线、湿度曲线、今日最高/最低、超过阈值变红告警……还能在一个面板里同时看几十个传感器。
这条链的好处,是每一环都能单独换、单独扩。今天加第二块板子,往新 topic 发就行,Grafana 加个图表;明天想存到别的库,只改 Telegraf 那一段。设备代码一行不动。这种"加设备不牵连前端"的弹性,正是它适合长期项目的原因。
嫌四个组件多?InfluxDB 自带的 Telegraf 也能直接订阅 MQTT,省掉单独配置的麻烦;或者更轻量,直接用 Node-RED 把"收 MQTT → 写库 → 出图"拖一条流出来。组件怎么搭没有标准答案,按你手里有的机器和顺手程度选。一台树莓派就能把这一整套跑起来。
数据该存哪:为什么是时序数据库
到这你可能会问:存数据用普通的 MySQL 不行吗?能用,但传感器数据有它的脾气,时序数据库(InfluxDB、TimescaleDB 这类)是为它量身做的,差别在三点:
- 天生按时间组织。传感器数据的本质就是"某时刻某个值",时序库把时间戳当成一等公民,"查最近一小时""按分钟取平均""算今天最高"这类操作又快又顺手。用普通关系库,这些查询得自己费劲拼 SQL。
- 写入扛得住高频。一个传感器每秒一条、十个传感器就是每秒十条,一天下来近百万条。时序库专门优化了这种"海量小数据高频写入",普通库写多了会越来越慢。
- 会自动"老化"数据。它能配保留策略:原始数据只留 7 天,超过就自动压成"每小时一个平均值"长期存。这样既看得到细节、又不让磁盘无限涨。传感器数据"近期要精细、久远只看趋势"的特点,跟这个能力天生契合。
一句话:偶尔记几条用什么都行;要持续记成千上万条传感器读数,时序数据库省心得多。
故障排查:图不更新、数据断、时间乱
可视化这块的坑,大多集中在下面几类,对照查:
| 现象 | 最可能的原因 | 怎么办 |
|---|---|---|
| 网页能开,但图是空的、不长线 | /data 接口没返回、或前端 fetch 报错 |
浏览器直接访问 http://板子IP/data 看有没有吐 JSON;按 F12 看 Console 有没有红字 |
图是空的,且 /data 能看到 JSON |
没设 application/json 类型,前端 r.json() 解析失败 |
确认 data_handler 里调了 httpd_resp_set_type(req, "application/json") |
| 数据点全是 0 或不变 | DHT 读出来失败没挡住、或接线/型号不对 | 看日志有没有刷 DHT 读取失败;DHT11 别配成 DHT22;核对 DHT_GPIO 和接线 |
| 接口偶尔超时或板子重启 | handler 里漏了 cJSON_free/cJSON_Delete 内存泄漏,攒一会儿就 OOM |
检查每次组完 JSON 都成对释放:cJSON_free(json) + cJSON_Delete(root) |
| 横轴时间乱跳/对不上 | 用浏览器本地时间当横轴,没用设备真实时间戳 | 简单图用浏览器时间够了;要准就让设备走 NTP 校时后把时间戳一起组进 JSON 发上来 |
| 接口跨域报 CORS 错误 | 网页和数据接口不在同一来源(比如网页托管在别处、数据来自板子) | 同一块板子既发页面又发数据时不会有这问题;真要跨域,在 data_handler 里 httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*") |
这里要点出和 Arduino 旧写法的一个根本差异:Arduino 的 WebServer 要在 loop 里反复 server.handleClient(),所以那边有个高频坑是"loop 里写了长 delay 导致网页卡死"。esp_http_server 是后台任务驱动的,这个坑天然不存在——你的 sensor_task 里 vTaskDelay 睡多久都不影响 server 任务接客。但换来一个新注意点:data_handler 是在 server 任务里跑的,组 JSON 要快进快出,别在 handler 里干读传感器这种可能阻塞的重活——所以上面把读 DHT 单独放进了 sensor_task。
变体:多传感器、看历史
跑通单条曲线后,往两个方向扩,体会这套结构的弹性。
- 多传感器多曲线。Chart.js 的
datasets是个数组,再往里加一组{ label, data, borderColor }就多一条线。后端data_handler多cJSON_AddNumberToObject(root, "t2", s_temp2)加一个字段(JSON 变成{"t":24.3,"h":55,"t2":26.1}),前端对应多 push 一路。这正是用 cJSON 而不是手拼字符串的好处——加字段就一行,不用去数引号和逗号。一张图里叠温度、湿度、第二个房间的温度,互不干扰。 - 历史回看。路线一受内存限制存不了多久;真要看"昨天一整天",就得让数据落地——要么板子把读数定时写进 SPIFFS/LittleFS 文件系统(断电也在),要么直接上路线二,让 Grafana 给你拉一个可以左右拖、放大缩小的历史时间轴。看历史这件事,本质上是路线二存在的理由。
路线一的网页把历史点存在浏览器内存里,关掉标签页就清零;ESP32-S3 那端只用 s_temp/s_humi 缓存"最新一次"读数,断电更是啥也不剩。别误以为这个网页在帮你存数据——它只负责画实时。一旦"历史能不能丢"成了问题,就别在路线一上较劲,老老实实上时序数据库。
动手挑战
把"看实时"升级成"看趋势",做一个能回看最近一小时温度的网页:
- 撑到一小时。改路线一的代码,把读数在
sensor_task里定时存进 SPIFFS/LittleFS 文件系统(每分钟存一条,一小时 60 条,内存压力很小)。再加一条/history路由,handler 用 cJSON 把这 60 条组成一个数组返回;网页加载时先拉一次/history、画出已有曲线,再开始实时追加。这样刷新页面、甚至重连,那一小时的曲线还在。 - 加最高最低。在图上方显示这一小时的最高温、最低温、平均温——遍历数据数组算一下即可。这是仪表盘最常见的"概览数字"。
- 配个阈值线。给 Chart.js 加一条固定的水平参考线(比如 28°C),温度曲线一旦顶上去就一眼看见。这是告警的雏形。
做不动了?把你的 main.c 和"想看最近一小时曲线"的目标一起发给 AI,让它帮你补 SPIFFS 读写、/history 路由的 cJSON 数组组装和网页加载历史那段——把接口(路径 /history、返回的是数组)写清楚,这类有固定套路的代码 AI 写得又快又稳。
小结 · 下一步
- 你拎清了四条可视化路线:设备自带网页(入门)、时序库 + Grafana(进阶)、IoT 平台面板(省事)、Node-RED(拖拽),知道按场景怎么选。
- 你做出了一个完整能跑的 ESP32-S3 网页:复用
start_webserver()骨架开 httpd,/返回内嵌 Chart.js 的页面、/data用 cJSON 组 JSON,前端定时fetch拉取刷新看实时温湿度双曲线。 - 你理解了为什么把读传感器放进独立任务、handler 只管组 JSON,也明白了 cJSON 比手拼字符串安全在哪、
cJSON_free+cJSON_Delete为什么必须成对。 - 你理解了进阶链 MQTT → InfluxDB + Grafana 的数据怎么流,也明白了时序数据库为什么比普通库更适合传感器数据。
- 你会查图不更新、数据断、时间乱、跨域、内存泄漏这几类常见故障,也知道往多曲线、看历史方向怎么扩。
本篇代码为参考实现,需结合你所用的最新 ESP-IDF 文档自校——尤其
esp_http_server(httpd_resp_send/httpd_resp_set_type的签名)、cJSON(cJSON_PrintUnformatted的释放方式用cJSON_free)和你装的 DHT 组件的读取接口随版本/组件可能不同,以官方文档为准。
数据能存、能看了,下一个绕不开的问题是安全——你的设备开了网页、连了云、传着数据,怎么不让别人随便控制它、不让数据裸奔?接着看设备安全那节。完整的进阶地图见学习路线,更多同阶内容在 L3 阶梯里。
本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。