这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » 活动中心 » 板卡试用 » 【M5PAPERESP32EINKDEVKIT评测】在M5Paper上复刻青萍蓝

共1条 1/1 1 跳转至

【M5PAPERESP32EINKDEVKIT评测】在M5Paper上复刻青萍蓝牙温湿度计:从Python模拟到ESP-IDF实战

助工
2026-04-12 23:45:35     打赏
在 M5Paper 上复刻青萍蓝牙温湿度计:从 Python 模拟到 ESP-IDF 实战前言

青萍蓝牙温湿度计(Cleargrass Bluetooth Thermometer)凭借其极简的圆形表盘、7 段数码管字体,是一款非常吸睛的蓝牙温湿度计。
M5Stack 的 M5Paper 拥有 4.7 英寸 960×540 电子墨水屏,16 级灰度、540×960 的分辨率,再加上板载 SHT30 温湿度传感器和 AXP192 电源管理,简直就是为这类“静态信息仪表”量身定做的硬件平台。

本文将完整记录我在 M5Paper 上复刻青萍蓝牙温湿度计 V40 UI 的全过程,包括:

  1. 参考对象分析:青萍蓝牙温湿度计的 UI 拆解;

  2. 设计规划:当圆形表盘遇见方形屏幕,如何用“上下云纹 + 中间圆环”的设计语言化解比例冲突;

  3. Python 像素级模拟:V1 → V40 的迭代历程,如何在桌面上就把界面“磨”到和实体屏完全一致;

  4. M5Paper 真机适配:从 .ttf 字体陷阱、栈溢出崩溃,到使用内置 Font7 7 段数码管字体,再到坐标硬编码的实战细节。

项目仓库https://github.com/HonestQiao/m5paper-weather(ESP-IDF 工程)
功能定位:本 ESP-IDF 版本为纯本地显示版本,包含 V40 UI 绘制、SHT30 传感器读取和 AXP192 电量显示。


一、参考对象:青萍蓝牙温湿度计

青萍蓝牙温湿度计的界面非常克制,核心元素只有五组:

  1. 圆形灰度圆环:外径约占据屏幕宽度的 96%,内径与外径之间形成一个浅灰色呼吸环,视觉焦点极强;

  2. 温度大区:整数部分使用 7 段数码管字体,尺寸最大;右侧带一位小数、手绘风格的 °C;

  3. 湿度大区:同样使用 7 段数码管字体,整数两位,右侧一位小数、 % 符号;

  4. 顶部状态栏:左侧蓝牙图标(未连接时仅显示本体,已连接时追加信号弧线),右侧电池图标(仅图形,无百分比数字);

m5paper-weather-01-ref.jpg

在动手写 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 个版本都是在这些骨架之上做“皮肉精雕”。

m5paper-weather-02-v1.png

3.2 V10:引入 digital-7 字体——字模与手绘符号的博弈

从 V10 开始,我把 digital-7.ttf 正式引入 PIL。这个字体是 7 段数码管风格,和青萍原版极为神似。

但很快发现两个“字体陷阱”:

  • digital-7.ttf  .(小数点)字形是一个扁黑方块,非常丑;

  • C  % 的字模质量也不佳(C 近乎空白,%  /o 状)。

于是我在 Python 里做了第一次重大修正:弃用字体的 . C %,改为手绘。 PIL 仍用 digital-7.ttf 渲染数字,但小数点改用 Helvetica 圆点;°C  % 改成纯 PIL 图形绘制(椭圆 + 直线 + 圆点)。这奠定了后续 V40 的符号风格。

另外,在这个阶段我还顺手解决了“龙”的问题。原本我的设计意图是让上下各画一条(云龙纹嘛),但 PIL 的椭圆弧和正弦波叠加出来,怎么看都更像两条扭来扭去的。我盯着屏幕沉思了三秒钟,觉得……这虫挺简洁的,有种抽象的美,于是大度地把它保留下来的。

