机器人开发

把天气装进一块墨水屏,桌面上多了个小东西

一块 200 × 200 像素的墨水屏,配上 ESP32,把图案、天气和时间留在桌面上。本文从电子纸的反射显示、画面保持与刷新节奏讲起,拆解 LVGL 到黑白画布的交接,并给出工程入口、引脚和复刻时需要修正的代码,分清面板的省电潜力与整机功耗。

视频演示

代码开源

查看 GitHub 源码

硬件开源

查看嘉立创硬件开源


一只粉色外壳,正面嵌着一块方方正正的墨水屏,黑白头像占了大半个画面。它很适合安静地留在桌面上,把一张图案、一页天气或一组时间放在那里,等人抬头看。里面的 ESP32 和触摸控制器,则负责让这张「电子小纸片」换上下一页内容。

粉色外壳的桌面墨水屏,屏幕显示黑白头像

我喜欢这种体量的项目,也喜欢墨水屏给它带来的节奏。天气、日期和时间慢慢更新,图案可以停留得久一些;外壳又留了一点发挥空间,让这张电子小纸片长成自己喜欢的样子。

脑洞不大,落到电路板上,事情倒是一件也不会少。屏幕怎么画,手指点哪里,天气从哪来,几个任务怎样接力,都得接上。

这篇就顺着这块桌面墨水屏,把它拆开聊聊。先看画面怎样留下来、刷新为什么需要等待,再看 ESP32 怎样把天气和时间送上去。想复刻的朋友可以从接线和工程入手,也能借走一套适合墨水屏的显示和交互思路。

原项目 Bilibili 演示视频 00:34 的装配视图截图,非本次实机验收

小屏幕里装了什么

工程使用 PlatformIO 和 Arduino 框架,主控配置为 esp32dev。界面交给 LVGL,触摸由 FT6336U 读取,本地温湿度传感器使用 AHT20。

部分 工程采用的方案 当前用途
主控 ESP32,PlatformIO 的 esp32dev 板型 运行界面、外设和网络任务
显示 200 × 200 像素单色墨水屏 显示图案、时间、日期和天气页面
触摸 FT6336U 向 LVGL 提供按下状态和坐标
温湿度 AHT20 读取本地温湿度,输出到串口
网络 WiFi、心知天气、NTP 获取城市天气和网络时间
界面 LVGL,SquareLine Studio 导出的 UI 组织页面、按钮、图标与点击事件

桌面墨水屏模块分工示意:触摸、网络和显示由 ESP32 处理,本地温湿度当前输出串口

UI 分成主页、爱心页、菜单页、时间页和天气页。启动时加载的是显示图案的 HomePage,时间和天气各有自己的页面。头像也就有了用武之地,桌面信息牌顺手兼任了一个小摆件。

原始 LVGL 主页与菜单离线预览:画面来自项目 C 源码、字库与图片,未连接设备

触摸驱动会把 FT6336U 的第一个触点交给 LVGL,注册的设备类型是 LV_INDEV_TYPE_POINTER。UI 事件里已经写了点击切页,菜单按钮通往时间、天气和主页,返回按钮再回到菜单。

这里适合先做清楚、少量的交互。当前接通的是点击事件,不能因为用上了触摸芯片,就把滑动手势也算进功能清单。

模型文件提供了 STL、3MF、STEP 和 SLDPRT 等格式,可以作为外壳修改的起点。打印前仍要对照自己选用的屏幕、排线和实际板子检查尺寸,尤其是屏幕开口和内部装配空间。硬件材料还要看开源页面中的电气设计,结构模型解决的是装配问题。

项目原始 STL 零件概览:前壳、后壳、支撑与压板,显示摆放不代表装配验证

原项目 3MF 内嵌打印排版预览:后壳等零件与带附加文字的前壳

LVGL 画好了,墨水屏还要再接一棒

一张靠环境光看见的画面

常见的黑白电泳电子纸,靠电场移动带电的黑白颗粒,让不同颜色来到可见的一侧,组成文字和图案。它反射环境光,显示本身不靠背光,亮处能借光看清,暗处则需要额外照明。

