首页 / 资讯中心 / 文章详情

ESP32全栈桌面仪表盘Status Deck:从架构设计到数据链路打通

ESP32全栈桌面仪表盘Status Deck:从架构设计到数据链路打通 ★ FEATURED ARTICLE
1. 为什么我要折腾一块桌面仪表盘手里攒了太多零碎的开发状态信息这是我做 Status Deck 最原始的动机。CI 流水线跑没跑完、本地服务有没有挂、今天写了多少行代码、家里温湿度是不是又飙到 30 度、番茄钟还剩几分钟——这些东西散落在浏览器标签页、终端窗口、手机 App 里每次想看都得切来切去。切窗口这个动作看着不起眼一天下来几十次注意力被切得稀碎。我想要的是一块常亮的小屏幕摆在显示器旁边抬头就能看到所有关键状态。它得满足几个硬条件第一不占用电脑资源不能是又一个跑在主机上的 Electron 应用第二能连 Wi-Fi 自己拉数据也能通过蓝牙跟手机或电脑通信第三成本要低坏了不心疼第四全栈可控从固件到后端到前端我都能自己改。选 ESP32 几乎是必然的。这颗芯片把 Wi-Fi 和 BLE 集成在一颗 SoC 里双核 240MHz带一堆 GPIO、SPI、I2C价格却只要十几二十块。你要做一块联网的、带屏幕的、能跑自定义逻辑的桌面小设备ESP32 系列基本是绕不开的选项。Status Deck 这个名字是我自己起的Deck 取的是仪表盘/控制台的意思它不是一个成品产品而是一个我自己从零搭起来的全栈项目。这个系列我打算分几篇写第一篇先把整体架构、硬件选型、开发环境、以及最核心的数据怎么从电脑流到屏幕上这条链路讲清楚。后面再拆 BLE 通信、后端服务、前端渲染这些模块。适合谁看有 Arduino 或嵌入式基础、想做一个完整全栈小项目的开发者或者你只是想找块屏幕显示点自定义信息也能从里面挑需要的部分抄。先说清楚一件事Status Deck 不是买块屏刷个固件就完事的项目。它的价值在于全栈——设备端固件、通信协议、后端聚合服务、前端配置界面四层都得自己设计。这也是为什么我把它叫全栈自造而不是ESP32 显示天气。2. Status Deck 的四层架构拆解在动手写第一行代码之前我把整个系统拆成了四层。这个拆分不是拍脑袋而是根据数据从哪来、怎么传、怎么显示、怎么配置这四个问题倒推出来的。每一层解决一个明确的问题层与层之间用清晰的接口隔开这样任何一层出问题我都能单独替换。2.1 设备层ESP32 负责什么不负责什么设备层就是那块 ESP32 加屏幕。我给它定的职责边界非常克制只负责显示和通信不做任何业务逻辑。什么意思就是 ESP32 不负责去请求 GitHub API、不负责解析 JSON、不负责计算番茄钟剩余时间。它只做三件事接收已经格式化好的显示数据、把数据画到屏幕上、把用户的按键/触摸操作上报出去。为什么这么设计因为 ESP32 的 RAM 和 Flash 都有限跑复杂的 JSON 解析和 HTTPS 请求很容易内存碎片化跑几天就崩。把业务逻辑放到后端ESP32 端只维护一个轻量的显示缓冲区稳定性会好很多。我实测过让 ESP32 直接请求 API 并解析嵌套 JSON连续跑 12 小时左右就会出现内存分配失败重启才能恢复。改成后端推、设备画之后连续跑一周没出过问题。设备层内部再分三个模块显示驱动、通信模块、输入处理。显示驱动根据屏幕型号选SPI 屏用 TFT_eSPII2C 屏用 U8g2通信模块同时维护 Wi-Fi 和 BLE 两条链路输入处理负责按键或触摸事件。这三个模块之间通过一个共享的DisplayState结构体通信避免全局变量满天飞。2.2 通信层Wi-Fi 和 BLE 各管一段通信层是整个项目里我改动最多的地方。一开始我想只用 Wi-Fi后来发现有些场景 Wi-Fi 不方便——比如我想在没连路由器的环境下用手机临时改配置或者电脑和 ESP32 在同一个没有外网的内网里。这时候 BLE 就派上用场了。我的最终方案是双通道并存Wi-Fi 负责常态化的数据拉取设备主动去后端拉或者后端通过 WebSocket 推BLE 负责近距离的配置和调试。两条链路互不干扰Wi-Fi 断了 BLE 还能用BLE 连不上也不影响 Wi-Fi 显示。这里有个关键决策Wi-Fi 用拉模式还是推模式我两种都试过。拉模式是 ESP32 每隔 N 秒请求一次后端接口实现简单但实时性差而且请求频率高了费电、低了延迟大。推模式是后端通过 WebSocket 主动推实时性好但 ESP32 端要维护长连接断线重连逻辑复杂。最后我选了混合模式常规状态用拉模式30 秒一次紧急通知比如 CI 失败用 WebSocket 推。这样兼顾了稳定性和实时性。BLE 这边我用的是 BLE 的 GATT 服务模型。ESP32 作为外设Peripheral手机或电脑作为中心设备Central。我自定义了一个服务 UUID里面放了几个特征值一个用来写配置Wi-Fi SSID、密码、后端地址一个用来读设备状态一个用来触发 OTA 升级。BLE 的 MTU 默认是 23 字节传大块数据要分包这个坑后面细讲。2.3 后端层数据聚合与格式转换后端层跑在一台常开的机器上我用的是家里的迷你主机你也可以用树莓派或者云服务器。它的核心职责是聚合多个数据源转换成设备能直接渲染的格式。数据源包括GitHub Actions APICI 状态、本地服务健康检查接口、系统监控CPU/内存/温度、自定义的番茄钟服务、以及一些第三方 API天气、加密货币价格等。后端每隔一段时间去拉这些数据统一存到一个内存状态对象里然后根据设备订阅的卡片列表生成对应的显示指令。这里有个设计要点后端输出的是渲染指令不是原始数据。比如温度卡片后端输出的不是{temp: 26.5}而是{type: text, x: 10, y: 20, content: 26.5°C, color: #00FF00}。这样 ESP32 端只需要一个极简的渲染器把指令翻译成绘图调用不需要理解任何业务含义。这个设计让设备端固件变得非常薄也方便我以后换屏幕型号——只要改渲染器业务逻辑完全不用动。后端我用的是 Node.js Express选它不是因为性能而是因为生态成熟、写起来快、WebSocket 支持好。如果你更熟 Python用 FastAPI 也完全没问题架构是一样的。2.4 配置层前端界面与设备解耦配置层是一个跑在浏览器里的 Web 应用用来管理设备订阅哪些卡片、每个卡片显示什么、布局怎么排。它跟设备是解耦的——配置存在后端设备启动时从后端拉取自己的配置。为什么不做成设备上的触摸配置因为在小屏幕上做配置体验太差而且配置是低频操作没必要占用设备资源。用浏览器配置键盘鼠标齐全改起来舒服。配置层通过 WebSocket 跟后端通信改完即时生效设备下一次拉取就能看到新配置。这四层的关系可以这样理解配置层决定显示什么后端层负责把数据变成显示指令通信层负责把指令送到设备设备层负责把指令画出来。每一层职责单一接口清晰这是我能在后续不断加功能而不把项目搞崩的根本原因。3. 硬件选型屏幕、主控和供电的取舍硬件这块我踩的坑最多因为选型一旦定了后面改起来成本很高。这里把我对比过的方案和最终选择讲清楚你照着抄能少走弯路。3.1 主控为什么是 ESP32 而不是 ESP8266 或树莓派 Pico W先说结论ESP32我用的具体型号是 ESP32-WROOM-32后来也试了 ESP32-S3。对比过程如下。ESP8266 便宜但只有单核RAM 只有 80KB 左右跑 Wi-Fi 屏幕刷新 BLE8266 根本没有 BLE基本不可能。直接排除。树莓派 Pico W 有 Wi-Fi但没有 BLE而且它是 RP2040 双核 Cortex-M0跑网络协议栈比 ESP32 吃力。如果你只需要 Wi-Fi 不需要 BLEPico W 也能用但生态上 ESP32 的 Arduino 库和 ESP-IDF 明显更成熟。ESP32 的优势双核一个核专门跑网络协议栈一个核跑应用逻辑、520KB SRAM、集成 Wi-Fi 和 BLE、GPIO 丰富、社区资料海量。价格上一块 ESP32-WROOM-32 开发板十几块ESP32-S3 带更多 RAM 和 AI 指令也就二三十。我最终主力用ESP32-S3原因是它 RAM 更大512KB SRAM 可选 PSRAM跑屏幕刷新更从容而且支持 USB OTG调试方便。如果你手头只有经典 ESP32也完全能跑只是屏幕分辨率别选太高。3.2 屏幕SPI TFT 还是 I2C OLED屏幕我试过三种0.96 寸 I2C OLEDSSD1306、2.4 寸 SPI TFTILI9341、1.9 寸 SPI LCDST7789。I2C OLED 最省引脚两根线但分辨率只有 128x64显示不了多少信息而且单色。适合极简状态显示不适合做仪表盘。ILI9341 是 240x320彩色SPI 接口刷新率够用。缺点是引脚占用多至少 5 根SCK、MOSI、CS、DC、RST而且功耗比 OLED 高。ST7789 是 240x240 方形屏我最后选的就是它。方形布局适合做卡片网格240x240 能放下 2x2 或 3x3 的卡片。SPI 接口刷新率实测能到 40fps 以上足够。选屏幕时有个参数必须注意驱动芯片型号。同样是 1.9 寸有的用 ST7789有的用 ST7789V初始化序列略有不同。买之前一定确认驱动芯片然后在 TFT_eSPI 的User_Setup.h里选对驱动。我第一次买的一块屏卖家标ST7789实际是 ST7789V初始化参数不对屏幕花屏折腾了半天才定位到。3.3 供电与外壳别小看这两个细节供电我用的是 USB Type-C 直接供电5V/1A 足够。如果你想做成无线摆件可以用 18650 电池 TP4056 充电模块但要注意 ESP32 的 Wi-Fi 峰值电流能到 500mA电池放电能力要够。外壳我是 3D 打印的设计时留了两个关键开口一个是屏幕排线的走线槽一个是散热孔。ESP32 跑 Wi-Fi 时芯片会发热密闭外壳里温度能到 50 度以上加散热孔后降到 40 度左右。如果你不打印外壳用亚克力板加铜柱也行重点是别把芯片完全闷住。还有一个容易忽略的点屏幕背光。ST7789 的背光 LED 直接接 3.3V 会很亮晚上刺眼。我在背光电路里串了一个 100 欧姆的可调电阻或者你也可以用 PWM 控制背光亮度做成根据环境光自动调节。这个细节对桌面常亮场景很重要太亮会干扰工作。4. 开发环境搭建从零到点亮第一块屏环境搭建这块网上教程很多但坑也不少。我把从零到点亮屏幕的完整流程和踩过的坑整理出来你按这个走能省下至少半天。4.1 Arduino IDE 还是 PlatformIO两个我都用过最后长期用PlatformIO。原因依赖管理清晰platformio.ini里声明库版本不会出现昨天还能编译今天就不行的情况、支持多环境配置同一份代码编译不同板子、调试体验好。Arduino IDE 的优势是上手快但库版本冲突是老大难问题。如果你只是点个灯Arduino IDE 够了但 Status Deck 这种要引十几个库的项目PlatformIO 的依赖锁定能救命。PlatformIO 的安装VS Code 装 PlatformIO 插件然后新建项目选 ESP32-S3 或 ESP32 Dev Module。platformio.ini里我这样配[env:esp32s3] platform espressif32 board esp32-s3-devkitc-1 framework arduino monitor_speed 115200 upload_speed 921600 lib_deps bodmer/TFT_eSPI^2.5.0 bblanchon/ArduinoJson^7.0.0 links2004/WebSockets^2.4.0 build_flags -DBOARD_HAS_PSRAM -mfix-esp32-psram-cache-issueupload_speed设 921600 能显著加快烧录速度默认的 115200 烧一次要等半天。build_flags里的 PSRAM 相关参数是 ESP32-S3 专用的如果你用经典 ESP32 要去掉。4.2 TFT_eSPI 的配置最容易翻车的地方TFT_eSPI 这个库功能强但配置全靠改库文件里的User_Setup.h这是新手最容易翻车的地方。正确做法是不要直接改库文件而是在项目里建一个User_Setup.h通过build_flags指定build_flags -DUSER_SETUP_LOADED1 -include User_Setup.h然后在项目根目录的User_Setup.h里定义驱动和引脚#define ST7789_DRIVER #define TFT_WIDTH 240 #define TFT_HEIGHT 240 #define TFT_MOSI 11 #define TFT_SCLK 12 #define TFT_CS 10 #define TFT_DC 9 #define TFT_RST 8 #define TFT_BL 7 #define LOAD_GLCD #define SPI_FREQUENCY 40000000SPI_FREQUENCY设 40MHz 是 ST7789 的稳定上限设太高会花屏。引脚号根据你的实际接线改别照抄。我第一次配置时犯的错把TFT_MOSI和TFT_SCLK接反了屏幕全白。排查了半天才发现是接线问题。所以点亮屏幕前先用万用表确认每根线通断比对着代码猜快得多。4.3 第一个可运行固件显示静态文字环境配好后先跑一个最小固件验证屏幕#include TFT_eSPI.h TFT_eSPI tft TFT_eSPI(); void setup() { tft.init(); tft.setRotation(0); tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextSize(2); tft.setCursor(20, 100); tft.println(Status Deck); tft.setCursor(20, 130); tft.setTextSize(1); tft.println(Booting...); } void loop() { delay(1000); }烧录后屏幕应该显示绿色文字。如果花屏检查SPI_FREQUENCY如果全白检查背光和复位引脚如果全黑检查TFT_BL有没有拉高。这三个症状对应三类问题按这个顺序排查效率最高。4.4 串口调试与日志别用 Serial.print 硬扛调试 ESP32 时Serial.print是最原始的手段但项目复杂后日志会淹没在串口输出里。我的做法是分级日志定义一个LOG_LEVEL宏只输出当前级别以上的日志。生产环境设成 WARN调试时设成 DEBUG。另外ESP32 的串口输出和烧录共用 USB烧录时串口会断开。如果你在loop里疯狂Serial.print烧录会失败。解决办法是烧录前把日志级别调高或者用Serial.setDebugOutput(false)关掉底层调试输出。还有一个技巧用 BLE 做无线日志。设备装进外壳后USB 口可能被挡住这时候通过 BLE 把日志发到手机上看比拆外壳插线方便得多。这个功能我在通信层里实现了后面讲 BLE 时细说。5. 数据链路打通从后端到屏幕的第一条指令环境搭好、屏幕点亮之后下一步是打通后端生成指令 → 设备渲染这条链路。这是整个项目最核心的部分也是最能体现全栈设计价值的地方。5.1 定义渲染指令协议协议设计我遵循一个原则设备端零业务逻辑。后端发什么设备画什么。协议用 JSON结构如下{ version: 1, cards: [ { id: ci_status, type: text, x: 10, y: 10, content: CI: PASS, color: #00FF00, size: 2 }, { id: temp, type: text, x: 10, y: 40, content: 26.5C, color: #FFFFFF, size: 2 } ] }type目前支持text、rect、progress、icon四种。x/y是像素坐标color是十六进制颜色size是字号倍数。这个协议足够简单ESP32 端用 ArduinoJson 解析后直接调 TFT_eSPI 的绘图函数。为什么不用更紧凑的二进制协议因为 JSON 可读性好调试时用串口打印出来一眼就能看懂而且 ArduinoJson 解析 1KB 以内的 JSON 在 ESP32 上只要几毫秒性能完全够。等以后数据量大了再考虑二进制优化。5.2 后端生成指令的服务实现后端我用 Node.js 写了一个/api/render接口返回上面那个 JSON。核心逻辑是遍历设备订阅的卡片配置逐个生成指令function buildRenderPayload(deviceId) { const config getDeviceConfig(deviceId); const cards []; for (const card of config.cards) { const data dataStore.get(card.dataSource); cards.push(renderCard(card, data)); } return { version: 1, cards }; } function renderCard(card, data) { if (card.type text) { return { id: card.id, type: text, x: card.x, y: card.y, content: formatText(card.template, data), color: card.color || #FFFFFF, size: card.size || 1 }; } // ... 其他类型 }formatText用模板字符串把数据填进模板比如CI: {{status}}加上{status: PASS}变成CI: PASS。这样配置层只需要改模板不用改代码。5.3 设备端拉取与渲染循环设备端的loop里维护一个定时器每 30 秒拉一次/api/renderunsigned long lastFetch 0; const unsigned long FETCH_INTERVAL 30000; void loop() { if (millis() - lastFetch FETCH_INTERVAL) { lastFetch millis(); fetchAndRender(); } // 处理 BLE 事件 bleHandler.poll(); } void fetchAndRender() { HTTPClient http; http.begin(String(backendUrl) /api/render?device deviceId); int code http.GET(); if (code 200) { String payload http.getString(); renderPayload(payload); } http.end(); }renderPayload用 ArduinoJson 解析后先清屏再逐条画卡片。这里有个优化点不要每次都全屏清空重画那样会闪。我的做法是维护一个上一帧的卡片列表只重画内容变化的卡片。实测下来局部刷新比全屏刷新视觉上舒服很多而且省电。5.4 第一次联调的三个典型问题第一次把后端和设备连起来我遇到了三个问题估计你也会遇到。问题一设备连不上后端。排查顺序先确认设备 Wi-Fi 连上了串口打印 IP再确认后端地址和端口对用电脑浏览器访问一下最后确认防火墙没拦。我卡在最后一步——后端跑在迷你主机上主机的防火墙默认拦了 3000 端口加规则后就好了。问题二JSON 解析失败。ArduinoJson 对 JSON 格式很敏感多一个逗号就解析失败。我的做法是后端返回前先JSON.stringify校验一遍设备端解析失败时把原始 payload 打印出来对比。另外注意 ArduinoJson 7.x 的 API 跟 6.x 有变化DynamicJsonDocument在 7.x 里改成了JsonDocument别照抄老教程。问题三中文乱码。如果卡片内容有中文TFT_eSPI 默认字体不支持会显示方块。解决办法是加载中文字库比如efontCN或者干脆用英文和图标。我选了后者因为中文字库占 Flash 空间大而且状态信息用英文缩写更简洁。6. 踩坑记录那些让我熬夜的瞬间做这个项目的过程里有几个坑让我印象特别深写出来给你避雷。6.1 内存泄漏跑 12 小时就崩的元凶前面提过我一开始让 ESP32 直接请求 API 并解析 JSON跑 12 小时左右就崩。用ESP.getFreeHeap()打印剩余内存发现内存持续下降典型的泄漏。定位过程先怀疑是 HTTPClient 没end()加上后还是漏再怀疑是 String 拼接改成char[]后漏得慢了但还是漏最后发现是 WebSocket 库在断线重连时没有释放旧的连接对象。换成拉模式为主、WebSocket 只做通知的混合方案后内存稳定在 200KB 左右不再下降。教训ESP32 上任何动态分配都要警惕尤其是网络库。能用栈上数组就别用new能用固定大小缓冲就别用String。6.2 BLE 的 MTU 陷阱为什么传大配置会断BLE 默认 MTU 是 23 字节实际可用的 payload 只有 20 字节。我一开始想通过 BLE 传一个 200 字节的配置 JSON结果每次传到一半就断连。解决办法有两个一是协商更大的 MTUESP32 端调esp_ble_gatt_set_local_mtu(512)手机端也请求大 MTU协商成功后能到 512 字节二是分包传输把大 JSON 切成 20 字节的小块每块带一个序号接收端拼起来。我两个都用了先协商 MTU 到 512超过 512 的配置再分包。这里有个坑不是所有手机都支持大 MTUiOS 和 Android 的行为还不一样。所以分包逻辑必须保留不能只靠 MTU 协商。6.3 屏幕刷新撕裂SPI 速度和 DMA 的权衡屏幕刷新时如果 SPI 速度太快会出现画面撕裂上半屏是新内容下半屏是旧内容。原因是 SPI 传输和绘图不同步。解决办法是用DMA 传输。TFT_eSPI 支持 DMA在User_Setup.h里定义USE_DMA就行。但 DMA 会占用内存ESP32-S3 有 PSRAM 还好经典 ESP32 内存紧张时要权衡。我的做法是静态内容用普通 SPI动态刷新的区域用 DMA兼顾内存和流畅度。6.4 烧录失败那些莫名其妙的报错烧录失败的原因五花八门我整理了一个排查表报错信息可能原因解决办法Failed to connect串口被占用 / 驱动没装关掉串口监视器装 CP210x 或 CH340 驱动Timed out waiting for packet header没进下载模式按住 BOOT 键再按 RST或检查自动下载电路A fatal error occurred: Invalid head of packetUSB 线质量差换一根数据线别用只能充电的线烧录成功但不运行Flash 模式不对platformio.ini里设board_build.flash_mode dio最后一条我踩过ESP32-S3 默认 QIO 模式但有些板子只支持 DIO设错了烧录成功但启动就崩。改成 DIO 后正常。7. 下一步Status Deck 还能往哪走第一篇先到这里核心链路已经跑通了后端生成渲染指令设备拉取并显示。接下来我打算做几件事。一是把 BLE 配置通道做完整包括 Wi-Fi 配网、后端地址设置、OTA 升级。BLE 这块的细节比 Wi-Fi 多值得单独一篇。二是卡片系统做成插件化每种数据源一个插件配置层可以自由组合。这样加新数据源不用改核心代码。三是低功耗优化让设备在没人看的时候降频或熄屏有人靠近时通过 PIR 传感器唤醒。ESP32-S3 支持深度睡眠配合 RTC 定时唤醒理论上能用电池跑几天。四是多设备同步家里放两块 Status Deck一块在书房一块在客厅配置和后端共享设备各自拉取。这个改动不大主要是设备 ID 管理。如果你跟着做到了这一步屏幕应该已经能显示后端推过来的状态了。这个项目最大的乐趣在于每一层你都能按自己的想法改——想换屏幕就改渲染器想加数据源就写插件想换通信方式就改通信层。全栈自造的意义就在这没有黑盒每一行代码都是自己的。
阅读完成 · 觉得有帮助?
咨询建站