青萍蓝牙温湿度计(Cleargrass Bluetooth Thermometer)凭借其极简的圆形表盘、7 段数码管字体,是一款非常吸睛的蓝牙温湿度计。
M5Stack 的 M5Paper 拥有 4.7 英寸 960×540 电子墨水屏,16 级灰度、540×960 的分辨率,再加上板载 SHT30 温湿度传感器和 AXP192 电源管理,简直就是为这类“静态信息仪表”量身定做的硬件平台。
本文将完整记录我在 M5Paper 上复刻青萍蓝牙温湿度计 V40 UI 的全过程,包括:
参考对象分析:青萍蓝牙温湿度计的 UI 拆解;
设计规划:当圆形表盘遇见方形屏幕,如何用“上下云纹 + 中间圆环”的设计语言化解比例冲突;
Python 像素级模拟:V1 → V40 的迭代历程,如何在桌面上就把界面“磨”到和实体屏完全一致;
M5Paper 真机适配:从 .ttf 字体陷阱、栈溢出崩溃,到使用内置 Font7 7 段数码管字体,再到坐标硬编码的实战细节。
项目仓库:https://github.com/HonestQiao/m5paper-weather(ESP-IDF 工程)
功能定位:本 ESP-IDF 版本为纯本地显示版本,包含 V40 UI 绘制、SHT30 传感器读取和 AXP192 电量显示。
一、参考对象:青萍蓝牙温湿度计
青萍蓝牙温湿度计的界面非常克制,核心元素只有五组:
圆形灰度圆环:外径约占据屏幕宽度的 96%,内径与外径之间形成一个浅灰色呼吸环,视觉焦点极强;
温度大区:整数部分使用 7 段数码管字体,尺寸最大;右侧带一位小数、手绘风格的 °C;
湿度大区:同样使用 7 段数码管字体,整数两位,右侧一位小数、 % 符号;
顶部状态栏:左侧蓝牙图标(未连接时仅显示本体,已连接时追加信号弧线),右侧电池图标(仅图形,无百分比数字);

在动手写 C++ 之前,我坚持先用 Python + PIL 在桌面上生成和最终墨水屏“完全一致”的模拟图。只有模拟图和脑中目标 100% 对齐后,才进入硬件代码阶段——这在后续规避了大量反复烧录调参的成本。
二、设计规划:当圆形表盘遇见方形屏幕2.1 青萍的圆形之美
青萍蓝牙温湿度计的核心魅力在于它的圆形硬件形态。所有信息密度都集中在圆心:温度数字沿上半圆弧分布,湿度数字沿下半圆弧分布,外围的浅灰色呼吸环既是装饰,也是天然的边界。整个界面没有任何冗余,圆形本身就是它的设计语言。
2.2 M5Paper 的方形画布而 M5Paper 是一块 540×960 的竖屏矩形。如果直接把青萍的圆形 UI 居中缩小放置,上下会留下巨大的空白区域,视觉重心会被屏幕的长宽比例拉长,显得空洞且失衡。
2.3 设计语言:上下云纹,中间圆环为了化解这种“圆 vs 方”的比例冲突,我的设计策略是:保留青萍的灵魂区域(圆形表盘),用中国传统云龙纹填满四周的空白。
核心区域:一个占据屏幕宽度约 96% 的圆形灰度圆环,圆心与屏幕中心对齐((270, 480))。温度在上半圆,湿度在下半圆,以分隔线为界,完美复刻青萍的排版逻辑;
氛围填充:在圆环上方和下方,用多层椭圆弧与正弦波虚线绘制抽象的云龙纹,颜色在浅灰与中灰之间过渡。云纹既填补了方形屏幕上下多余的空间,又不过度抢夺中心圆环的视觉焦点;
状态栏置顶:蓝牙和电池图标被放置在圆环顶部内侧,保持在圆形核心区域内的可读性,同时进一步将视线引导回圆心。
这种“上下云纹、中间圆环”的设计,既向青萍的圆形表盘致敬,又充分利用了 M5Paper 竖屏的纵向空间,在方圆之间找到了一种视觉平衡。
三、Python 模拟界面的五段进化史
整个模拟过程共经历了 40 多个版本(V1 ~ V40)。其中最关键、最具代表性的五个阶段如下:
3.1 V1:骨架搭建——从圆环到分隔线最早的版本只解决“结构问题”:
确定圆环中心 (270, 480)、外径 260、内径 236;
分隔线 Y 539 把圆环精确切成上下两半;
上下云龙纹用椭圆弧和正弦波快速占位;
数值暂时使用系统默认无衬线字体,仅用于验证排版重心。
此时界面还非常朴素,但三大核心坐标(圆心、半径、分隔线)已经锁定,后续 39 个版本都是在这些骨架之上做“皮肉精雕”。