还有一个有意思的特性,叫双稳态。画面写好后可以保持,面板保持这张图本身不需要持续供电。放一个头像,它可以留在那里,不必反复重画;放天气,就得记住留下来的也可能是旧天气。断网或停止更新后,字还在,信息却会慢慢过时。

墨水屏通用电泳原理示意:黑白颗粒位置、环境光反射与画面保持,整机功耗需另行计算

把界面收进黑白画布

用 LVGL 做界面,按钮、文字和图片可以放在同一套对象系统里。到了墨水屏这一端,画面就要收敛成黑白两色。

工程里有一块完整的单色画布,大小是:

200 × 200 ÷ 8 = 5000 字节

每个像素用一位表示,Paint 负责往这块缓冲区画点。LVGL 自己还有一个可容纳 10 行像素的绘制缓冲区,它和这块 5000 字节的单色画布承担不同工作。

关键的交接发生在 display.cpp 的 disp_flush()。LVGL 每次给出需要更新的区域,回调逐个读取颜色,再写进 Paint。当前转换规则很直接,颜色值等于 0xffff 的像素画成白色,其余画成黑色。

灰色也会进黑色那一边。做 UI 时就要考虑这个规则,用大一点的文字、明确的黑白边界和适当留白,让信息看起来清楚。图标尽量简洁,交互安排少量点击切页,别指望一张彩色素材放进去后自动得到细腻的灰阶效果。

这个回调完成后,调用 lv_disp_flush_ready() 告诉 LVGL,像素已经搬完。真正往设备写入画面,是另一处调用:

void Display::refresh(void)
{
    epd.SetFrameMemory(paint.GetImage()); // 设置电子屏的帧内存
    epd.DisplayFrame();                   // 显示图像
}

显示逻辑链路示意:LVGL 像素经黑白转换写入 Paint,设备刷新是独立环节

刷新要按屏幕自己的节奏来

界面画完和屏幕刷新,是两个环节。 看见标签的内容已经改了,屏幕却没动,就要顺着这条链检查,不能只盯着 UI 对象。

当前初始化使用的是 epd.Init(lut_full_update),刷新时把整幅 Paint 画布送进屏幕。驱动库里虽然还有 lut_partial_update,这条初始化路径没有启用它。LVGL 只重画一个小区域,也不能直接算作电子纸已经完成了局部刷新。

全刷可能出现黑白闪烁,刷新波形也会帮助清理上一帧留下的痕迹。局刷可以减少这种打断,但有残影的折中,还需要按具体面板的要求周期性全刷。要改刷新方式,得把面板支持的波形和驱动一起对上,不能只把 UI 的重绘区域缩小。

全刷与局刷的取舍示意:闪烁、变化区域与残影,当前代码使用全刷且未启用局刷

负责刷屏的 Device_Screen 任务会执行刷新,然后阻塞等待任务通知。收到通知后,再延时 150 毫秒进入下一轮。UI 事件回调会发出通知,天气和时间任务也会在对应页面处于前台时通知它。

显示驱动源码截图:disp_flush 写入 Paint,Display::refresh 执行设备刷新

因此,代码里的 150 不能读成「每 150 毫秒固定刷新一次」。它还要等待通知,也要经历实际的屏幕驱动过程。底层驱动会轮询 BUSY,等待低电平表示屏幕空闲;其中的 100 毫秒是轮询间隔,也不能当作屏幕的刷新耗时。给墨水屏安排任务,得把这几个环节一起看。

双稳态带来了省电的空间,整机功耗还要另算。当前工程没有调用 Epd::Sleep(),也没有让 ESP32 进入深度睡眠,WiFi 和 MCU 仍在工作。想继续优化,可以让面板更新后睡眠,唤醒时按要求重新初始化,再配合 MCU、网络和数据更新的唤醒调度;这些需要一起设计和验证,不能仅凭一张能保持的画面就推算续航。

天气里的温度,和桌上的温度

这个项目有两路温度数据,很容易聊着聊着就把它们混成一个功能。

天气页显示的气温来自网络。天气任务请求心知天气,默认城市参数为 qingdao,拿到天气代码和气温后,更新对应的图标、天气文字和温度标签。任务在每轮末尾等待一小时,再进行下一轮请求。

