1. 为什么我要给桌面塞一块状态屏
先说结论:我给自己攒了一个叫 Status Deck 的小硬件,巴掌大,放在显示器下面,用 BLE 跟电脑上的桌面端 Agent 通信,实时显示构建状态、CI 结果、服务器负载、待办数量这些我写代码时最常瞄一眼的信息。整套东西从固件到桌面端都是自己写的,没有用任何现成的成品方案。
做这个东西的起因很朴素。我日常写代码的时候,注意力经常被各种通知切碎:CI 挂了弹一个系统通知,服务器告警弹一个,IM 消息再弹一个。这些通知混在一起,我根本分不清哪个是真正需要立刻处理的。更烦的是,每次想看构建状态都得切到浏览器、点开流水线页面、等它加载,一来一回两分钟没了。我需要的是一个"余光可见"的东西——不用切窗口,扫一眼就知道现在有没有事。
市面上不是没有类似产品,但要么是纯软件方案(比如在副屏上开个网页),要么是成品智能屏,前者占屏幕资源,后者封闭、改不动、还得联网。我想要的是一个完全可控、离线可用、能随便改显示逻辑的小硬件。ESP32 正好合适:自带 BLE、有 WiFi、GPIO 够用、价格便宜、社区资料多到看不完。于是就有了 Status Deck 这个项目。
这篇是第一篇,主要讲整体设计思路、硬件选型、通信协议怎么定、固件怎么搭骨架。后面会单独写桌面端 Agent 的实现、BLE 抓包调试、以及显示层的优化。如果你也是那种"想给自己做个桌面小玩意、又不想被成品方案绑死"的开发者,这套思路应该能直接抄。
2. Status Deck 到底要解决什么问题
2.1 从"通知轰炸"到"状态常驻"
传统的通知模型是事件驱动的:有事发生就弹一下,弹完就消失。问题是,弹的那一下我可能在专注写代码,根本没看到;等我回过神来,通知已经堆了一屏,还得一条条翻。而状态类信息(比如"当前分支 CI 是否通过""生产环境是否健康")本质上是持续存在的状态,不是一次性事件。用通知去表达状态,本身就是错配。
Status Deck 的核心思路是把"状态"从"事件"里剥离出来。桌面端 Agent 负责采集各种状态源(Git、CI、监控、待办),归一化成几个简单的状态值,通过 BLE 推给硬件;硬件只负责显示,不做任何逻辑判断。这样职责非常清晰:Agent 挂了不影响显示(显示的是最后一次收到的状态),硬件重启也不影响 Agent。
2.2 为什么不用 WiFi 而选 BLE
这里有个反直觉的选择。ESP32 明明支持 WiFi,为什么我最终选了 BLE 作为主通道?
第一,配网成本。WiFi 方案意味着每次换环境都要重新配网,SSID、密码、IP 这些事在开发阶段极其烦人。BLE 不需要配网,配对一次就行,换 WiFi 环境完全无感。
第二,功耗和发热。桌面设备虽然插着电,但 WiFi 持续连接会让 ESP32 明显发热,尤其是信号不好的时候。BLE 的连接维持功耗低得多,摸上去是凉的。
第三,安全性。BLE 是点对点短距通信,物理上就限定了范围,不像 WiFi 那样暴露在局域网里。对于显示构建状态、服务器负载这类信息,我不想让它出现在局域网任何一台设备都能扫到的地方。
第四,延迟。BLE 的连接间隔可以调到很低(我用的 15ms),状态更新几乎无感。WiFi 方案走 MQTT 或者 WebSocket,中间多一层 broker 或者握手,反而更重。
当然 BLE 也有代价:带宽低、传输大数据慢、不同平台的 BLE 栈行为差异大。但 Status Deck 传的就是几个状态值,几十字节的事,BLE 完全够用。
2.3 桌面端 Agent 的角色定位
Agent 是整个系统的大脑,跑在电脑上,用 Python 写(后面会讲为什么不用 Node)。它做三件事:
- 采集:定时轮询或者订阅各种状态源。比如每 30 秒查一次 CI 的 API,每 5 秒读一次本地 Git 状态,订阅监控系统的 webhook。
- 归一化:把五花八门的状态统一成有限的几种。我定义的状态类型只有几种:
ok、warn、error、busy、idle,每种对应一个颜色和图标。 - 推送:通过 BLE 把归一化后的状态打包发给硬件。只在状态变化时推,避免无谓的通信。
Agent 和硬件之间是单向为主、双向为辅的关系。状态推送是单向的,但硬件可以发心跳和按键事件回来,Agent 收到按键可以触发一些动作(比如按一下刷新 CI 状态)。
3. 硬件选型:ESP32 型号怎么挑
3.1 常见 ESP32 型号对比
ESP32 家族型号很多,选错了后面会很难受。我把几个主流型号拉出来对比一下:
| 型号 | 核心 | BLE | WiFi | 价格区间 | 适合场景 |
|---|---|---|---|---|---|
| ESP32 | 双核 Xtensa | 4.2 | 支持 | 低 | 通用,资料最多 |
| ESP32-S3 | 双核 Xtensa | 5.0 | 支持 | 中 | 需要 USB、AI 加速 |
| ESP32-C3 | 单核 RISC-V | 5.0 | 支持 | 低 | 成本敏感、单核够用 |
| ESP32-C6 | 单核 RISC-V | 5.0 | 支持 | 中 | 需要新协议支持 |
Status Deck 我最终用的是ESP32-S3。理由有几个:BLE 5.0 的连接参数更灵活,USB 原生支持方便调试和烧录(不用外接 USB-TTL),双核可以一个核跑 BLE 协议栈、一个核跑显示刷新,互不干扰。如果你只是想先跑通,普通 ESP32 也完全够,资料还更多。
3.2 显示方案的选择
显示是 Status Deck 的脸面,选型要考虑几个维度:刷新率、可视角度、驱动复杂度、功耗。
我试过三种方案:
- 0.96 寸 OLED(SSD1306):I2C 接口,驱动简单,但太小,放桌面上看不清,而且单色显示状态不够直观。
- 1.3 寸 IPS(ST7789):SPI 接口,彩色,可视角度好,尺寸合适。这是我最终选的方案。
- 电子墨水屏:省电、护眼,但刷新慢(几百毫秒),做状态显示会有明显拖影,不适合。
ST7789 用 SPI 驱动,ESP32-S3 的 SPI 时钟可以跑到 80MHz,刷一屏 240x240 的全屏内容也就几毫秒。我用的是 TFT_eSPI 库,配置起来比较直接。
3.3 供电和外壳
供电我一开始想用电池,后来放弃了。桌面设备插着 USB 就行,电池反而增加充电管理、电量显示这些不必要的复杂度。直接用 USB-C 供电,顺便还能烧录和串口调试。
外壳我用 3D 打印,设计的时候注意两点:一是屏幕开孔要留公差,二是背面要留散热孔。ESP32-S3 跑 BLE 加 SPI 刷屏的时候会有一定发热,密闭外壳里温度会累积。
提示:如果你也打算 3D 打印外壳,屏幕开孔建议比实际尺寸大 0.3-0.5mm,不同打印机的收缩率不一样,留点余量好装。
4. 通信协议:BLE 上跑什么
4.1 用 GATT 还是自定义协议
BLE 通信有两种思路:一种是走标准的 GATT 服务,定义 Characteristic 来读写;另一种是在 BLE 之上再套一层自定义协议。
我选的是GATT + 自定义 payload。GATT 负责连接管理和通道定义,payload 用紧凑的二进制格式。这样既利用了 BLE 成熟的连接机制,又能把数据压到最小。
具体定义了一个主服务,下面挂几个 Characteristic:
- 状态推送 Characteristic:Agent 往这里写状态数据,硬件 notify 订阅。
- 心跳 Characteristic:硬件定期写,Agent 读,用来判断硬件是否在线。
- 按键事件 Characteristic:硬件按键触发时写,Agent 订阅。
4.2 状态数据的二进制格式
状态数据我用固定长度的二进制,不用 JSON。原因很简单:BLE 的 MTU 有限(默认 23 字节,协商后可以到 247),JSON 的键名太占地方。一个状态包我压到 8 字节:
[类型 1B][状态码 1B][数值 2B][时间戳 4B]类型表示这是哪个状态源(CI、Git、监控等),状态码是 ok/warn/error 这些,数值可以放百分比或者计数,时间戳用来判断数据新鲜度。8 字节的包,一个 BLE 包就能装下,延迟极低。
如果你觉得二进制调试麻烦,开发阶段可以先用 JSON,跑通之后再换成二进制。我实际就是这么干的,先用 JSON 把链路打通,确认没问题再优化格式。
4.3 连接参数怎么调
BLE 的连接参数直接影响延迟和功耗,这几个参数要重点调:
- Connection Interval:连接间隔。我设的 15ms(最小值 7.5ms 的倍数)。间隔越小延迟越低,但功耗越高。桌面设备插电,可以往低了调。
- Slave Latency:从设备延迟。设 0,表示每个连接事件从设备都要响应,延迟最低。
- Supervision Timeout:监督超时。设 4 秒左右,太短容易误判断连,太长断连发现慢。
这些参数在 ESP32 端通过esp_ble_gap_set_prefer_conn_params设置,桌面端也要配合。不同操作系统的 BLE 栈对这些参数的支持程度不一样,Windows 上尤其要注意,它有时候会强制覆盖你设的参数。
5. 固件骨架:从零搭一个能跑的框架
5.1 开发环境的选择
ESP32 的开发环境有好几种:Arduino IDE、ESP-IDF、PlatformIO。我最终用的是PlatformIO + ESP-IDF 框架。
Arduino IDE 上手快,但工程管理弱,依赖一多就乱,而且编译速度慢(Windows 上尤其明显)。ESP-IDF 是官方框架,功能全、性能好,但纯命令行配置对新手不友好。PlatformIO 是折中方案:用platformio.ini管理依赖和编译配置,底层可以选 Arduino 或 ESP-IDF 框架,编译速度也比 Arduino IDE 快。
我的platformio.ini大致长这样:
[env:esp32-s3-devkitc-1] platform = espressif32 board = esp32-s3-devkitc-1 framework = espidf monitor_speed = 115200 build_flags = -DCONFIG_BT_ENABLED=1 -DCONFIG_BT_BLE_ENABLED=1如果你完全没接触过 ESP32,建议先用 Arduino IDE 跑个点灯,确认工具链没问题,再切到 PlatformIO。离线包的问题在热词里出现频率很高,我的建议是:第一次装的时候联网装好,之后 PlatformIO 会缓存,离线也能编译。
5.2 任务划分:双核怎么用
ESP32-S3 是双核,合理划分任务能避免 BLE 和显示互相卡。我的划分是:
- Core 0:跑 BLE 协议栈和通信任务。BLE 协议栈对实时性要求高,不能被显示刷新阻塞。
- Core 1:跑显示刷新和按键扫描。显示刷新是周期性的,偶尔慢一点无所谓。
任务之间用 FreeRTOS 的队列通信。BLE 收到状态数据后,丢进队列,显示任务从队列取出来渲染。这样解耦之后,即使显示任务卡住,BLE 也不会丢包。
// 简化的队列通信示意 QueueHandle_t status_queue = xQueueCreate(10, sizeof(status_packet_t)); // BLE 回调里 xQueueSend(status_queue, &packet, 0); // 显示任务里 status_packet_t pkt; if (xQueueReceive(status_queue, &pkt, portMAX_DELAY)) { render_status(&pkt); }5.3 BLE 初始化的关键步骤
BLE 初始化顺序不能乱,乱了会各种报错。我的顺序是:
- 初始化 NVS(非易失存储),BLE 协议栈需要用它存配对信息。
- 释放经典蓝牙内存(如果不用经典蓝牙),给 BLE 腾空间。
- 初始化控制器和协议栈。
- 注册 GATT 服务。
- 配置广播参数,开始广播。
每一步的返回值都要检查,ESP-IDF 的 API 出错会返回错误码,不检查的话后面会莫名其妙崩溃。我踩过的坑是 NVS 没初始化就调 BLE 初始化,结果直接重启,查了半天才发现是 NVS 的问题。
5.4 广播数据怎么设计
广播数据是设备被发现时展示的信息,包括设备名、服务 UUID 等。这里有个细节:广播包只有 31 字节,装不下太多东西。设备名我设成StatusDeck,服务 UUID 用自定义的 128 位 UUID。
如果你想让设备名动态变化(比如显示当前状态),要注意广播包长度限制。我试过把状态塞进广播包,结果名字一长就超了,后来还是老老实实走连接后的 GATT 通信。
6. 实测踩过的坑和调试技巧
6.1 BLE 抓包:怎么只看指定设备
调试 BLE 最有效的工具是抓包。Wireshark 配合专用的 BLE 抓包硬件(比如 Nordic 的 sniffer)能看到空口的所有数据。但空口数据量巨大,怎么只看自己设备的?
我的做法是先用手机 App(比如 nRF Connect)扫一下,拿到设备的 MAC 地址,然后在 Wireshark 里加过滤条件:
btle.advertising_address == xx:xx:xx:xx:xx:xx连接之后,过滤条件换成:
btle.address == xx:xx:xx:xx:xx:xx这样就能只看自己设备的数据流,清爽很多。抓包的时候要注意,BLE 的加密连接抓到的数据是密文,得在 Wireshark 里配置密钥才能解密,这个后面单独写。
6.2 连接不稳定的排查链路
我遇到过连接一会儿就断的问题,排查过程记录一下,供参考:
第一步,看串口日志。ESP32 端会打印断开原因码,比如reason 0x08是超时,0x13是对方主动断开。原因码能直接定位方向。
第二步,看是不是连接参数的问题。如果原因码是超时,多半是 supervision timeout 设太短,或者连接间隔和从设备延迟不匹配。
第三步,看是不是电源问题。ESP32 在 BLE 发射瞬间电流会飙到 100mA 以上,如果 USB 供电不稳或者线材太差,会瞬间掉压导致重启。我换了一根粗一点的 USB 线就好了。
第四步,看是不是干扰。2.4GHz 频段很拥挤,WiFi 路由器、微波炉都会干扰。把设备挪个位置试试。
这个排查顺序的逻辑是:从软件到硬件,从确定到不确定。串口日志是最确定的,先看;电源和干扰是最不确定的,最后试。
6.3 烧录方式的坑
ESP32 烧录有几种方式:USB 直接烧、USB-TTL 烧、OTA 烧。开发阶段用 USB 直接烧最方便,但要注意自动下载电路。
ESP32-S3 的 USB 是原生的,直接插上就能烧,不用手动按 BOOT 键。但普通 ESP32 需要自动下载电路(两个三极管控制 EN 和 IO0),如果电路没做对,就得手动按 BOOT 再按 RESET,很烦。我第一版板子就是忘了做自动下载电路,每次烧录都要按两个键,后来重新打板才解决。
OTA 烧录适合设备装进外壳之后,不用拆开就能更新固件。但 OTA 需要设备先连上 WiFi,而且要有 OTA 分区表。Status Deck 主通道是 BLE,OTA 我暂时没做,更新固件还是拆开插 USB。
6.4 显示刷新的性能问题
ST7789 用 SPI 驱动,如果每次状态变化都全屏刷新,会有明显的闪烁。我的优化是局部刷新:只重绘变化的区域。
具体做法是把屏幕分成几个区域,每个区域对应一个状态源。状态变化时,只重绘对应的区域。TFT_eSPI 支持设置绘制窗口(setAddrWindow),只往指定区域写数据。
另一个优化是双缓冲。在内存里维护一份完整的帧缓冲,所有绘制操作先画到缓冲里,画完一次性推给屏幕。这样避免了绘制过程中的中间状态被显示出来。ESP32-S3 的 PSRAM 够大,开个 240x240x2 字节的缓冲(约 115KB)完全没问题。
7. 这套架构还能怎么扩展
Status Deck 目前只做了状态显示,但架构留了不少扩展空间。
按键交互:硬件上留了两个按键,短按刷新、长按切换显示模式。按键事件通过 BLE 回传给 Agent,Agent 可以触发任意动作,比如"按一下重新跑 CI"。
多设备联动:BLE Mesh 可以让多个 Status Deck 组网,一个显示 CI、一个显示监控、一个显示待办。ESP32 支持 BLE Mesh,但 Mesh 和普通 BLE 连接不能同时跑,需要切换模式,这个后面研究。
边缘计算:ESP32-S3 带 AI 加速指令,理论上可以在设备端做一些简单的判断,比如根据历史数据预测状态趋势。不过目前状态判断都在 Agent 端做,设备端保持"哑终端"更简单可靠。
固件安全:如果 Status Deck 要显示敏感信息(比如生产环境状态),固件加密和 BLE 配对加密就很重要。ESP32 支持 Secure Boot 和 Flash 加密,BLE 也支持配对绑定。这些我打算在后续版本加上。
我个人在实际操作中的体会是:做这类小硬件项目,最容易犯的错是"想太多、做太少"。一开始就想着要支持十种状态源、要 Mesh 组网、要 OTA,结果一个月过去连点灯都没跑通。正确的做法是先跑通最小闭环——一个状态源、一块屏、一条 BLE 链路——然后再往上加。Status Deck 的第一版就只显示 CI 状态,跑通之后加别的状态源就是复制粘贴的事。
最后分享一个小技巧:调试 BLE 的时候,手机上的 nRF Connect 是神器。它能扫描、连接、读写 Characteristic、看日志,比在电脑上折腾半天快得多。我基本是先在手机上把 GATT 服务调通,确认读写正常,再去写桌面端 Agent。这样能把问题范围缩小到"是硬件问题还是桌面端问题",排查效率高很多。