从 V10 开始,我把 digital-7.ttf 正式引入 PIL。这个字体是 7 段数码管风格,和青萍原版极为神似。
但很快发现两个“字体陷阱”:
digital-7.ttf 的 .(小数点)字形是一个扁黑方块,非常丑;
C 和 % 的字模质量也不佳(C 近乎空白,% 呈 /o 状)。
于是我在 Python 里做了第一次重大修正:弃用字体的 . C %,改为手绘。 PIL 仍用 digital-7.ttf 渲染数字,但小数点改用 Helvetica 圆点;°C 和 % 改成纯 PIL 图形绘制(椭圆 + 直线 + 圆点)。这奠定了后续 V40 的符号风格。
另外,在这个阶段我还顺手解决了“龙”的问题。原本我的设计意图是让上下各画一条龙(云龙纹嘛),但 PIL 的椭圆弧和正弦波叠加出来,怎么看都更像两条扭来扭去的虫。我盯着屏幕沉思了三秒钟,觉得……这虫挺简洁的,有种抽象的美,于是大度地把它保留下来的。

V19 阶段主要打磨状态栏。早期的蓝牙图标是简单的两根交叉线(类似字母 X),不够精致。于是我按照 Material Design 官方路径 用 12 个三角形拼出带镂空的标准蓝牙图标,并在未连接/已连接两种状态下分别渲染:
未连接:仅绘制蓝牙本体;
已连接:在本体右侧追加两道信号弧线。
电池图标也改为手绘空心矩形 + 内部动态填充条,宽度根据电量百分比实时计算,右侧加上 3×6 的“正极凸起”。

到了 V35,数字和图标的位置已经收敛到误差 3px 以内,我开始处理“氛围层”——上下云龙纹。
顶部云龙纹:5 组椭圆弧 + 420 点正弦波虚线,颜色在浅灰 CLR_GL 和中灰 CLR_GM 之间过渡;
底部云龙纹:3 组大比例椭圆 + 380 点正弦波,并在下方追加 3 层 {18, 32, 48}px 的扇形装饰弧;
圆环灰度从早期的 200 调整为 240,让圆环在纯白背景上更柔和。

V40 是最终确认的“完美版”。所有坐标和字号如下:
| 温度整数 | digital-7.ttf | 230pt(实际像素高约 151px) | 左起 (109, 311)、228, 311 |
| 温度小数 | digital-7.ttf | 80pt(实际高约 52px) | (369, 435);圆点手绘在 (352, 469) |
| 温度 °C | digital-7.ttf + 手绘椭圆 | 30pt(实际高约 20px) | (381, 369);° 为手绘椭圆 |
| 湿度整数 | digital-7.ttf | 145pt(实际高约 95px) | 左起 (165, 549)、242, 549 |
| 湿度小数 | digital-7.ttf | 50pt(实际高约 33px) | (337, 627);圆点手绘在 (326, 645) |
| 湿度 % | digital-7.ttf + 手绘 | 30pt(实际高约 20px) | (338, 574) |
| 蓝牙图标 | Material 路径手绘 | 高 26px | (222, 285) |
| 电池图标 | 手绘矩形+填充 | 28×12 | (318, 292) |
| 圆环 | 椭圆填充 | 外径 260 / 内径 236 | 中心 (270, 480),灰度 240 |
| 分隔线 | 横线 | 长度跨圆环 | Y = 539 |
V40 同时输出两种状态图:蓝牙未连接和已连接。