图标映射包含晴、云、阴、雨、雪几类,尚未覆盖的代码会落到默认分支。做自己的天气界面时,可以继续补齐映射,避免把陌生天气代码一律画成晴天。

换城市要改两个地方。一个是 System_Interaction.cpp 中的请求参数,另一个是 src/ui/screens/ui_WeatherPage.c 中城市标签的静态文字。只改请求,页面还写着「青岛」,天气就成了人在别处、地名还没搬家。

另一条来自 AHT20,它读的是传感器所在位置的本地温湿度。当前任务每分钟读取一次,随后打印到串口。返回值放在 std::map<String, int> 里,浮点读数转成了整数;它没有绑定到天气页的温度标签。

所以,想做室内温湿度卡片,还得继续接上 UI 标签,按需要保留小数位,再安排页面更新和刷屏通知。传感器已经读到数据,是这条链的起点。

两路温度数据去向示意:心知天气进入天气页,AHT20 本地温湿度当前只输出到串口

AHT20 与天气任务真实源码截图:本地读取和网络城市天气分别处理

时间页走的是 NTP,代码使用 ntp.aliyun.com,时区偏移设为 UTC+8。时间任务每轮先等待 30 秒,再尝试读取并更新日期、时间和星期。如果继续调整,我会优先考虑分钟级显示,少让秒数牵着墨水屏频繁刷新,给画面留一点停下来的时间。

原始 LVGL 时间与天气页离线预览:时间、日期和天气均为 UI 初始化示例,不是实机采样

这里还有两个小地方需要修正。当前顺序是先 getLocalTime(),后 configTime();复刻时应先配置校时,再检查时间读取是否成功。星期二的判断字符串写成了 Tueaday,应改为 Tuesday。一个字母放错位置,周二就可能在页面上消失,时间倒还照常往前走。

从工程开始复刻

先取源码,工程入口在 02_firmware/ESP32_Desk:

git clone https://github.com/zhanghongchen1213/DIY_DesktopScreen.git
cd DIY_DesktopScreen/02_firmware/ESP32_Desk

VS Code 安装 PlatformIO 后,可以直接打开这个工程目录。platformio.ini 的环境名叫 denky32,实际板型配置是 esp32dev,构建时使用环境名,别把两者当成同一个参数。

依赖声明如下:

[env:denky32]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
    knolleary/PubSubClient@^2.8
    bblanchon/ArduinoJson@^7.0.2
    adafruit/Adafruit AHTX0@^2.0.5
    lvgl/lvgl@^8.3.4

当前项目目录与 PlatformIO 原始配置截图

^ 表示一个允许更新的版本范围,这份配置没有把依赖锁死到单个版本。UI 文件的生成注释写的是 SquareLine Studio 1.3.4 和 LVGL 8.2.0,而依赖声明使用 LVGL ^8.3.4。排查兼容问题时,要看本机实际安装的版本。

UI 代码还明确检查两项配置。在实际使用的 lv_conf.h 中确认:

#define LV_COLOR_DEPTH 16
#define LV_COLOR_16_SWAP 0

墨水屏最终显示黑白,并不等于 LVGL 这端也应改成 1 位色深。这里的 UI 和颜色转换按 16 位输入来写,擅自改掉,代码里的检查就会阻止编译。

还要确认 LVGL 的时基来源。仓库没有提供可见的 lv_conf.h、lv_tick_inc() 调用或 LV_TICK_CUSTOM 配置,需要检查实际编译环境是否已提供,必要时补上。主循环里的 lv_task_handler() 和 delay(20),并不能代替给 LVGL 提供经过的时间。

几个任务一起运行时,交接也要加一道约束。当前天气和时间任务直接修改 LVGL 控件,主循环同时运行它的处理函数,尚未看到共享互斥;可以把 UI 更新统一放进一个任务,或者按 LVGL 的要求用互斥保护。单色画布也存在一边写入、一边送往屏幕的路径,读写同步需要一起联调。界面代码能画出一页,和并发运行时始终画对,是两件要分别接住的事。

