news 2026/11/4 10:59:16

ESP32桌面仪表盘Status Deck:BLE+JSON固件开发实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ESP32桌面仪表盘Status Deck:BLE+JSON固件开发实战

1. 为什么我要折腾一个桌面仪表盘

手头这块 ESP32 开发板在抽屉里躺了大半年,一直想给它找个正经活儿干。平时写代码的时候,我总得切窗口看构建状态、看时间、看服务器负载,切来切去烦得很。市面上那些智能屏要么太贵,要么生态封闭,想接自己的数据得绕一大圈。后来一琢磨,ESP32 自带 Wi-Fi 和蓝牙,价格便宜量又足,干脆自己做一个桌面小仪表盘,专门给开发者用。

这个项目我给它起名叫Status Deck,定位很明确:一块放在显示器旁边的常亮小屏,通过 BLE 和桌面端 Agent 通信,实时显示构建状态、系统指标、待办事项这些开发者日常关心的信息。核心链路是ESP32 固件 + BLE 低功耗蓝牙 + 桌面端 Agent + JSON 数据协议。选 BLE 而不是 Wi-Fi 直连,是因为 BLE 配对后几乎不占网络资源,桌面端不用开端口,安全性也好控制。JSON 作为数据格式则是图个省事,桌面端什么语言都能拼,ESP32 这边用 ArduinoJson 解析也成熟。

这篇文章是系列第一篇,重点讲整体设计思路和固件侧的核心实现。适合有 Arduino 基础、想玩点实用硬件的开发者,也适合做桌面工具想加个物理显示终端的朋友。读完你能拿到一套可复现的架构方案,知道每个环节为什么这么选,以及我踩过的那些坑。

2. 整体架构与方案选型拆解

2.1 三层结构:固件、协议、Agent

Status Deck 的整体结构分三层。最底层是ESP32 固件,负责屏幕渲染、BLE 服务端、数据解析。中间层是BLE 通信协议,定义数据怎么从桌面传到板子上。最上层是桌面端 Agent,跑在电脑上,采集构建状态、CPU 内存、时间等信息,打包成 JSON 通过 BLE 发出去。

这么分层的好处是每层职责单一。固件不用关心数据从哪来,Agent 不用关心屏幕怎么画,协议层只要约定好 JSON 字段就行。后面想换屏幕、换通信方式、换数据源,都只动一层。我见过不少人把逻辑全塞进固件里,结果改个显示布局都要重新烧录,调试成本极高。

三层之间是单向数据流:Agent 主动推送,固件被动接收并渲染。为什么不做双向?因为仪表盘的核心需求是"显示",固件回传数据的需求很弱。单向流让固件逻辑简单很多,也避免了 BLE 连接上的状态同步麻烦。如果后面真要加按键交互,再在协议里加一个上行通道即可,不影响现有结构。

2.2 为什么是 BLE 而不是 Wi-Fi 或串口

通信方式的选择是这个项目第一个要拍板的事。我对比过三种方案:

方案优点缺点适用场景
Wi-Fi + HTTP/WebSocket带宽大、协议成熟占网络、要配网、开端口有安全顾虑数据量大、需要远程访问
串口极稳定、零延迟要插线、位置受限固定工位、调试阶段
BLE无线、低功耗、配对即用带宽小、MTU 有限小数据量、近距离常显

Status Deck 传输的是状态文本和数字,单次数据量撑死几百字节,BLE 完全够用。而且 BLE 配对后桌面端不需要开任何网络端口,不用担心局域网里被别人扫到。功耗方面,BLE 广播和连接态的电流远低于 Wi-Fi 持续连接,板子长期插 USB 供电虽然不敏感,但发热确实小一些。

串口其实是最省事的,但线缆是个硬伤。我的显示器支架上已经缠了一堆线,再加一根实在受不了。BLE 让我可以把板子随便摆在桌面上,只要在电脑蓝牙范围内就行。实测下来,普通笔记本蓝牙和 ESP32 隔两三米完全没问题,中间隔个显示器也不影响。

2.3 JSON 协议设计:够用就好

