← 返回教程库

数据上报与可视化:图表与仪表盘

最后更新 2026-06-22
L3 · 联网与 IoT ⏱ 约 18 分钟 🟢 软件/低风险
你将学到
  • 搞清楚四条传感器数据可视化路线各自适合什么场景,能照需求选型
  • 用 esp_http_server(httpd)开一个 /data JSON 接口,cJSON 组装传感器读数,前端 Chart.js 定时 fetch 拉取刷新画曲线
  • 理解时序数据库为什么适合传感器数据,知道 MQTT→Grafana 这条进阶链怎么搭

接了个 DHT11DHT22,代码里一行 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 + ] 退出),按顺序对一下,一半看串口、一半看浏览器

  1. 日志里先滚出连 WiFi 的过程,然后是关键的一行(来自网页服务器那节复用的 WiFi 骨架):

    I (3210) web: 拿到 IP: 192.168.1.50,浏览器访问这个地址
    I (3215) dash: HTTP 服务器已启动
    

    这串 192.168.1.50 就是你要访问的地址。

  2. 同一个 WiFi 下(注意是 2.4G 那个)的手机或电脑浏览器输入这个地址,回车——页面上出现一张折线图标题"实时温湿度"。

  3. 等几秒,图上开始一格一格往右长出两条线:红的是温度、蓝的是湿度。每 2 秒长一格——这正是前端 setInterval(poll,2000) 在拉 /data

  4. 想单独验证接口,浏览器直接访问 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/temp24.3、往 home/sensor/humi55。代码就是 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_handlerhttpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*")
📌 说明

这里要点出和 Arduino 旧写法的一个根本差异:Arduino 的 WebServer 要在 loop 里反复 server.handleClient(),所以那边有个高频坑是"loop 里写了长 delay 导致网页卡死"。esp_http_server 是后台任务驱动的,这个坑天然不存在——你的 sensor_taskvTaskDelay 睡多久都不影响 server 任务接客。但换来一个新注意点:data_handler 是在 server 任务里跑的,组 JSON 要快进快出,别在 handler 里干读传感器这种可能阻塞的重活——所以上面把读 DHT 单独放进了 sensor_task


变体:多传感器、看历史

跑通单条曲线后,往两个方向扩,体会这套结构的弹性。

  • 多传感器多曲线。Chart.js 的 datasets 是个数组,再往里加一组 { label, data, borderColor } 就多一条线。后端 data_handlercJSON_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 缓存"最新一次"读数,断电更是啥也不剩。别误以为这个网页在帮你存数据——它只负责画实时。一旦"历史能不能丢"成了问题,就别在路线一上较劲,老老实实上时序数据库。


动手挑战

把"看实时"升级成"看趋势",做一个能回看最近一小时温度的网页:

  1. 撑到一小时。改路线一的代码,把读数在 sensor_task 里定时存进 SPIFFS/LittleFS 文件系统(每分钟存一条,一小时 60 条,内存压力很小)。再加一条 /history 路由,handler 用 cJSON 把这 60 条组成一个数组返回;网页加载时先拉一次 /history、画出已有曲线,再开始实时追加。这样刷新页面、甚至重连,那一小时的曲线还在。
  2. 加最高最低。在图上方显示这一小时的最高温、最低温、平均温——遍历数据数组算一下即可。这是仪表盘最常见的"概览数字"。
  3. 配个阈值线。给 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_serverhttpd_resp_send/httpd_resp_set_type 的签名)、cJSON(cJSON_PrintUnformatted 的释放方式用 cJSON_free)和你装的 DHT 组件的读取接口随版本/组件可能不同,以官方文档为准。

数据能存、能看了,下一个绕不开的问题是安全——你的设备开了网页、连了云、传着数据,怎么不让别人随便控制它、不让数据裸奔?接着看设备安全那节。完整的进阶地图见学习路线,更多同阶内容在 L3 阶梯里。

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明