接线可以按当前固件定义逐项核对:

模块 信号 ESP32 GPIO
墨水屏 CLK 26
墨水屏 MOSI / DIN 25
墨水屏 BUSY 13
墨水屏 RST 12
墨水屏 DC 14
墨水屏 CS 27
AHT20 SDA 21
AHT20 SCL 22
FT6336U SDA 33
FT6336U SCL 32
FT6336U RST 5
FT6336U INT 4

这张表对应固件 GPIO,模块供电、接口方向和排线连接仍以所用硬件的原理图为准。驱动头文件还留着早期 ESP8266、NodeMCU 的注释,接 ESP32 时应按这些宏定义和 SPI 初始化参数核对,别照旧注释接过去。

嘉立创公开硬件项目原理图 P1 的浏览器预览截图

嘉立创公开硬件项目 PCB1 布线的浏览器预览截图

联调建议按这个顺序推进:

  1. 在 src/Application/System_Config.cpp 中设置自己的 WiFi 信息,在 src/network.cpp 中设置自己的天气服务参数。不要沿用仓库中的账号配置。
  2. 修正天气请求的 Host 字段。当前代码拼入的是端口 80,这里应使用主机名 api.seniverse.com;同时给连接失败路径补上明确的返回结果,并检查 HTTP 状态和 JSON 解析结果,再更新 UI。
  3. 修正时间初始化顺序和星期二拼写,核对 LVGL 配置、时基、任务间同步与引脚,然后运行构建命令。
  4. 构建通过后连接对应硬件,再上传固件并打开串口。串口初始化速率是 9600,要手动选择一致的监视速率。
  5. 依次检查图案显示、触摸坐标、点击切页、天气请求、时间读取和 AHT20 输出。每次先接通一条链,出现问题会更容易找到位置。
pio run -e denky32
pio run -e denky32 -t upload
pio device monitor -b 9600

有个启动顺序值得提前记住。System_Resource_Init() 先连接 WiFi,并一直等待连接成功,之后才初始化串口和屏幕等外设。如果上电后既没有画面,也没有预期的串口日志,先查 WiFi 配置和网络,程序可能还没走到屏幕初始化。

让串口更早启动、给联网增加超时,都是后续可以改进的地方。准备把它长期放在桌面上,这些失败路径和异常提示也应一并补好。

留一点空位,给下一个脑洞

代码里已经有 MQTT 类,可以看到连接、订阅、发布和 JSON 打包方法。不过默认的连接与订阅调用被注释了,AHT20 任务里的发布调用也被注释了,回调尚未注册,客户端 loop() 也没有接到运行循环中。

想让它接入自己的消息服务,可以从这里继续做,把连接、回调、客户端处理和断线重连补齐,再决定哪些数据应该更新页面。当前工程还不能直接算作已经完成智能家居联动。

我更愿意把这块屏幕看成一个可以继续动手的小底座。先让天气与时间各守住一页,再接本地温湿度,或者换上自己画的图案。功能每多一个,就把数据、交互和刷新之间的交接再理顺一点,也问问这条信息是否值得让屏幕再刷一次。

前壳原始 STL 外侧视图:屏幕开口与外形保持原网格

粉色外壳里的黑白头像,依然可以留着。天气该更新时换一页,其他时候就让画面安静地待在那里。桌面上这个小东西的意思,也在这里,实用之外,还有一点属于自己的样子。

参考资料

ESP32 Arduino 官方文档

PlatformIO 官方项目配置说明

LVGL 8.3 显示驱动接口

LVGL 8.3 时基接口

LVGL 8.3 操作系统与多线程

ArduinoJson 6 到 7 官方升级说明

心知天气官方网站

SquareLine Studio 官方网站

E Ink 电子墨水的电泳显示原理

E Ink 双稳态与反射显示特性

微雪 1.54 英寸墨水屏手册:全刷与局刷说明

鸿尘客 Honchic · 把脑洞焊到电路板上

接着看

再读几篇项目与技术手记。

返回机器人开发

读到这里,也聊聊你的想法。

项目复刻、技术交流,或者一起折腾点什么。