数据格式我选了 JSON,理由很直接:桌面端不管用 Python、Node 还是 Go,拼 JSON 都是一行的事;ESP32 这边 ArduinoJson 库成熟稳定,解析几百字节的文档毫无压力。协议字段我设计得很克制,就一个顶层对象,包含几个固定字段:

{ "type": "status", "ts": 1735689600, "build": "passing", "cpu": 23, "mem": 61, "msg": "build #142 ok" }

type用来区分消息种类,后面扩展可以加todo、weather等。ts是时间戳,固件可以用来判断数据新鲜度。build、cpu、mem是具体指标。msg是自由文本,用来显示一行简短说明。

这里有个关键决策:不做字段级的增量更新,每次全量发。因为数据量本来就小,全量发省去了固件端维护状态的复杂度。如果哪天数据字段多到几百字节以上,再考虑增量。协议里我特意留了type字段,就是为了将来能按类型走不同的渲染分支,现在只实现status一种。

注意:JSON 字段名尽量短,因为 BLE 的 MTU 有限。默认 ATT MTU 是 23 字节,实际可用载荷约 20 字节。虽然可以通过协商提高到 200 多字节,但为了兼容性,单包数据最好控制在 180 字节以内,超了就要分片。

3. 固件侧核心实现细节

3.1 开发环境与依赖选型

固件我用 Arduino 框架写,不是 ESP-IDF。原因很简单:Arduino 生态里屏幕驱动、JSON 解析、BLE 库都是一行#include的事,开发速度快。ESP-IDF 更底层更灵活,但这个项目不需要那么细的控制。开发管理器我用的 Arduino IDE 2.x,板子管理地址走的是国内镜像源,下载 ESP32 支持包快很多,不然官方源那个速度能等到天亮。

核心依赖库有四个:

  • NimBLE-Arduino:比 ESP32 自带的 Bluedroid 库省内存,API 也更清爽,做 BLE 服务端首选。
  • ArduinoJson:解析 JSON 用,版本选 6.x,7.x 的 API 有变动,老项目升级要注意。
  • TFT_eSPI:屏幕驱动,支持多种驱动芯片,配置集中在User_Setup.h里。
  • ESP32Time:可选,用来做本地时间戳,如果不需要精确时间可以不加。

安装方式在 Arduino IDE 的库管理器里搜名字直接装就行。NimBLE 装完后要在esp32的板子配置里把默认的 Bluedroid 关掉,不然两个 BLE 栈会冲突。这个坑我踩过,编译能过但运行起来 BLE 初始化直接失败,查了半天才发现是栈冲突。

3.2 BLE 服务与特征值设计

BLE 通信的核心是 GATT 服务。我定义了一个自定义服务,里面放两个特征值:一个用于接收数据(Write),一个用于发送状态(Notify)。UUID 我用的自定义 128 位格式,避免和标准服务冲突:

#define SERVICE_UUID "6e400001-b5a3-f393-e0a9-e50e24dcca9e" #define CHAR_RX_UUID "6e400002-b5a3-f393-e0a9-e50e24dcca9e" #define CHAR_TX_UUID "6e400003-b5a3-f393-e0a9-e50e24dcca9e"

这套 UUID 其实是 Nordic UART Service 的经典格式,很多 BLE 调试助手都认识,方便用手机 App 先做连通性测试。RX 特征值设成 Write 属性,桌面端往这里写 JSON。TX 特征值设成 Notify 属性,固件有状态变化时主动推给桌面端,比如"数据已接收""解析失败"这类回执。

为什么用 Write 而不是 Write Without Response?因为 Write 有确认机制,桌面端能知道数据到底有没有送达。BLE 的 Write Without Response 虽然快,但丢包了双方都不知道。状态数据对实时性要求没那么高,可靠性优先。实测下来,一次 Write 大概几毫秒到十几毫秒,完全够用。

3.3 JSON 解析与内存管理

ESP32 的内存分内部 SRAM 和外部 PSRAM,JSON 解析要特别注意堆的使用。ArduinoJson 6.x 默认用动态内存,解析一个几百字节的文档大概占几百字节堆空间。如果频繁解析,容易产生内存碎片。我的做法是用静态 JsonDocument,在编译期就确定容量:

