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 联调最容易卡在"连不上"和"连上了没反应"这两步。我的排查顺序是这样的:
- 先确认板子 BLE 在广播。用手机上的 BLE 调试助手扫一下,能看到
StatusDeck这个设备名就说明固件 BLE 初始化成功。 - 再确认服务能发现。连上后看 GATT 服务列表里有没有那个自定义 UUID,没有的话多半是服务注册代码有问题。
- 然后测特征值读写。用调试助手往 RX 写一段 JSON,看串口日志有没有打印解析结果。
- 最后才上 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 并显示出来。这一步通了,后面加什么功能都是锦上添花。别一上来就设计大而全的协议和花哨的界面,容易在细节里迷失,最后连第一个字节都没传通。硬件项目尤其如此,能跑起来比设计得漂亮重要得多。