1. 为什么我要折腾一个桌面仪表盘手里这块ESP32开发板在抽屉里躺了大半年一直没想好拿它做什么。直到有天下午写代码频繁切窗口看构建状态、看时间、看服务器负载切得我实在烦躁。当时就想桌上要是有一块小屏幕能把这些信息集中显示出来抬眼就能看到那该多省事。于是就有了这个“Status Deck”的念头——一个放在桌面上、专门给开发者用的信息仪表盘。说白了它就是一个带屏幕的小硬件终端通过无线方式和电脑上的桌面端Agent通信实时显示我关心的几类信息当前时间、构建状态、系统资源占用、待办事项数量甚至还能显示今天写了多少行代码。核心思路很简单电脑端跑一个轻量Agent负责采集数据通过BLE低功耗蓝牙把数据以JSON格式推给ESP32ESP32解析后刷新屏幕。整个链路不依赖网络不需要路由器插上电就能用。这个项目适合谁呢如果你手头正好有ESP32或者类似的开发板对嵌入式开发有点兴趣又想做一个真正能每天用起来的小工具那这篇内容应该对你有帮助。如果你只是好奇桌面仪表盘能做成什么样也可以看看思路。我会把选型理由、通信协议设计、固件开发要点、桌面端Agent的实现逻辑以及实际调试中踩过的坑都完整地讲一遍。2. 整体方案设计与核心选型思路2.1 为什么选ESP32而不是树莓派一开始我考虑过用树莓派Zero来做毕竟Linux环境跑Python脚本方便屏幕驱动也成熟。但实际评估下来树莓派有几个问题让我放弃了。首先是功耗树莓派Zero待机也要一百多毫安而ESP32在BLE连接状态下平均电流可以控制在几十毫安以内差距明显。其次是启动速度树莓派从通电到系统就绪要二十多秒ESP32烧录好固件后上电即运行基本一秒内就能开始工作。再就是成本一块ESP32开发板三四十块钱树莓派Zero加上SD卡和电源适配器轻松过百。当然ESP32也有它的局限。它的RAM只有几百KB跑不了复杂的图形界面框架屏幕刷新逻辑得自己手写。但反过来想仪表盘这种场景本身就不需要复杂的UI显示几个数字和状态图标而已ESP32完全够用。而且ESP32自带BLE和WiFi省去了外接通信模块的麻烦整体电路简单很多。选型这件事没有绝对的对错关键看场景。桌面仪表盘的核心诉求是“低功耗常亮、快速启动、无线通信”ESP32在这三点上都比树莓派更合适。2.2 BLE通信方案的设计考量通信方式我选了BLE而不是WiFi。原因有几个第一BLE配对后连接稳定不像WiFi那样受路由器状态影响电脑重启或者切换网络都不影响仪表盘工作。第二BLE的功耗远低于WiFi对于需要长时间通电的设备来说这点很重要。第三BLE的传输速率虽然不高但仪表盘要传的数据量很小每次就是几百字节的JSON完全够用。具体来说ESP32作为BLE外设桌面端Agent作为BLE中心设备。ESP32启动后广播自己的服务UUIDAgent扫描到后发起连接然后订阅ESP32的特征值通知。数据流向是双向的Agent可以主动写入数据到ESP32的写特征值ESP32也可以通过通知特征值把状态回传给Agent。这种设计的好处是Agent不需要轮询ESP32有数据更新时主动推送即可。关于UUID的设计我用的是自定义128位UUID没有用标准服务。因为标准服务里没有完全匹配“开发者仪表盘”这个场景的自定义反而更灵活。写特征值用来接收Agent发来的JSON数据通知特征值用来上报ESP32的按键事件和心跳。2.3 JSON作为数据交换格式的理由数据格式选了JSON这个决定几乎没有犹豫。JSON的可读性好调试的时候直接打印出来就能看懂不需要额外的解析工具。ESP32端用ArduinoJson库来解析这个库很成熟内存占用可控支持流式解析对于ESP32这种内存有限的平台很友好。桌面端Agent用Python的话json库是标准库自带的序列化和反序列化都是一行代码的事。数据结构上我设计了一个固定的顶层字段结构包含type、timestamp和payload三个部分。type用来区分消息类型比如system_status、build_status、todo_count等。payload里放具体的数据内容不同type对应不同的payload结构。这样设计的好处是扩展方便以后想加新的数据类型只需要定义新的type和对应的payload格式就行ESP32端的解析逻辑改动很小。{ type: system_status, timestamp: 1718000000, payload: { cpu: 23.5, memory: 61.2, disk: 45.0 } }2.4 屏幕选型与驱动方案屏幕我用的是一块1.3寸的IPS彩屏分辨率240x240驱动芯片是ST7789。选它是因为尺寸刚好放在桌面上不占地方240x240的分辨率显示几行文字和简单图标绰绰有余。驱动方面用的是TFT_eSPI库这个库对ESP32的支持很好刷新速度也够快。屏幕布局我分成了四个区域顶部一行显示时间和日期中间左侧显示系统资源占用CPU、内存、磁盘中间右侧显示构建状态和待办数量底部一行显示滚动消息或者提示信息。每个区域独立刷新只有数据变化时才重绘对应区域避免全屏刷新带来的闪烁。3. 核心细节解析与实操要点3.1 ESP32端固件的关键模块拆解固件部分我分成了四个主要模块BLE通信模块、JSON解析模块、屏幕驱动模块和主循环调度模块。BLE通信模块负责广播、连接管理和数据收发。JSON解析模块负责把收到的JSON字符串解析成结构体。屏幕驱动模块负责初始化和刷新各个显示区域。主循环调度模块负责协调各个模块的工作节奏避免阻塞。BLE通信这块有个细节需要注意ESP32的BLE栈默认的MTU是23字节也就是说一次最多只能传20字节的有效数据。对于我们的JSON数据来说这个大小肯定不够。所以需要在连接建立后协商更大的MTU我设置的是512字节这样一次就能传完大部分JSON消息。协商MTU的代码在连接回调里处理void onConnect(BLEServer* pServer) { // 请求更大的MTU pServer-getPeerDevices(true); esp_ble_gatt_set_local_mtu(512); }JSON解析模块用的是ArduinoJson 6.x版本。这个版本和5.x的API差异比较大网上很多教程还是5.x的写法直接抄会编译报错。6.x里用DynamicJsonDocument来分配内存需要预估JSON的大小。我一般给512字节的容量对于我们的数据量来说足够了。解析的时候用deserializeJson函数它会自动处理字符串到对象的转换。DynamicJsonDocument doc(512); DeserializationError error deserializeJson(doc, jsonString); if (error) { Serial.print(JSON解析失败: ); Serial.println(error.c_str()); return; } const char* type doc[type]; JsonObject payload doc[payload];屏幕驱动模块用的是TFT_eSPI库需要在User_Setup.h里配置好引脚和驱动芯片型号。这里有个坑不同厂家的ESP32开发板引脚定义不一样屏幕的接线也不一样所以User_Setup.h里的配置需要根据自己的硬件来改。我用的这块屏幕是SPI接口接线是SCL接GPIO18SDA接GPIO23DC接GPIO2RST接GPIO4CS接GPIO5。背光引脚直接接3.3V常亮没有做PWM调光因为桌面使用不需要调亮度。3.2 桌面端Agent的数据采集逻辑桌面端Agent我用Python写的跑在电脑上负责采集数据并通过BLE发送给ESP32。采集的数据分几类系统资源CPU、内存、磁盘、构建状态监听构建命令的输出、待办事项读取本地todo文件、时间日期直接取系统时间。系统资源采集用的是psutil库这个库跨平台支持好Windows、macOS、Linux都能用。CPU占用率用psutil.cpu_percent(interval1)来获取注意这个函数会阻塞一秒钟所以不能放在主线程里频繁调用。我的做法是单独开一个线程每隔5秒采集一次采集结果放到一个共享变量里主线程发送数据时直接读取。import psutil import threading import time system_status {cpu: 0, memory: 0, disk: 0} def collect_system_status(): while True: system_status[cpu] psutil.cpu_percent(interval1) system_status[memory] psutil.virtual_memory().percent system_status[disk] psutil.disk_usage(/).percent time.sleep(4) threading.Thread(targetcollect_system_status, daemonTrue).start()构建状态的采集稍微复杂一些。我的做法是监听一个特定的文件或者管道构建脚本在开始和结束时往里面写状态。比如构建开始时写入building成功时写入success失败时写入failed。Agent读取这个文件的变化然后推送给ESP32。这种方式比解析构建工具的输出要可靠得多因为不同构建工具的输出格式差异很大。待办事项的采集最简单直接读取一个JSON文件里面存了待办列表Agent统计未完成的数量推送给ESP32就行。3.3 BLE通信协议的具体设计BLE通信协议我设计得很简单就是“连接-订阅-推送”三步。ESP32启动后开始广播广播名称叫StatusDeck这样Agent扫描的时候能快速识别。Agent扫描到设备后发起连接连接成功后订阅ESP32的通知特征值然后就可以开始发送数据了。数据发送的格式就是前面说的JSON字符串。ESP32收到数据后解析根据type字段决定更新哪个显示区域。如果解析失败ESP32会通过通知特征值回传一个错误消息Agent收到后记录日志。这里有个细节要注意BLE的写入操作有“带响应”和“不带响应”两种模式。带响应的写入会等待ESP32确认收到数据可靠性高但速度慢。不带响应的写入速度快但可能丢数据。我选的是带响应模式因为仪表盘的数据更新频率不高每秒最多几次对速度要求不高可靠性更重要。# Agent端发送数据的核心逻辑 async def send_data(client, data): json_str json.dumps(data) await client.write_gatt_char(WRITE_CHAR_UUID, json_str.encode())3.4 屏幕刷新策略与防闪烁处理屏幕刷新是嵌入式UI开发里比较头疼的问题。如果每次数据更新都全屏重绘屏幕会明显闪烁看起来很不舒服。我的做法是分区刷新加双缓冲。分区刷新就是把屏幕分成几个独立的区域每个区域有自己的刷新函数只有数据变化时才调用对应的刷新函数。双缓冲是在内存里维护一个屏幕缓冲区先在缓冲区里画好再一次性推送到屏幕。TFT_eSPI库支持Sprite功能可以创建一个离屏的Sprite对象在Sprite上绘制内容然后一次性推送到屏幕。这种方式能有效避免闪烁但会消耗额外的内存。240x240的16位色Sprite需要115KB的内存ESP32的RAM够用但要注意不要创建太多Sprite。TFT_eSPI tft TFT_eSPI(); TFT_eSprite spr TFT_eSprite(tft); void setup() { tft.init(); tft.setRotation(0); spr.createSprite(240, 60); // 只给顶部区域创建Sprite }实际用下来我只给顶部时间区域和底部消息区域创建了Sprite中间的资源占用和构建状态区域直接绘制因为这两个区域刷新频率低闪烁不明显。4. 实操过程与核心环节实现4.1 硬件接线与开发环境搭建先说硬件接线。我用的ESP32开发板是ESP32-DevKitC屏幕是1.3寸ST7789 IPS屏。接线如下屏幕引脚ESP32引脚说明VCC3.3V电源正极GNDGND电源负极SCLGPIO18SPI时钟SDAGPIO23SPI数据DCGPIO2数据/命令选择RSTGPIO4复位CSGPIO5片选BLK3.3V背光常亮接线的时候注意屏幕的电压是3.3V不要接到5V上否则可能烧毁屏幕。另外SPI的时钟线要尽量短避免干扰。开发环境我用的是Arduino IDE需要安装ESP32的开发板支持。国内下载开发板包有时候会比较慢可以在首选项里添加国内的镜像源地址速度会快很多。安装完开发板支持后还需要安装三个库TFT_eSPI、ArduinoJson和ESP32 BLE Arduino。这三个库都可以通过库管理器搜索安装。安装TFT_eSPI之后一定要去Arduino的库目录里找到TFT_eSPI文件夹编辑里面的User_Setup.h文件把驱动芯片改成ST7789引脚改成上面表格里的定义。不改的话屏幕不会亮。4.2 固件核心代码实现固件的主循环逻辑是这样的初始化BLE和屏幕然后进入循环在循环里检查是否有新的BLE数据到达如果有就解析并更新屏幕同时每隔一秒更新一次时间显示。BLE初始化的代码#include BLEDevice.h #include BLEServer.h #include BLEUtils.h #include BLE2902.h #define SERVICE_UUID 4fafc201-1fb5-459e-8fcc-c5c9c331914b #define WRITE_CHAR_UUID beb5483e-36e1-4688-b7f5-ea07361b26a8 #define NOTIFY_CHAR_UUID beb5483e-36e1-4688-b7f5-ea07361b26a9 BLEServer* pServer NULL; BLECharacteristic* pWriteChar NULL; BLECharacteristic* pNotifyChar NULL; bool deviceConnected false; class MyServerCallbacks : public BLEServerCallbacks { void onConnect(BLEServer* pServer) { deviceConnected true; Serial.println(设备已连接); } void onDisconnect(BLEServer* pServer) { deviceConnected false; Serial.println(设备已断开重新广播); pServer-startAdvertising(); } }; class MyWriteCallbacks : public BLECharacteristicCallbacks { void onWrite(BLECharacteristic* pChar) { std::string value pChar-getValue(); if (value.length() 0) { String jsonStr String(value.c_str()); handleJson(jsonStr); } } }; void setupBLE() { BLEDevice::init(StatusDeck); pServer BLEDevice::createServer(); pServer-setCallbacks(new MyServerCallbacks()); BLEService* pService pServer-createService(SERVICE_UUID); pWriteChar pService-createCharacteristic( WRITE_CHAR_UUID, BLECharacteristic::PROPERTY_WRITE ); pWriteChar-setCallbacks(new MyWriteCallbacks()); pNotifyChar pService-createCharacteristic( NOTIFY_CHAR_UUID, BLECharacteristic::PROPERTY_NOTIFY ); pNotifyChar-addDescriptor(new BLE2902()); pService-start(); BLEAdvertising* pAdvertising BLEDevice::getAdvertising(); pAdvertising-addServiceUUID(SERVICE_UUID); pAdvertising-setScanResponse(true); pAdvertising-setMinPreferred(0x06); pAdvertising-setMinPreferred(0x12); BLEDevice::startAdvertising(); }JSON处理的代码void handleJson(String jsonStr) { DynamicJsonDocument doc(512); DeserializationError error deserializeJson(doc, jsonStr); if (error) { Serial.print(JSON解析失败: ); Serial.println(error.c_str()); return; } const char* type doc[type]; if (type NULL) return; if (strcmp(type, system_status) 0) { float cpu doc[payload][cpu]; float memory doc[payload][memory]; float disk doc[payload][disk]; updateSystemStatus(cpu, memory, disk); } else if (strcmp(type, build_status) 0) { const char* status doc[payload][status]; updateBuildStatus(status); } else if (strcmp(type, todo_count) 0) { int count doc[payload][count]; updateTodoCount(count); } }屏幕刷新的代码void updateSystemStatus(float cpu, float memory, float disk) { spr.fillSprite(TFT_BLACK); spr.setTextColor(TFT_WHITE, TFT_BLACK); spr.setTextSize(2); spr.setCursor(0, 0); spr.printf(CPU: %.0f%%, cpu); spr.setCursor(0, 20); spr.printf(MEM: %.0f%%, memory); spr.setCursor(0, 40); spr.printf(DSK: %.0f%%, disk); spr.pushSprite(0, 60); }4.3 桌面端Agent的完整实现桌面端Agent用Python写依赖bleak库来做BLE通信psutil库来采集系统信息。安装依赖pip install bleak psutilAgent的主逻辑是扫描BLE设备找到名为StatusDeck的设备后连接然后启动一个定时任务每隔几秒采集一次数据并发送。import asyncio import json import time from bleak import BleakScanner, BleakClient WRITE_CHAR_UUID beb5483e-36e1-4688-b7f5-ea07361b26a8 DEVICE_NAME StatusDeck async def find_device(): devices await BleakScanner.discover(timeout5.0) for d in devices: if d.name DEVICE_NAME: return d return None async def main(): device await find_device() if not device: print(未找到StatusDeck设备) return async with BleakClient(device) as client: print(f已连接到 {device.name}) while True: data { type: system_status, timestamp: int(time.time()), payload: { cpu: system_status[cpu], memory: system_status[memory], disk: system_status[disk] } } json_str json.dumps(data) await client.write_gatt_char(WRITE_CHAR_UUID, json_str.encode()) await asyncio.sleep(5) asyncio.run(main())4.4 构建状态监听的实现细节构建状态监听我用了一个文件监听的方式。在项目根目录下创建一个.build_status文件构建脚本在开始和结束时更新这个文件的内容。Agent用watchdog库监听文件变化变化时读取内容并推送给ESP32。from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler class BuildStatusHandler(FileSystemEventHandler): def on_modified(self, event): if event.src_path.endswith(.build_status): with open(event.src_path, r) as f: status f.read().strip() asyncio.run_coroutine_threadsafe( send_build_status(status), loop )构建脚本里加两行echo building .build_status # ... 构建命令 ... echo success .build_status # 或 failed这种方式的好处是完全解耦构建脚本不需要知道Agent的存在只需要写文件就行。Agent也不需要解析构建工具的输出只需要读文件内容。5. 常见问题与排查技巧实录5.1 BLE连接不稳定怎么办这是我最开始遇到的最头疼的问题。现象是ESP32和Agent连接后过几分钟就自动断开然后ESP32重新广播Agent重新连接循环往复。排查下来发现几个原因第一个原因是MTU协商失败。ESP32默认MTU是23字节如果Agent发送的数据超过20字节写入会失败多次失败后BLE栈会主动断开连接。解决办法是在连接建立后立即协商更大的MTU前面代码里已经提到了。第二个原因是电源干扰。ESP32通过USB线连接电脑供电如果USB线质量不好或者电脑USB口供电不稳BLE信号会受影响。我换了一根带屏蔽的USB线之后连接稳定性明显提升。第三个原因是Agent端的扫描间隔设置太短。bleak库默认的扫描间隔是1秒如果ESP32广播间隔也是1秒两者可能错过。我把ESP32的广播间隔改成100毫秒Agent的扫描间隔改成500毫秒匹配上之后连接就稳定了。问题现象可能原因解决办法连接后几分钟断开MTU协商失败连接后立即协商512字节MTU连接后频繁断开电源干扰换带屏蔽的USB线或改用独立电源扫描不到设备广播间隔不匹配ESP32广播间隔改100msAgent扫描间隔改500ms数据写入失败数据超过MTU限制协商更大MTU或分片发送5.2 JSON解析失败怎么排查JSON解析失败一般有几个原因数据不完整、格式错误、内存不足。数据不完整通常是BLE传输过程中丢了字节解决办法是在JSON末尾加一个结束符ESP32收到结束符后才开始解析。格式错误一般是Agent端序列化时出了问题可以在Agent端打印出序列化后的字符串和ESP32端收到的字符串对比看看哪里不一样。内存不足是ArduinoJson 6.x特有的问题。DynamicJsonDocument需要预先分配内存如果分配的容量不够解析会失败。我一开始分配了256字节解析系统状态数据时偶尔失败后来改成512字节就稳定了。判断内存是否够用的方法是看doc.memoryUsage()的返回值如果接近容量上限就需要加大。DynamicJsonDocument doc(512); // ... 解析 ... Serial.print(内存使用: ); Serial.println(doc.memoryUsage());5.3 屏幕显示异常的处理屏幕显示异常我遇到过几种花屏、白屏、颜色不对。花屏一般是SPI时钟太快或者接线太长解决办法是把SPI频率降到20MHz试试。白屏一般是初始化失败检查User_Setup.h里的驱动芯片型号和引脚定义是否正确。颜色不对一般是颜色格式设置错了ST7789支持RGB和BGR两种格式在User_Setup.h里改一下TFT_RGB_ORDER就行。还有一个容易被忽略的问题屏幕的复位引脚如果没有正确接线屏幕可能无法正常初始化。有些屏幕模块内部有复位电路RST引脚可以不接但有些必须接。我建议无论如何都把RST接上然后在代码里手动复位一次。void setup() { pinMode(4, OUTPUT); digitalWrite(4, LOW); delay(10); digitalWrite(4, HIGH); delay(100); tft.init(); }5.4 Agent端数据采集的坑psutil.cpu_percent()这个函数有个坑第一次调用时返回的是0因为它需要两次采样才能计算差值。解决办法是在启动采集线程后先调用一次忽略返回值然后再进入循环。磁盘占用率在Windows上要注意psutil.disk_usage(/)在Windows上会报错需要改成psutil.disk_usage(C:\\)。跨平台的做法是用os.path.abspath(os.sep)来获取根路径。import os root_path os.path.abspath(os.sep) disk_usage psutil.disk_usage(root_path).percent还有一个问题是Agent在电脑休眠后可能无法自动重连。我的做法是在Agent里加一个心跳检测如果超过30秒没有收到ESP32的通知消息就主动断开重连。6. 实际使用体验与后续扩展方向这套Status Deck我已经用了两个多月整体体验超出预期。最直观的感受是写代码时不再需要频繁切窗口看系统状态了抬眼就能看到CPU和内存占用构建状态也能第一时间看到。待办数量显示在屏幕上也有一种无形的督促作用。从稳定性来说BLE连接基本能保持一整天不断偶尔断一次也会自动重连。屏幕刷新流畅没有明显的闪烁。功耗方面用USB电流表测了一下ESP32加屏幕整体电流在80mA左右一天耗电不到2度完全可以接受。后续我打算加几个功能。一个是按键交互在ESP32上接一个按钮按一下可以切换显示模式比如从系统状态切换到天气信息。另一个是增加一个简单的蜂鸣器构建失败时发出提示音。再就是考虑把数据记录到SD卡里方便回溯查看历史状态。如果你也想做一套类似的桌面仪表盘我的建议是从最简单的功能开始先把BLE通信和屏幕显示跑通再逐步加功能。不要一开始就想着做得很完美先让东西能用起来然后在用的过程中慢慢改进。硬件方面ESP32加一块小屏幕的成本不到五十块钱试错成本很低值得动手试试。