1. 从一块屏幕说起:Status Deck 到底要做什么
Status Deck 这个概念,最早是我在折腾桌面信息终端时冒出来的想法。简单说,它是一块放在桌角的常驻小屏,实时显示我关心的几类状态:构建流水线是否通过、服务器负载、待办事项数量、天气、甚至咖啡机有没有开。它不追求炫酷的动画,核心诉求只有三个:常亮、低功耗、信息一眼可读。而“全栈自造”这四个字,意味着从硬件选型、固件开发、通信协议到后端服务、前端渲染,整条链路都得自己打通。
为什么第二篇才聊技术栈选型?因为第一篇我踩了个大坑——先买了一堆模块,结果发现通信方案选错了,导致后面全部返工。所以这次我把顺序倒过来:先把技术栈定死,再动手买硬件。这篇文章就是把我最终敲定的方案、选型背后的权衡、以及实际实施过程中遇到的坑,完整地摊开讲一遍。适合谁看?如果你正在做类似的桌面信息终端、智能家居中控、或者任何需要“低功耗无线通信 + 小屏渲染 + 后端聚合”的项目,这篇内容可以直接抄作业。哪怕你只是对 ESP32-S3 和 BLE 感兴趣,里面的参数计算和排查思路也能用得上。
核心关键词我先埋在这里:全栈、Status Deck、技术栈选型、ESP32-S3、BLE。后面每一节都会围绕它们展开,不跑题。
2. 技术栈选型的整体思路与取舍逻辑
2.1 为什么把“通信协议”放在选型第一位
很多人做这类项目,习惯先挑主控芯片,再看屏幕,最后才想通信。我第一版就是这么干的,结果买了一块很漂亮的 2.4 寸 SPI 屏,主控选了某款不带无线功能的芯片,最后只能外挂一个蓝牙模块,走 AT 指令,延迟高得离谱,状态更新要等两三秒。那种体验放在桌面上,基本等于废了。
所以第二版我定了一个原则:通信协议决定主控选型,主控选型决定屏幕和外设。Status Deck 的数据源在后端,屏幕在前端,中间必须有一条稳定、低延迟、低功耗的无线链路。候选方案无非三种:WiFi、BLE、以及私有 2.4G。WiFi 的优点是带宽大、协议成熟,缺点是功耗高、配网麻烦,而且桌面上长期插电虽然可以接受,但我想保留电池供电的可能性。私有 2.4G 需要自己写协议栈,开发成本太高。最终BLE胜出,理由有三条:第一,ESP32-S3 原生支持 BLE 5.0,不需要外挂模块;第二,BLE 的连接间隔可以调到几十毫秒,对于状态刷新这种小数据量场景完全够用;第三,手机端也能直接连上来做配置,省掉一个配网按钮。
这里有个关键参数需要算一下。Status Deck 每次刷新的数据量大概在 200 字节以内,包括几个状态字段和时间戳。BLE 默认的 ATT MTU 是 23 字节,实际可用载荷 20 字节。如果不开 MTU 协商,200 字节要分 10 包发,按 30ms 连接间隔算,理论延迟 300ms 左右。开启 MTU 协商后,ESP32-S3 可以支持到 517 字节,一包就发完了。所以我在固件里强制开启了 MTU 交换,实际测下来端到端延迟稳定在 80ms 以内,肉眼完全无感。
2.2 ESP32-S3 凭什么成为最终选择
主控这块我对比过 ESP32、ESP32-C3、ESP32-S3 和 STM32WB。STM32WB 的 BLE 协议栈很稳,但开发环境对新手不友好,而且价格偏高。ESP32-C3 是 RISC-V 单核,跑 BLE 没问题,但内存偏小,如果后面想加 LVGL 做复杂 UI 会吃力。ESP32 经典款双核但蓝牙是 4.2,MTU 支持不如 S3。ESP32-S3的优势在于:双核 LX7、512KB SRAM、支持 BLE 5.0、自带 USB OTG、而且乐鑫的 ESP-IDF 对 BLE 的封装已经相当成熟。
我最终选的型号是ESP32-S3-WROOM-1-N16R8,16MB Flash + 8MB PSRAM。为什么需要这么大?因为我要在设备端做一层缓存,把最近 24 小时的状态数据存下来,断网时屏幕还能显示历史趋势。8MB PSRAM 用来跑 LVGL 的双缓冲,刷新率能稳在 30fps 以上。如果你只是做纯文本状态显示,N8R2 就够,能省十几块钱。但我的经验是,PSRAM 这东西,买的时候觉得多余,用的时候永远不够。
2.3 屏幕与交互的选型细节
屏幕我试过三种:SPI IPS、I2C OLED、以及并口 RGB。I2C OLED 最省引脚,但刷新率上不去,而且尺寸小,放桌面上看不清。并口 RGB 刷新快,但占用引脚太多,ESP32-S3 的 GPIO 虽然够,但布线复杂度陡增。最终选了2.4 寸 SPI IPS,240x320 分辨率,ST7789V 驱动。SPI 时钟跑到 80MHz,全屏刷新一帧大概 15ms,配合 LVGL 的部分刷新机制,实际只刷变化区域,功耗和速度都平衡得不错。
交互方面我没做触摸,原因很简单:Status Deck 是“看”的设备,不是“摸”的设备。加触摸屏会让成本翻倍,而且桌面场景下触摸使用频率极低。我留了一个物理按键做配网重置,其余配置全部通过 BLE 从手机端下发。这个取舍在后面实施时省了大量 UI 调试时间。
3. 核心细节解析与实操要点
3.1 BLE 服务与特征值的规划
BLE 通信的核心是 GATT 服务。我把 Status Deck 的数据拆成三个特征值:状态数据特征(只读 + 通知)、配置特征(读写)、时间同步特征(写)。状态数据特征用 Notify 方式主动推送给订阅端,这样后端一有更新,设备立刻收到,不需要轮询。配置特征用来接收屏幕亮度、刷新间隔、WiFi 备份凭证等参数。时间同步特征用于校准设备 RTC,保证时间戳准确。
这里有个坑要注意:BLE 的 Notify 需要客户端先写 CCCD(客户端特征配置描述符)开启。很多新手调试时发现设备发了数据但手机收不到,八成是忘了开 CCCD。我在固件里加了一个判断,如果 5 秒内没有客户端订阅,就自动切换到广播模式,降低功耗。这个逻辑用 ESP-IDF 的esp_ble_gatts_send_indicate配合连接状态回调就能实现。
UUID 我建议不要用随机生成的,而是按规范自定义。比如基础 UUID 设为0000xxxx-0000-1000-8000-00805F9B34FB,xxxx 部分自己分配。这样在调试工具里一眼就能认出哪个是 Status Deck 的服务,不会和周围一堆蓝牙设备混淆。
3.2 数据协议的设计与序列化
BLE 传数据不能直接扔 JSON,太占带宽。我设计了一个紧凑的二进制协议:前 2 字节是消息类型,接着 2 字节是 payload 长度,后面跟实际数据。状态数据里,每个字段用固定长度表示,比如构建状态用 1 字节(0 失败、1 成功、2 进行中),待办数量用 2 字节无符号整数,温度用 2 字节有符号整数(放大 10 倍存储)。这样 200 字节能塞下十几个状态字段,还有余量。
序列化我一开始想用 Protobuf,但 ESP-IDF 里集成 Protobuf 需要额外编译 nanopb,增加固件体积。后来改用手写打包/解包函数,代码量不到 100 行,反而更可控。实测下来,打包 200 字节数据耗时不到 1ms,解包同样。对于这种固定结构的数据,手写比通用序列化库更高效。
注意:二进制协议一定要写好版本号字段。我在消息头里留了 1 字节版本号,后面协议升级时,设备端可以根据版本号决定用哪种解析方式,避免旧固件直接崩溃。
3.3 电源管理与低功耗策略
虽然 Status Deck 大部分时间插电,但我还是做了低功耗设计,万一哪天想改成电池供电呢。ESP32-S3 支持多种睡眠模式,我选的是Light Sleep + BLE 广播间隔动态调整。具体策略是:屏幕常亮时,CPU 降频到 80MHz,BLE 连接间隔 30ms;屏幕关闭(夜间模式)时,CPU 进入 Light Sleep,BLE 广播间隔拉长到 1s,靠 GPIO 唤醒。
实测功耗数据:屏幕常亮 + BLE 连接状态下,整机电流约 120mA;屏幕关闭 + Light Sleep 状态下,电流降到 8mA 左右。如果用 2000mAh 电池,夜间模式能撑 10 天。这个数据不算极致,但对于桌面设备已经够用。如果你追求更低功耗,可以把屏幕换成 Memory LCD,但那又是另一个成本故事了。
4. 实操过程与核心环节实现
4.1 开发环境搭建与固件框架
我用的开发环境是ESP-IDF v5.1 + VS Code。为什么不选 Arduino?因为 ESP-IDF 对 BLE 的控制更底层,能精细调整连接参数和 MTU。Arduino 的 BLE 库虽然简单,但隐藏了很多细节,出问题时不好排查。安装步骤这里不展开,官方文档很全,我只说两个容易卡住的点:第一,Python 依赖一定要用 IDF 自带的安装脚本装,不要用系统 Python,否则编译时会报模块找不到;第二,Windows 下串口驱动要装对,CH340 和 CP2102 的驱动不一样,装错了设备管理器里能看到端口但烧录失败。
固件框架我分成四层:硬件抽象层(屏幕、按键、电源)、BLE 协议层(GATT 服务、数据打包)、业务逻辑层(状态缓存、刷新调度)、应用层(LVGL UI)。层与层之间用回调函数和消息队列通信,避免阻塞。比如 BLE 收到数据后,不直接刷屏,而是往队列里扔一条消息,UI 任务从队列取数据再刷新。这样即使 BLE 处理慢,也不会卡住屏幕。
4.2 BLE 服务端代码的关键片段
下面是我实际用的 BLE 初始化和广播代码,基于 ESP-IDF 的 GATT 服务端示例修改。注意看 MTU 设置和广播参数的部分。
// BLE 初始化与 MTU 设置 esp_ble_gatts_register_callback(gatts_event_handler); esp_ble_gap_register_callback(gap_event_handler); esp_ble_gatts_app_register(APP_ID); // 设置本地 MTU 为 517 字节 esp_ble_gatt_set_local_mtu(517); // 广播参数:间隔 100ms,快速广播 30 秒后转慢速 esp_ble_adv_params_t adv_params = { .adv_int_min = 0x00A0, // 100ms .adv_int_max = 0x00A0, .adv_type = ADV_TYPE_IND, .own_addr_type = BLE_ADDR_TYPE_PUBLIC, .channel_map = ADV_CHNL_ALL, .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, }; esp_ble_gap_start_advertising(&adv_params);这段代码里,esp_ble_gatt_set_local_mtu(517)是提升吞吐的关键。如果不设,默认 MTU 是 23,大数据包会被拆成很多片,延迟明显。广播间隔 100ms 是平衡功耗和连接速度的结果,太快费电,太慢手机搜不到。
4.3 后端数据聚合与推送链路
Status Deck 的数据来源很杂:GitHub Actions 的构建状态、服务器上的 Node Exporter 指标、Todoist 的待办数量。我在一台常开的迷你主机上跑了一个Python FastAPI 服务,定时拉取这些数据,聚合成一个状态对象,然后通过 BLE 推送给设备。为什么不用 MQTT?因为 MQTT 需要设备端也跑客户端,增加复杂度和功耗。BLE 直连更简单,而且手机也能同时连上看数据。
推送逻辑是这样的:FastAPI 服务每 30 秒检查一次数据是否有变化,有变化就通过bleak库(Python 的 BLE 客户端库)连接设备,写入状态特征值。这里有个细节:BLE 连接不能太频繁,否则设备端射频一直处于活跃状态,功耗飙升。我的做法是,如果连续 5 次检查数据都没变,就把检查间隔拉长到 5 分钟。实测下来,一天内实际 BLE 连接次数不到 200 次,对设备毫无压力。
4.4 屏幕 UI 的 LVGL 实现要点
LVGL 的移植网上教程很多,我说几个实际踩过的坑。第一,SPI 缓冲区大小要匹配 PSRAM。我一开始用 240x320 的全屏缓冲,占 150KB,ESP32-S3 的 SRAM 不够,编译直接报错。后来改成 1/10 屏缓冲,配合 LVGL 的lv_disp_flush_ready回调,刷新率反而更高,因为 DMA 传输和渲染可以并行。第二,中文字体要裁剪。LVGL 默认字体不含中文,如果直接加载全量中文字库,Flash 瞬间爆满。我用 LVGL 的字体转换工具,只提取了状态显示用到的 200 多个汉字,生成的字体文件不到 50KB。
UI 布局我采用三栏式:左侧是构建状态和服务器负载,中间是待办列表,右侧是时间和天气。每个区域用独立的 LVGL 对象,数据更新时只刷新对应对象的文本,不重绘整个屏幕。这样即使 30 秒刷新一次,肉眼也看不到闪烁。
5. 常见问题与排查技巧实录
5.1 BLE 连接不稳定,频繁断开
这是我最开始遇到的头号问题。设备跑几个小时就断连,手机端显示“连接超时”。排查思路分三步:第一,看电源。ESP32-S3 在 BLE 发射瞬间电流会冲到 200mA 以上,如果供电不足,电压跌落会导致重启。我用示波器抓过,发现 USB 线太长导致压降,换了一根短粗线就稳了。第二,看连接参数。手机端默认的连接间隔可能和设备端不匹配,我在固件里强制设置了esp_ble_gap_set_prefer_conn_params,把最小间隔设为 24(30ms),最大间隔设为 40(50ms),超时设为 400(4s)。第三,看周围干扰。2.4G 频段拥挤,WiFi 路由器、微波炉都会干扰 BLE。我把设备的广播信道固定在 37、38、39 三个信道轮流,避开拥挤的 WiFi 信道。
5.2 数据推送延迟高,屏幕更新慢
延迟问题通常出在三个环节:数据打包、BLE 传输、屏幕刷新。我逐段测过耗时:打包 0.5ms,BLE 传输 60-80ms,屏幕刷新 15ms。加起来不到 100ms,但实际感觉有 1-2 秒延迟。后来发现是 FastAPI 服务的定时任务用了time.sleep(30),导致数据变化后最多要等 30 秒才推送。改成asyncio的事件驱动后,数据一变就推,延迟降到 100ms 以内。所以排查延迟时,先看数据源到服务端的链路,再看服务端到设备的链路,别一上来就怀疑 BLE。
5.3 屏幕花屏或局部残影
SPI 屏幕花屏,九成是时序问题。ST7789V 的 SPI 时钟我一开始设到 80MHz,结果偶尔花屏。降到 40MHz 后稳定,但刷新率下降。后来发现是 PCB 走线太长,SPI 信号完整性不好。解决办法是在 SPI 时钟线上串一个 33 欧姆电阻,抑制振铃。残影问题则是 LVGL 的双缓冲没开,或者缓冲区的内存没对齐。ESP32-S3 的 PSRAM 访问有对齐要求,缓冲区地址必须 4 字节对齐,否则 DMA 传输会出错。用heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)分配内存就能保证对齐。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| BLE 搜不到设备 | 广播未启动或天线匹配差 | 用手机蓝牙调试 App 扫描 | 检查esp_ble_gap_start_advertising返回值,确认天线焊接 |
| 连接后立即断开 | MTU 协商失败或供电不足 | 看串口日志有无ESP_GATT_MTU报错 | 降低 MTU 到 247,换短 USB 线 |
| 数据写入无响应 | CCCD 未开启或特征权限错 | 用 nRF Connect 查看特征属性 | 确认特征属性包含WRITE或WRITE_NR |
| 屏幕白屏 | 背光或初始化序列错 | 测量背光引脚电压 | 检查 ST7789V 初始化命令,确认复位时序 |
| 设备频繁重启 | 看门狗超时或电源跌落 | 读重启原因寄存器 | 增加任务喂狗,加大电源滤波电容 |
这张表是我实际调试时整理的,每次遇到新问题就加一行。建议你也建一个自己的排查表,比翻论坛快得多。
6. 项目实施中的经验与后续扩展方向
整个 Status Deck 从选型到跑通,前后花了大概三周业余时间。其中 BLE 调试占了一半,屏幕和 UI 占了三成,后端反而最快。如果让我重新来一遍,我会在买硬件之前先用两块开发板把 BLE 通信跑通,确认延迟和稳定性达标,再画 PCB。这个顺序能省掉大量返工。
后续我打算加两个功能:一是BLE Mesh,让多个 Status Deck 组网,比如客厅一个、书房一个,数据同步走 Mesh 广播,不用每个都连后端。ESP32-S3 支持 BLE Mesh,但协议栈比 GATT 复杂不少,需要重新设计消息模型。二是本地语音播报,用 ESP32-S3 的 I2S 接一个小喇叭,状态变化时播报“构建成功”或“待办已清空”。这个对内存占用有要求,得把 PSRAM 用起来。
最后分享一个我在调试 BLE 时的小技巧:用 Wireshark 抓 BLE 空口包时,如果只想看指定设备的流量,可以在捕获过滤器里写btle.advertising_address == xx:xx:xx:xx:xx:xx,这样抓到的包全是目标设备的,分析起来清爽很多。这个过滤器我用了无数次,比在几百个广播包里翻找高效得多。