m5paper-weather-03-v10.png

3.3 V19:Material Design 蓝牙图标 + 动态电池

V19 阶段主要打磨状态栏。早期的蓝牙图标是简单的两根交叉线(类似字母 X),不够精致。于是我按照 Material Design 官方路径 用 12 个三角形拼出带镂空的标准蓝牙图标,并在未连接/已连接两种状态下分别渲染:

  • 未连接:仅绘制蓝牙本体;

  • 已连接:在本体右侧追加两道信号弧线。

电池图标也改为手绘空心矩形 + 内部动态填充条,宽度根据电量百分比实时计算,右侧加上 3×6 的“正极凸起”。

m5paper-weather-04-v19.png

3.4 V35:背景云龙纹与灰度统一

到了 V35,数字和图标的位置已经收敛到误差 3px 以内,我开始处理“氛围层”——上下云龙纹。

  • 顶部云龙纹:5 组椭圆弧 + 420 点正弦波虚线,颜色在浅灰 CLR_GL 和中灰 CLR_GM 之间过渡;

  • 底部云龙纹:3 组大比例椭圆 + 380 点正弦波,并在下方追加 3 层 {18, 32, 48}px 的扇形装饰弧;

  • 圆环灰度从早期的 200 调整为 240,让圆环在纯白背景上更柔和。

m5paper-weather-05-v35.png

3.5 V40:定稿——像素级对齐的最终 verdict

V40 是最终确认的“完美版”。所有坐标和字号如下:

元素字体/方式字号/高度坐标/位置
温度整数digital-7.ttf230pt(实际像素高约 151px)左起 (109, 311)、228, 311
温度小数digital-7.ttf80pt(实际高约 52px)(369, 435);圆点手绘在 (352, 469)
温度 °Cdigital-7.ttf + 手绘椭圆30pt(实际高约 20px)(381, 369);° 为手绘椭圆
湿度整数digital-7.ttf145pt(实际高约 95px)左起 (165, 549)、242, 549
湿度小数digital-7.ttf50pt(实际高约 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 同时输出两种状态图:蓝牙未连接和已连接。

m5paper-weather-06-v40-off.png

m5paper-weather-07-v40-on.png

只有模拟图通过了 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() 函数,核心逻辑是:

  1. 把字符串按 . 分割成 intpart  fracpart;

  2.  Font7 的大字号画整数部分,每个字符单独测量宽度后手动添加字间距;

  3. 手绘一个圆点当小数点;

  4. 用较小的 Font7 字号画小数部分;

  5. 根据 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 画一个小椭圆表示度数符号。

6. 分隔线

温度在上半圆,湿度在下半圆,中间用一条纯黑横线隔开。因为 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)会全部错位,导致屏幕上只有一团背景填充的“白色块块”,完全看不到数字笔画。

m5paper-weather-08-bug.jpg

2. 栈溢出——alloca 的末日

就在我以为 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

六、实拍效果

m5paper-weather-09-final.jpg

目前 ESP-IDF 版本已经在 M5Paper 真机上稳定运行,主要特性完成度:

  •  V40 UI 像素级复刻(Python 预览 ↔ 墨水屏显示完全一致)

  •  SHT30 温度/湿度读取 + AXP192 电量图标动态填充

  •  1 分钟 esp_timer 自动刷新

  •  坐标全部硬编码,拒绝漂移

七、未来优化

未来可以探索的优化方向:

  1. 局部刷新:当前每次更新调用 display() 做全刷,若觉得闪屏明显,可深入研究 M5Paper EPD 的局部刷新策略;

  2. RTC 休眠唤醒:结合 M5Paper 的低功耗优势,实现“每小时唤醒一次 + 关键阈值触发唤醒”的长续航模式;

  3. 历史数据曲线:在 SPIFFS 中缓存 24h 温湿度数据,在墨水屏上绘制迷你折线图;





关键词: M5Paper     温湿度     青萍    

共1条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]