1. 为什么我要折腾一个桌面状态仪表盘
手上同时跑着三台编译机、两台测试设备、一个本地推理服务,还有一堆定时任务在后台默默执行。每天开工第一件事就是挨个 SSH 上去看状态,或者切到浏览器里翻各种监控面板。时间一长,这种碎片化的状态查看方式让我非常烦躁——我需要一个东西,放在桌角,抬眼就能看到所有关键指标,不用切窗口,不用敲命令。
这就是 Status Deck 的起点。它本质上是一个桌面仪表盘,硬件核心是 ESP32,通过 BLE(低功耗蓝牙)和桌面端的 Agent 通信,Agent 负责采集本机或局域网内的各种状态数据,打包成 JSON 推给 ESP32,ESP32 驱动一块小屏幕把数据渲染出来。整个链路不依赖云端,不走公网,纯本地闭环。
你可能会问,为什么不用现成的监控大屏方案?原因很简单:那些方案要么太重(Grafana + Prometheus 一套下来资源占用不小),要么太依赖网络(Web 面板断网就瞎了),要么就是屏幕太大太亮,放在桌面上反而干扰注意力。我要的是一个低功耗、常亮、小尺寸、纯本地的状态窗口,ESP32 恰好完美匹配这个需求。
这篇文章是 Status Deck 系列的第一篇,主要讲整体架构设计、硬件选型思路、BLE 通信协议的设计,以及桌面端 Agent 的核心实现。后续会单独写固件开发细节、屏幕 UI 布局、以及扩展传感器接入的部分。适合有基本嵌入式开发经验、写过 Arduino 或者玩过 ESP32、同时对桌面端开发有一定了解的开发者参考。如果你只是想找一个开箱即用的监控方案,这篇可能不太适合你;但如果你想自己造一个完全可控的桌面仪表盘,那接下来的内容应该能帮你少走不少弯路。
2. 整体架构设计与技术选型拆解
2.1 三层架构的核心思路
Status Deck 的架构分三层:数据采集层(桌面端 Agent)、传输层(BLE)、展示层(ESP32 + 屏幕)。这个分层不是拍脑袋定的,而是基于几个实际约束推导出来的。
第一个约束是 ESP32 的计算能力和内存有限。它跑 FreeRTOS,可用堆内存通常在 200KB 左右(具体取决于芯片型号和配置),要在这上面做 HTTP 请求、JSON 解析、屏幕渲染,如果数据采集逻辑也塞进去,很容易出现内存碎片和任务调度冲突。所以采集逻辑必须外移,ESP32 只负责接收和展示。
第二个约束是数据源的多样性。我需要监控的东西包括:本机 CPU/内存/磁盘、编译任务队列长度、本地服务的健康检查结果、甚至还有几个自定义脚本的输出。这些数据源分散在不同的进程和不同的机器上,ESP32 直接去拉取是不现实的。桌面端 Agent 作为一个中间层,可以灵活地对接各种数据源,统一格式化后再推给 ESP32。
第三个约束是实时性和功耗的平衡。ESP32 通过 BLE 接收数据时,射频模块的工作功耗远高于屏幕刷新。如果让 ESP32 主动轮询,功耗会很难看。所以采用Agent 主动推送 + ESP32 被动接收的模式,ESP32 大部分时间处于轻睡眠状态,收到 BLE 通知后唤醒刷新屏幕,刷完继续睡。
三层架构的数据流是这样的:
[数据源] --> [桌面端 Agent] --BLE Notify--> [ESP32] --> [屏幕渲染] (采集+JSON序列化) (GATT) (解析+显示)2.2 为什么选 BLE 而不是 WiFi 或串口
传输层的选择我认真对比过三种方案,最终选了 BLE,这里把当时的思考过程完整还原一下。
WiFi 方案:ESP32 连 WiFi 后开一个 HTTP Server 或者 WebSocket,Agent 通过局域网推送数据。这个方案的优势是带宽大、协议成熟、调试方便(浏览器就能测)。但问题也很明显:WiFi 连接建立慢(通常 2-5 秒),功耗高(射频持续工作),而且 ESP32 在 WiFi 模式下对电源要求较高,用 USB 供电还好,如果用电池就很不现实。另外,WiFi 连接需要路由器参与,一旦路由器重启或者 IP 变动,ESP32 就得重新配网,维护成本不低。
串口方案:USB 串口直连,最简单最稳定,带宽也够。但线缆是个大问题——我的桌面已经够乱了,再加一根 USB 线从主机拉到桌角的 ESP32,实在不优雅。而且串口方案意味着 ESP32 必须和主机物理连接,没法灵活摆放。
BLE 方案:低功耗、无需配网、连接建立快(通常 1 秒内)、支持通知模式(Notify)实现服务端主动推送。带宽虽然不高(BLE 4.2 理论最大约 100kbps,实际有效吞吐在 5-20kbps 左右),但对于状态数据这种小包低频的场景完全够用。唯一需要注意的是 BLE 在部分主机上的兼容性,尤其是 Linux 下需要 BlueZ 版本足够新,Windows 下需要 WinRT API 支持。
最终选 BLE 的核心理由是:它在这个场景下的短板(带宽)不是瓶颈,而它的长板(低功耗、免配网、快速连接)恰好是刚需。这个决策逻辑我觉得可以推广到很多 IoT 项目里——不要追求参数上的最优,要找场景匹配度最高的。
2.3 JSON 作为数据交换格式的取舍
Agent 和 ESP32 之间的数据格式,我选了 JSON。这个选择其实有争议,因为 JSON 在嵌入式端的解析开销不小。ESP32 上常用的 JSON 库有 ArduinoJson 和 cJSON,前者对内存管理更友好,后者更轻量但需要手动管理内存。
选 JSON 的理由有三点。第一,Agent 端用 Python 或 Node.js 写,JSON 是原生支持,序列化几乎零成本。第二,JSON 的可读性强,调试的时候直接打印出来就能看懂,不用对着二进制协议文档查字段。第三,JSON 的扩展性好,加字段不用改协议版本,ESP32 端解析时忽略不认识的字段就行。
当然,为了控制解析开销,我在数据格式上做了约束:扁平结构、字段名尽量短、数值用整数、避免嵌套对象。比如不会写成{"cpu": {"usage": 45, "temp": 62}},而是写成{"cu": 45, "ct": 62}。这样 ArduinoJson 的解析时间可以控制在几毫秒级别,对 ESP32 来说完全可以接受。
提示:如果你也在 ESP32 上用 JSON,强烈建议用 ArduinoJson 的
StaticJsonDocument而不是DynamicJsonDocument,前者在栈上分配,避免堆碎片,后者虽然灵活但长期运行容易出问题。文档大小根据实际数据量算,一般 512 字节到 2KB 足够了。
3. BLE 通信协议与 GATT 服务设计
3.1 GATT 服务结构规划
BLE 通信的核心是 GATT(通用属性协议)服务。ESP32 作为 GATT Server,桌面端 Agent 作为 GATT Client。整个服务结构设计得尽量简洁,只用一个 Primary Service,里面放三个 Characteristic:
| Characteristic | UUID 后缀 | 属性 | 用途 |
|---|---|---|---|
| 数据通道 | 0xFF01 | Notify | Agent 推送状态数据 |
| 命令通道 | 0xFF02 | Write | Agent 下发控制命令 |
| 心跳通道 | 0xFF03 | Read + Notify | ESP32 上报自身状态 |
数据通道用 Notify 属性,意味着 Agent 不需要每次请求,ESP32 也不用轮询,数据来了自动推。命令通道用 Write,Agent 可以主动发指令,比如切换显示模式、调整亮度、重启设备等。心跳通道让 ESP32 定期上报自己的电量、信号强度、运行时长,Agent 收到后可以判断设备是否在线。
UUID 我用的是自定义的 128 位 UUID,基于一个固定的基址加上后缀生成。这里不贴具体 UUID 了,因为每个人应该根据自己的项目生成不同的基址,避免和周围其他 BLE 设备冲突。生成方法很简单,随便找个 UUID 生成器,把最后几位改成上面表格里的后缀就行。
3.2 数据包格式与 MTU 协商
BLE 的默认 MTU(最大传输单元)是 23 字节,减去 3 字节的 ATT 头,实际可用载荷只有 20 字节。这个大小对于状态数据来说太局促了。所以连接建立后,Agent 需要主动发起 MTU 协商,请求更大的 MTU。
ESP32 支持的 MTU 上限通常是 517 字节(BLE 4.2 及以上),实际协商结果取决于双方的支持情况。在 Linux 下用 BlueZ,协商到 247 字节比较常见;Windows 下 WinRT 通常也能到 247 字节。我实测下来,请求 247 字节的 MTU 是最稳妥的,再大有些主机端会拒绝或者出现兼容性问题。
247 字节的 MTU 减去 3 字节 ATT 头,可用载荷 244 字节。这个空间对于状态数据来说足够了。我的 JSON 数据包通常控制在 150-200 字节之间,留有余量。如果数据量确实大,可以分片发送,ESP32 端按序号重组。不过分片会增加复杂度,我建议尽量在 Agent 端做数据压缩,比如把多个指标合并成一个数组,而不是每个指标一个字段。
数据包的结构我定了一个简单的约定:
{ "t": 1712345678, "cu": 45, "mu": 62, "du": 78, "q": 3, "s": [ {"n": "build", "v": 1}, {"n": "test", "v": 0} ] }字段含义:t是时间戳,cu是 CPU 使用率,mu是内存使用率,du是磁盘使用率,q是任务队列长度,s是服务状态数组,n是服务名,v是状态值(0 表示异常,1 表示正常)。这个格式在 ESP32 端解析起来非常快,ArduinoJson 反序列化耗时在 2-3 毫秒左右。
3.3 连接管理与重连策略
BLE 连接不是永远稳定的。设备休眠、信号干扰、主机蓝牙服务重启,都可能导致连接断开。所以 Agent 端必须有一套健壮的重连机制。
我的做法是:Agent 启动后先扫描 BLE 设备,根据设备名称(ESP32 广播时设置的名称,比如 "StatusDeck")过滤,找到目标设备后发起连接。连接成功后立即协商 MTU,然后订阅数据通道和心跳通道的 Notify。之后进入主循环,定期采集数据并推送。
如果连接断开,Agent 进入重连模式,每隔 3 秒扫描一次,找到设备后重新连接。重连成功后不需要重新配置,直接恢复数据推送。这里有个细节:ESP32 端的 BLE 广播在连接断开后会自动恢复,所以 Agent 不需要做特殊处理,只要重新扫描就行。
注意:在 Linux 下用 BlueZ 做 BLE 客户端时,如果程序异常退出,可能会残留 GATT 连接,导致下次连接失败。解决办法是在程序启动时先调用
bluetoothctl disconnect清理一下,或者在代码里处理org.bluez.Error.Failed异常时主动断开重连。
4. 桌面端 Agent 的实现细节
4.1 技术栈选择与项目结构
Agent 端我用 Python 写的,核心依赖是bleak(跨平台 BLE 库)和psutil(系统指标采集)。选 Python 的理由很简单:开发快、库全、跨平台。虽然 Python 在性能上不如 Go 或 Rust,但 Agent 的工作负载很轻(每秒采集一次数据,序列化后推送),Python 完全够用。
项目结构大致如下:
status-deck-agent/ ├── main.py # 入口,主循环 ├── collector.py # 数据采集模块 ├── ble_client.py # BLE 连接与通信 ├── config.yaml # 配置文件 └── services/ # 自定义数据源插件 ├── build_queue.py └── health_check.pycollector.py负责采集系统指标,ble_client.py封装 BLE 连接和重连逻辑,main.py把两者串起来。services/目录放自定义数据源,每个文件实现一个collect()函数,返回一个字典,主循环会把这些字典合并到最终的数据包里。
这种插件化的设计让扩展变得很容易。比如我想加一个监控本地 Git 仓库状态的功能,只需要在services/下新建一个git_status.py,实现collect()返回当前分支和未提交文件数就行,不用改主循环的代码。
4.2 数据采集模块的实现
系统指标的采集用psutil非常直接:
import psutil def collect_system(): return { "cu": int(psutil.cpu_percent(interval=None)), "mu": int(psutil.virtual_memory().percent), "du": int(psutil.disk_usage("/").percent), }注意cpu_percent的interval参数设成None,这样它返回的是自上次调用以来的平均使用率,不会阻塞。如果设成1,它会阻塞一秒来采样,这在主循环里是不可接受的。
自定义数据源的采集稍微复杂一点,因为要处理超时和异常。我的做法是给每个采集函数加一个超时装饰器,超过 500 毫秒没返回就跳过,避免某个数据源卡住导致整个循环阻塞:
import signal from functools import wraps class TimeoutError(Exception): pass def timeout(seconds): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): def handler(signum, frame): raise TimeoutError() signal.signal(signal.SIGALRM, handler) signal.alarm(seconds) try: result = func(*args, **kwargs) finally: signal.alarm(0) return result return wrapper return decorator这个装饰器在 Linux 和 macOS 下工作良好,Windows 下signal.SIGALRM不支持,需要用threading.Timer替代。考虑到我的主力开发环境是 Linux,这里就用signal方案了。
4.3 BLE 客户端的连接与推送逻辑
BLE 客户端的核心逻辑用bleak实现。连接部分的关键代码如下:
import asyncio from bleak import BleakClient, BleakScanner DEVICE_NAME = "StatusDeck" DATA_CHAR_UUID = "0000ff01-0000-1000-8000-00805f9b34fb" 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 run_agent(): while True: device = await find_device() if not device: await asyncio.sleep(3) continue try: async with BleakClient(device) as client: await client.start_notify(HEARTBEAT_UUID, on_heartbeat) while client.is_connected: data = collect_all() payload = json.dumps(data, separators=(",", ":")).encode() await client.write_gatt_char(DATA_CHAR_UUID, payload, response=False) await asyncio.sleep(1) except Exception as e: print(f"Connection lost: {e}") await asyncio.sleep(3)这里有几个细节值得展开说。write_gatt_char的response=False参数表示用 Write Without Response 模式,速度更快,但可靠性略低。对于状态数据这种可以容忍偶尔丢包的场景,这个取舍是合理的。如果数据很重要,改成response=True会走 Write With Response,每条数据都有确认,但吞吐会下降。
json.dumps的separators参数设成(",", ":")可以去掉默认的空格,减小包体积。这个优化在 BLE 场景下很有意义,因为每个字节都要走射频传输。
4.4 配置管理与启动方式
Agent 的配置放在config.yaml里,包括设备名称、推送间隔、启用的数据源等:
device_name: "StatusDeck" push_interval: 1.0 services: - system - build_queue - health_check health_check: targets: - name: "local-api" url: "http://127.0.0.1:8080/health" timeout: 2启动方式我做了两种:命令行直接python main.py,以及 systemd 服务。后者适合长期运行,开机自启,崩溃自动重启。systemd 的 unit 文件很简单:
[Unit] Description=Status Deck Agent After=bluetooth.target [Service] ExecStart=/usr/bin/python3 /opt/status-deck-agent/main.py Restart=always RestartSec=5 User=youruser [Install] WantedBy=multi-user.target提示:如果你的 Agent 跑在 Linux 上,记得把用户加到
bluetooth组里,否则bleak可能没有权限访问蓝牙设备。命令是sudo usermod -aG bluetooth $USER,改完要重新登录才生效。
5. 常见问题与排查技巧实录
5.1 BLE 连接相关的高频问题
BLE 开发最让人头疼的就是连接问题,我把踩过的坑整理成了一张速查表:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 扫描不到设备 | ESP32 广播未启动 | 用手机 BLE 调试 App 扫描 | 检查固件中广播初始化代码 |
| 连接后立即断开 | MTU 协商失败 | 抓包看 ATT_MTU_REQ 响应 | 降低 MTU 请求值到 185 |
| 数据推送无响应 | Characteristic UUID 不匹配 | 打印服务列表对比 | 确认 UUID 大小写和格式 |
| 连接不稳定 | 2.4G 频段干扰 | 换个环境测试 | 调整 ESP32 天线位置 |
| Linux 下权限拒绝 | 用户不在 bluetooth 组 | groups命令查看 | 加组后重新登录 |
其中 MTU 协商失败是最隐蔽的问题。有些主机端在协商 MTU 时不会返回期望的值,而是返回一个较小的值,如果 Agent 端硬编码了 244 字节的载荷大小,就会导致写入失败。正确的做法是协商后读取实际的 MTU,然后动态计算可用载荷:
mtu = client.mtu_size max_payload = mtu - 3 if len(payload) > max_payload: payload = payload[:max_payload]5.2 JSON 解析与内存问题
ESP32 端解析 JSON 时最常见的问题是内存不足。ArduinoJson 的DynamicJsonDocument在堆上分配内存,如果文档大小估算不准,要么浪费内存,要么解析失败。我的经验是:先用DynamicJsonDocument开发调试,打印出实际使用的内存量,然后换成StaticJsonDocument并设置成实际用量的 1.5 倍。
另一个坑是 JSON 字段类型不匹配。比如 Agent 端发送的cu是整数,但某次采集时psutil.cpu_percent()返回了浮点数,ESP32 端用doc["cu"].as<int>()解析就会得到 0。解决办法是在 Agent 端强制类型转换,确保发送的每个字段类型都是稳定的。
5.3 长时间运行的稳定性问题
Agent 连续跑几天后,可能会遇到 BLE 连接假死的情况——client.is_connected返回True,但数据推送实际上已经失败了。这个问题在 BlueZ 下比较常见,原因是底层 D-Bus 连接出了问题但上层没有感知到。
我的解决方案是加一个心跳检测:ESP32 每秒通过心跳通道上报一次状态,Agent 端维护一个last_heartbeat时间戳,如果超过 5 秒没收到心跳,就主动断开重连。这个机制比单纯依赖is_connected可靠得多。
last_heartbeat = time.time() def on_heartbeat(sender, data): global last_heartbeat last_heartbeat = time.time() # 在主循环里检查 if time.time() - last_heartbeat > 5: raise ConnectionError("Heartbeat timeout")注意:心跳超时时间不要设得太短,BLE 在系统负载高的时候可能会有几百毫秒的延迟。5 秒是一个比较稳妥的值,既能及时发现断连,又不会误判。
5.4 跨平台兼容性踩坑记录
我在 Linux(Ubuntu 22.04)、Windows 11、macOS 三个平台上都测试过 Agent,兼容性问题主要集中在 BLE 库的底层实现上。
Linux 下bleak用 BlueZ,需要 BlueZ 5.43 以上版本。Ubuntu 20.04 自带的 BlueZ 版本偏旧,建议升级到 22.04 或手动编译新版本。另外 Linux 下扫描设备需要 root 权限或者CAP_NET_ADMIN能力,普通用户运行时可能会报org.bluez.Error.NotPermitted。
Windows 下bleak用 WinRT API,整体比较稳定,但首次连接时可能会有 1-2 秒的延迟,这是 WinRT 的固有特性,没法优化。另外 Windows 的蓝牙驱动质量参差不齐,如果用的是杂牌 USB 蓝牙适配器,可能会出现扫描不到设备的情况,换个好点的适配器就能解决。
macOS 下bleak用 CoreBluetooth,稳定性最好,但有个限制:macOS 不允许程序在后台持续扫描 BLE 设备,如果 Agent 窗口失去焦点,扫描可能会被系统暂停。解决办法是把 Agent 打包成菜单栏应用,保持前台状态。
6. 后续扩展方向与个人体会
Status Deck 的基础版本跑通之后,我陆续加了一些扩展功能,这里简单提几个方向,给有类似需求的读者参考。
第一个扩展是多设备支持。一个 Agent 可以同时连接多个 ESP32,每个 ESP32 显示不同的数据子集。比如桌面上放一个显示系统指标,墙上挂一个显示构建状态。实现上只需要把ble_client.py里的单连接改成连接池,每个设备一个独立的BleakClient实例。
第二个扩展是双向控制。利用命令通道,ESP32 上的按键可以把指令回传给 Agent,比如按一下切换显示模式,长按触发一次构建。这个功能需要 Agent 端实现一个命令处理器,根据收到的指令执行对应的动作。
第三个扩展是数据持久化。Agent 可以把每次推送的数据写入 SQLite,后续可以用它做趋势分析,比如查看过去一周的 CPU 使用率曲线。这个功能对调试和容量规划很有帮助。
我个人在实际操作中的体会是:BLE 开发的调试成本远高于 WiFi 或串口,因为你看不到链路层发生了什么。建议在开发初期就配好抓包工具,Linux 下可以用btmon,Windows 下可以用 Wireshark 的 BLE 插件。抓到包之后,ATT 层的读写请求和响应一目了然,排查问题的效率会高很多。
最后再分享一个小技巧:ESP32 的 BLE 广播名称不要用默认的 "ESP32",周围如果有多个 ESP32 设备会很难区分。改成项目相关的名称,比如 "StatusDeck-A"、"StatusDeck-B",Agent 端按名称前缀过滤,多设备管理会清晰很多。