只有模拟图通过了 V40 的“终审”,我才开始写 ESP-IDF 的 C++ 代码。
四、M5Paper 真机适配:从 Python 到 C++4.1 屏幕驱动:M5Unified 的优雅方案
在 ESP-IDF v5.5 下驱动 M5Paper 的 IT8951 电子纸屏幕,最直接的方案是引入 M5Unified 库。只要在 main/CMakeLists.txt 里声明对 m5unified 的组件依赖,代码中一行 M5.begin(cfg) 就能完成包括 EPD 控制器、背光、电源、I2C 总线在内的全套初始化。
具体的集成踩坑与完整步骤,我之前写过一篇详细文章,直接参考即可:
在本项目中,真正用到屏幕刷新时只需要三板斧:startWrite() → 绘图 → endWrite() → display()。其中 display() 会触发 IT8951 的全局刷新,把显存内容推到墨水屏上。
auto& dsp = M5.Display; dsp.startWrite(); /* 在这里调用 fillEllipse、drawString、fillRect 等绘图API */ dsp.endWrite(); dsp.display(); // 触发 EPD 全刷4.2 温湿度读取:SHT30 极简驱动
M5Paper 板载了一颗 Sensirion SHT30,挂在内部 I2C 总线上,地址为 0x44。M5Unified 封装了 m5::In_I2C,可以直接进行读写,不需要额外依赖 Arduino 的 Wire 库。我实现了一个极简的 SHT30 类,发送单次测量命令 0x2C 0x06(高精度、时钟拉伸),等待 15ms 后读取 6 字节原始数据,再用 SHT3x 的出厂公式换算成温度和湿度:
class SHT30 {
uint8_t _addr = 0x44;public:
bool read(float &temp, float &humi) {
uint8_t cmd[2] = { 0x2C, 0x06 };
if (!m5::In_I2C.start(_addr, false, 100000)) return false;
if (!m5::In_I2C.write(cmd, 2)) { m5::In_I2C.stop(); return false; }
m5::In_I2C.stop();
vTaskDelay(pdMS_TO_TICKS(15));
uint8_t buf[6];
if (!m5::In_I2C.start(_addr, true, 100000)) return false;
if (!m5::In_I2C.read(buf, 6, false)) { m5::In_I2C.stop(); return false; }
m5::In_I2C.stop();
uint16_t t_raw = ((uint16_t)buf[0] << 8) | buf[1];
uint16_t h_raw = ((uint16_t)buf[3] << 8) | buf[4];
temp = -45.0f + 175.0f * ((float)t_raw / 65535.0f);
humi = 100.0f * ((float)h_raw / 65535.0f);
return true;
}};在主循环里,每次定时器触发时调用 sht30.read(g_temp, g_humi),如果返回 false 则打印一条警告日志。这里没有做 CRC-8 校验,因为本地展示 Demo 对数据可靠性要求不高,正式产品建议加上。
4.3 电量读取:AXP192 电源管理M5Paper 的电源管理 IC 是 AXP192,M5Unified 已经把它封装进了 M5.Power 对象。读取电池电量百分比只需要一行代码:
g_batt = M5.Power.getBatteryLevel();
返回值是一个 0~100 的整数,可以直接拿来驱动顶部的电池图标填充条。AXP192 还提供了充电状态、USB 插入检测、各路电源输出开关等能力,但在这个纯显示 Demo 里,有电量百分比就足够了。
4.4 UI 元素绘制:把代码一行行拆开看真机代码里的界面渲染全部集中在 update_screen() 函数中。它的整体刷新流程遵循 M5GFX 的标准范式:startWrite() → 批量绘图 → endWrite() → display() 触发 EPD 墨水屏全局刷新。下面把各个 UI 元素的绘制逻辑逐个拆开说明。
1. 云龙纹背景上下云龙纹分别由 drawCloudTop() 和 drawCloudBottom() 两个辅助函数完成。它们本质上是两组“椭圆弧 + 正弦波虚线”的组合:
顶部:5 组水平排列的椭圆弧,配合 420 个采样点的正弦波虚线,颜色在 CLR_GL(浅灰)和 CLR_GM(中灰)之间过渡;
底部:3 组大比例椭圆弧,配合 380 点正弦波,再追加 3 层扇形装饰弧,CLR_GD(深灰)作为点缀。
正弦波的参数直接照搬了 Python V40 模拟图的公式,保证了真机和预览图的一致性。
2. 中间圆环圆环是两次 fillEllipse 的套娃操作:先用灰度 CLR_RING 画一个实心大圆(半径 260),再用背景白色 CLR_BG 画一个同心小圆(半径 236)覆盖中心,自然就留下了一个宽度 24px 的圆环。最后再各描一遍黑边,让边界更硬:
dsp.fillEllipse(270, 480, 260, 260, CLR_RING); // 外圆(灰)dsp.fillEllipse(270, 480, 236, 236, CLR_BG); // 内圆(白),挖空dsp.drawEllipse(270, 480, 260, 260, CLR_BLK); // 外边框dsp.drawEllipse(270, 480, 236, 236, CLR_BLK); // 内边框3. 蓝牙与电池图标(纯手绘)
为了摆脱字体和位图依赖,状态栏图标全部采用基础图形 API 手绘:
蓝牙图标 drawBluetoothMaterial():按照 Material Design 官方路径,用 12 个 fillTriangle 拼出带镂空的蓝牙本体,再在两翼加两道 drawArc 弧线表示连接状态;
电池图标 drawBattery():一个 drawRect 空心矩形做外壳,内部按 g_batt 百分比计算填充宽度 fw,用 fillRect 画黑条,左侧再加一个 3×6 的凸起当正极。
drawBluetoothMaterial(dsp, 222, 298, 26, CLR_BLK, false);drawBattery(dsp, 318, 298, 28, 12, CLR_BLK, g_batt);4. 温度 / 湿度数值组合
温度和湿度的显示格式都是“整数部分 + 小数点 + 小数部分 + 单位符号”,但字号和间距不同。为了统一管理这种“组合排版”,我写了一个 drawValueGroup() 函数,核心逻辑是:
把字符串按 . 分割成 intpart 和 fracpart;
用 Font7 的大字号画整数部分,每个字符单独测量宽度后手动添加字间距;
手绘一个圆点当小数点;
用较小的 Font7 字号画小数部分;
根据 is_temp 布尔值,在数字右上方调用 drawSymbolC()(手绘 °C)或 drawSymbolPercent()(手绘 %)。
drawValueGroup(dsp, 270, 417, tbuf, &fonts::Font7, 3.0f, // 整数:Font7,scale 3.0 &fonts::Font7, 64.0f/48.0f, // 小数:Font7,scale 约 1.33 true, // is_temp 10, 6, 40, 5, 427, 25, // 各种间距和偏移微调参数 10, 42, 12, 12, 23);
湿度组合的逻辑完全一致,只是字号换成 2.0f 和 1.0f,坐标中心移到 (270, 617)。
5. 手绘温度与湿度符号°C 和 % 不再依赖任何字体,而是用最低级的 fillRect、drawLine、fillCircle 手绘而成:
drawSymbolC:画一个开口向右的 C 字形(左侧竖条 + 上下横条);
drawSymbolPercent:一根斜杠,上下各一个小圆点。
° 圆圈:额外在 C 的左上方用 drawEllipse 画一个小椭圆表示度数符号。
温度在上半圆,湿度在下半圆,中间用一条纯黑横线隔开。因为 EPD 墨水屏对 1px 细线容易发虚,所以用 2px 高的 fillRect 实心矩形代替 drawLine:
dsp.fillRect(55, 538, 430, 2, CLR_BLK);
4.5 踩坑实录
从 Python 模拟图走到真机 C++ 代码,我踩了以下三个大坑(以及无数小坑)。把它们集中记录下来,方便后来者避雷。
1. M5GFX 不支持 .ttf,VLW 字体陷阱我最初的方案是和 Python 保持一致:把 digital-7.ttf 放入 SPIFFS,在 setup() 里调用 M5.Display.loadFont("/spiffs/digital-7.ttf")。结果运行时字体加载失败,屏幕一片空白。查 m5gfx 源码才发现:loadFont() 只支持 Processing 的 .vlw 位图字体格式,.ttf 直接被静默忽略。
于是有了第一次补救:写了一个 Python 脚本 generate_vlw_fonts.py,把 digital-7.ttf 按 V40 的 5 个实际尺寸(151/95/52/33/20 px)预渲染成 .vlw。
但这里还有一个深藏坑:Processing 原生 VLW 的 glyph header 是 24 字节,而 LovyanGFX 的 VLWfont 实现却按 28 字节 读取。如果不补 4 字节 padding,所有字形的 metrics(宽、高、advance)会全部错位,导致屏幕上只有一团背景填充的“白色块块”,完全看不到数字笔画。

就在我以为 VLW 已经搞定、烧录成功后的下一秒,ESP32 开始无限重启。串口日志显示:Stack canary watchpoint triggered。追踪到 m5gfx/src/lgfx/v1/lgfx_fonts.cpp,发现大字号(151px, bitmap 109×151 ≈ 16KB)的 drawChar 内部使用了 alloca(w*h) 在栈上分配位图缓冲区。默认 main task 栈只有 8KB,直接爆满。
修复方案:把 lgfx_fonts.cpp 中两处 alloca 替换为 heap_alloc / heap_free,同时将 sdkconfig.defaults 中的 CONFIG_ESP_MAIN_TASK_STACK_SIZE 提升到 24576,CONFIG_ESP_TIMER_TASK_STACK_SIZE 也提升到 8192,彻底杜绝栈空间焦虑。
3. Font7 内置字体的坐标大迁徙与硬编码即使用上了 .vlw,M5Paper 墨水屏的显示效果和 Python PIL 仍有细微差异(灰度抗锯齿 vs 位图锯齿、EPD 刷新后的残影等)。更重要的是,已经确认 V40 的 Python 图完美,所以我决定放弃外部字体的不确定性,改用 M5GFX 内置的 7 段数码管字体 fonts::Font7。
温度整数:scale = 3.0f → 约 144px(接近 V40 的 151px)
湿度整数:scale = 2.0f → 约 96px(接近 V40 的 95px)
温度小数:scale ≈ 1.333f → 约 64px(最终版)
湿度小数:scale = 1.0f → 约 48px(接近 V40 的 33px,统一手感)
°C 和 % 也彻底改为纯手绘简单图形,不再依赖任何字体文件,从根本上消除了字体加载和字模质量的隐患。
由于 Font7 的字宽和 digital-7.ttf 并不完全一致(例如 1 比 8 窄很多),V40 的 Python 坐标不能直接照搬。我在实体 M5Paper 上进行了数轮“一像素一像素”的拉锯战:温度整数整体向下 20px、温度小数向右 3px 向上 3px、°C 图标向左 5px、湿度整数向下 20px、湿度小数点最终向上 5px、分隔线从 drawLine 改为 2px 宽的 fillRect 纯黑实心矩形防止 EPD 发虚……
最终,我把 update_screen() 中所有涉及计算表达式的坐标全部替换为计算后的绝对整数值。
注:本 ESP-IDF 版本不包含 BLE 功能。如果需要完整的 Dash IoT 蓝牙温湿度计功能(BLE 广播、App 连接、Time Graph 曲线),请参考后续移植的 Arduino 版本。**
五、工程结构与编译指令
m5paper-weather/ ├── CMakeLists.txt # 工程级 CMake,包含 SPIFFS 镜像打包 ├── sdkconfig.defaults # 16MB Flash、大栈空间等默认配置 ├── partition_table.csv # 自定义分区:factory 2M + spiffs 1M ├── main/ │ ├── CMakeLists.txt # main 组件依赖:m5unified、spiffs 等 │ └── main.cpp # 完整源码(含中文注释,已移除 NimBLE) └── spiffs/ # SPIFFS 资源目录(当前已清空外部字体)
编译指令(ESP-IDF 环境):
source ~/esp/env.sh idf.py set-target esp32 # 首次执行idf.py build idf.py -p /dev/ttyUSB0 flash
六、实拍效果

目前 ESP-IDF 版本已经在 M5Paper 真机上稳定运行,主要特性完成度:
V40 UI 像素级复刻(Python 预览 ↔ 墨水屏显示完全一致)
SHT30 温度/湿度读取 + AXP192 电量图标动态填充
1 分钟 esp_timer 自动刷新
坐标全部硬编码,拒绝漂移
未来可以探索的优化方向:
局部刷新:当前每次更新调用 display() 做全刷,若觉得闪屏明显,可深入研究 M5Paper EPD 的局部刷新策略;
RTC 休眠唤醒:结合 M5Paper 的低功耗优势,实现“每小时唤醒一次 + 关键阈值触发唤醒”的长续航模式;
历史数据曲线:在 SPIFFS 中缓存 24h 温湿度数据,在墨水屏上绘制迷你折线图;
我要赚赏金