StaticJsonDocument<512> doc; void onWrite(BLECharacteristic *pChar) { std::string value = pChar->getValue(); if (value.length() == 0) return; DeserializationError err = deserializeJson(doc, value); if (err) { Serial.printf("JSON parse failed: %s\n", err.c_str()); return; } const char* type = doc["type"] | "unknown"; if (strcmp(type, "status") == 0) { renderStatus(doc); } }

StaticJsonDocument<512>表示这块内存是静态分配的,不参与堆的反复申请释放,碎片问题直接绕开。512 字节的容量对当前协议绰绰有余,如果后面字段变多,再往上调。注意doc["type"] | "unknown"这个写法,是 ArduinoJson 的默认值语法,字段不存在时返回后面的默认值,比先containsKey再取值简洁。

提示:deserializeJson的输入如果是std::string,ArduinoJson 会直接读它的缓冲区,不会额外拷贝。但如果你传的是String对象,可能会有一次拷贝。NimBLE 的getValue()返回std::string,正好省事。

3.4 屏幕渲染的分层刷新策略

屏幕刷新是另一个容易踩坑的地方。如果每次收到数据就全屏重绘,屏幕会闪,而且 SPI 传输占用 CPU 时间。我的策略是分层刷新:把屏幕内容分成静态区和动态区。静态区是标题、标签这些不变的内容,只在启动时画一次。动态区是数值和状态文本,每次数据更新只重绘这部分。

TFT_eSPI 提供了setTextPadding和fillRect配合的方式,可以做到局部刷新不闪屏。具体做法是:动态区每个字段占固定宽度的矩形,更新时先用背景色fillRect清掉旧内容,再画新文本。这样只有变化的那一小块区域在动,视觉上很干净。

void renderStatus(JsonDocument& doc) { int cpu = doc["cpu"] | 0; int mem = doc["mem"] | 0; const char* build = doc["build"] | "unknown"; // CPU 区域局部刷新 tft.fillRect(CPU_X, CPU_Y, CPU_W, CPU_H, BG_COLOR); tft.setTextColor(TEXT_COLOR, BG_COLOR); tft.setCursor(CPU_X, CPU_Y); tft.printf("CPU %d%%", cpu); // 构建状态用颜色区分 uint16_t color = strcmp(build, "passing") == 0 ? GREEN : RED; tft.fillRect(BUILD_X, BUILD_Y, BUILD_W, BUILD_H, BG_COLOR); tft.setTextColor(color, BG_COLOR); tft.setCursor(BUILD_X, BUILD_Y); tft.print(build); }

颜色区分是个小细节但很实用。构建通过显示绿色,失败显示红色,扫一眼就知道状态,不用读文字。这个设计灵感来自 CI 流水线的状态灯,物理仪表盘做这个再合适不过。

4. 桌面端 Agent 与联调实操

4.1 Agent 的最小实现思路

桌面端 Agent 我第一版用 Python 写,因为bleak这个库跨平台支持好,Windows、macOS、Linux 都能跑。Agent 的核心逻辑就三步:扫描并连接 ESP32、按固定间隔采集数据、打包 JSON 写入 RX 特征值。

import asyncio import json import time from bleak import BleakClient, BleakScanner RX_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" DEVICE_NAME = "StatusDeck" async def find_device(): devices = await BleakScanner.discover(timeout=5.0) for d in devices: if d.name == DEVICE_NAME: return d return None async def push_status(client): while True: payload = { "type": "status", "ts": int(time.time()), "build": "passing", "cpu": 23, "mem": 61, "msg": "build #142 ok" } data = json.dumps(payload).encode("utf-8") await client.write_gatt_char(RX_UUID, data, response=True) await asyncio.sleep(2) async def main(): dev = await find_device() if not dev: print("device not found") return async with BleakClient(dev) as client: await push_status(client) asyncio.run(main())

response=True对应 BLE 的 Write With Response,确保数据送达。间隔 2 秒是权衡后的结果:太快没必要,状态数据变化没那么频繁;太慢仪表盘就显得迟钝。这个值可以按需调整,我实际用 1 到 3 秒都试过,2 秒体感最舒服。

4.2 数据采集的扩展点

上面代码里 CPU 和内存是写死的,实际用的时候要接真实数据源。Python 里用psutil可以拿到 CPU、内存、磁盘、网络各种指标,跨平台。构建状态则要看你的 CI 系统,GitHub Actions 有 API,Jenkins 也有 REST 接口,本地构建可以监听文件变化或者解析日志。

我的做法是 Agent 里开一个采集线程,把不同数据源的结果汇总到一个共享字典里,主循环只负责读字典、打包、发送。这样数据源和通信逻辑解耦,加新指标不用动发送代码。采集频率可以比发送频率高,比如每秒采一次,发送还是两秒一次,取最新值。

注意:BLE 写入是异步操作,如果采集线程和发送协程共享数据,要用线程安全的容器或者加锁。Python 的queue.Queue是个简单可靠的选择,采集线程往里塞,发送协程往外取。

4.3 联调阶段的排查顺序

BLE 联调最容易卡在"连不上"和"连上了没反应"这两步。我的排查顺序是这样的:

  1. 先确认板子 BLE 在广播。用手机上的 BLE 调试助手扫一下,能看到StatusDeck这个设备名就说明固件 BLE 初始化成功。
  2. 再确认服务能发现。连上后看 GATT 服务列表里有没有那个自定义 UUID,没有的话多半是服务注册代码有问题。
  3. 然后测特征值读写。用调试助手往 RX 写一段 JSON,看串口日志有没有打印解析结果。
  4. 最后才上 Agent。前面都通了,Agent 连不上基本就是 UUID 写错或者设备名匹配问题。

这个顺序的核心是从底层往上层排,每一步只验证一个环节。我见过有人一上来就跑 Agent,连不上就到处改代码,结果把本来没问题的固件也改坏了。分层验证能快速定位问题在哪一层。

5. 常见问题与避坑经验

5.1 BLE 连接不稳定怎么办

最常见的问题是连接几秒后自动断开。原因通常有三个:一是供电不足,ESP32 在 BLE 连接瞬间电流会跳一下,如果 USB 口供电弱或者线材差,电压跌落会导致重启;二是 MTU 协商失败,某些蓝牙适配器对 MTU 协商支持不好;三是固件里没有正确处理断开事件,断了之后没有重新广播。

我的解决办法:换一根短而粗的 USB 线,尽量插在电脑原生 USB 口而不是扩展坞上;固件里在onDisconnect回调里重新调用startAdvertising();MTU 不主动协商,用默认值,牺牲一点带宽换稳定性。实测下来,这三招下去连接能稳定跑几个小时不掉。

5.2 JSON 解析失败的典型原因

解析失败日志里最常见的是InvalidInput和NoMemory。InvalidInput一般是数据不完整,比如 BLE 分片传输时固件只收到了前半段。解决办法是在协议里加长度校验,或者约定单包不超过 MTU。NoMemory就是StaticJsonDocument容量不够,把 512 调大就行,但别调太大,ESP32 的栈空间有限,静态分配太多会影响其他功能。

还有一个隐蔽的坑:JSON 里的中文。如果msg字段带中文,UTF-8 编码下每个汉字占 3 字节,容量估算时要按字节算而不是字符数。我一开始按字符数估容量,结果带中文的消息一多就NoMemory,后来改成按字节估就没事了。

5.3 屏幕刷新闪烁的解决

闪烁的根源是全屏清屏 + 重绘。TFT 屏幕刷新时如果先fillScreen再画所有内容,中间会有一瞬间全黑,看起来就是闪。解决办法就是前面说的局部刷新,只清要更新的区域。另外 SPI 频率也可以调,TFT_eSPI 里默认频率如果偏低,刷新慢也会显得闪,适当调高到 40MHz 或 80MHz(看屏幕驱动芯片支持)能明显改善。

还有一个细节:setTextColor要带背景色参数,即setTextColor(fg, bg),这样画文字时背景会被自动填充,不会出现文字叠字的残影。如果只传前景色,旧文字没清干净就会糊成一团。

5.4 常见问题速查表

现象可能原因排查方向
扫不到设备BLE 未初始化 / 广播未启动看串口日志,确认 NimBLE 初始化成功
连上就断供电不足 / MTU 协商失败换 USB 线,关闭 MTU 协商
写入无反应UUID 不匹配 / 回调未注册核对 UUID,确认 onWrite 已绑定
JSON 解析失败数据分片 / 容量不足加长度校验,调大 JsonDocument
屏幕闪烁全屏重绘 / SPI 频率低改局部刷新,调高 SPI 频率
中文乱码字体不支持 / 编码问题换支持中文的字体,确认 UTF-8

这张表是我调试过程中攒下来的,基本覆盖了 90% 的常见问题。遇到新问题先往这几类上靠,能省不少时间。

6. 后续扩展方向与我的一点体会

Status Deck 第一版跑通后,我陆续加了些小功能。比如根据时间段自动调屏幕亮度,晚上暗一点不刺眼;比如构建失败时让屏幕边框闪红色,比看文字更醒目。这些都是在基础架构上做增量,没有动核心的 BLE 和 JSON 协议,说明当初的分层设计是对的。

协议层面我留了type字段的扩展空间,后面想加待办列表、天气、股票这些,只要定义新的 type 和对应渲染分支就行。桌面端 Agent 也可以做成插件式,每个数据源一个插件,配置文件里启用哪些就采集哪些。这样这个仪表盘就从"构建状态显示器"变成了一个通用的开发者信息终端。

如果你也想做一个,我的建议是先把最小链路跑通:板子能广播、能连上、能收到一段写死的 JSON 并显示出来。这一步通了,后面加什么功能都是锦上添花。别一上来就设计大而全的协议和花哨的界面,容易在细节里迷失,最后连第一个字节都没传通。硬件项目尤其如此,能跑起来比设计得漂亮重要得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 10:58:47

嵌入式内存实战:栈溢出与malloc碎片的硬核应对

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/4 10:57:08

从零构建AI工程能力:后端老兵的踩坑与重构实录

1. 从零搭建AI工程能力&#xff1a;一个后端老兵的踩坑与重构实录 "ai-engineering-from-scratch"这个标题&#xff0c;第一次看到的时候我愣了一下。不是因为它有多花哨&#xff0c;恰恰相反&#xff0c;它朴素得有点不像这个时代的项目名。但正是这种朴素&#xff…

作者头像 李华
网站建设 2026/11/4 10:56:47

CLI-Anything:零改造将任意Python函数转为标准化CLI命令

1. 项目概述&#xff1a;CLI-Anything 不是工具&#xff0c;而是一种 CLI 生态的底层范式重构 “CLI-Anything”这个名字乍看像某个具体命令行工具&#xff0c;但实际它代表的是一类新型 CLI 架构理念——不是把功能塞进一个二进制里&#xff0c;而是让任意 Python 项目、任意模…

作者头像 李华
网站建设 2026/11/4 10:56:28

RocketMQ生产实践:高并发消息可靠性与性能平衡指南

1. 这不是“学完就能用”的教程&#xff0c;而是我在三个高并发系统里踩出来的RocketMQ使用地图 你点开这个标题&#xff0c;大概率不是想看“消息队列是什么”这种教科书定义——你手头正压着一个订单超时未支付要自动关单、用户行为日志要异步落库、或者跨服务库存扣减必须强…

作者头像 李华
网站建设 2026/11/4 10:55:15

xontrib-onepath:提升命令行效率的智能路径导航工具

1. 项目概述 xontrib-onepath是Python生态中一个专注于提升命令行操作效率的实用工具包。作为xonsh shell的扩展组件&#xff0c;它重新定义了路径导航的交互范式。我在多个跨平台开发项目中深度使用这个工具后&#xff0c;发现它能将平均路径操作时间缩短60%以上&#xff0c;特…

作者头像 李华
网站建设 2026/11/4 10:55:00

开源在线表格库Univer:架构解析与工程实践

1. 先想清楚&#xff1a;你需要的到底是一张表格&#xff0c;还是一套表格能力看到“univer”这个标题点进来的朋友&#xff0c;应该都是奔着同一个问题来的&#xff1a;网页里能不能有一套开源、可控、还足够专业的在线表格方案&#xff1f;很多搜索“univer在线”的人&#x…

作者头像 李华