一块 200 × 200 像素的墨水屏,配上 ESP32,把图案、天气和时间留在桌面上。本文从电子纸的反射显示、画面保持与刷新节奏讲起,拆解 LVGL 到黑白画布的交接,并给出工程入口、引脚和复刻时需要修正的代码,分清面板的省电潜力与整机功耗。
视频演示
代码开源
硬件开源
一只粉色外壳,正面嵌着一块方方正正的墨水屏,黑白头像占了大半个画面。它很适合安静地留在桌面上,把一张图案、一页天气或一组时间放在那里,等人抬头看。里面的 ESP32 和触摸控制器,则负责让这张「电子小纸片」换上下一页内容。

我喜欢这种体量的项目,也喜欢墨水屏给它带来的节奏。天气、日期和时间慢慢更新,图案可以停留得久一些;外壳又留了一点发挥空间,让这张电子小纸片长成自己喜欢的样子。
脑洞不大,落到电路板上,事情倒是一件也不会少。屏幕怎么画,手指点哪里,天气从哪来,几个任务怎样接力,都得接上。
这篇就顺着这块桌面墨水屏,把它拆开聊聊。先看画面怎样留下来、刷新为什么需要等待,再看 ESP32 怎样把天气和时间送上去。想复刻的朋友可以从接线和工程入手,也能借走一套适合墨水屏的显示和交互思路。

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

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

触摸驱动会把 FT6336U 的第一个触点交给 LVGL,注册的设备类型是 LV_INDEV_TYPE_POINTER。UI 事件里已经写了点击切页,菜单按钮通往时间、天气和主页,返回按钮再回到菜单。
这里适合先做清楚、少量的交互。当前接通的是点击事件,不能因为用上了触摸芯片,就把滑动手势也算进功能清单。
模型文件提供了 STL、3MF、STEP 和 SLDPRT 等格式,可以作为外壳修改的起点。打印前仍要对照自己选用的屏幕、排线和实际板子检查尺寸,尤其是屏幕开口和内部装配空间。硬件材料还要看开源页面中的电气设计,结构模型解决的是装配问题。


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(); // 显示图像
}

刷新要按屏幕自己的节奏来
界面画完和屏幕刷新,是两个环节。 看见标签的内容已经改了,屏幕却没动,就要顺着这条链检查,不能只盯着 UI 对象。
当前初始化使用的是 epd.Init(lut_full_update),刷新时把整幅 Paint 画布送进屏幕。驱动库里虽然还有 lut_partial_update,这条初始化路径没有启用它。LVGL 只重画一个小区域,也不能直接算作电子纸已经完成了局部刷新。
全刷可能出现黑白闪烁,刷新波形也会帮助清理上一帧留下的痕迹。局刷可以减少这种打断,但有残影的折中,还需要按具体面板的要求周期性全刷。要改刷新方式,得把面板支持的波形和驱动一起对上,不能只把 UI 的重绘区域缩小。

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

因此,代码里的 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 标签,按需要保留小数位,再安排页面更新和刷屏通知。传感器已经读到数据,是这条链的起点。


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

这里还有两个小地方需要修正。当前顺序是先 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

^ 表示一个允许更新的版本范围,这份配置没有把依赖锁死到单个版本。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 初始化参数核对,别照旧注释接过去。


联调建议按这个顺序推进:
- 在
src/Application/System_Config.cpp中设置自己的 WiFi 信息,在src/network.cpp中设置自己的天气服务参数。不要沿用仓库中的账号配置。 - 修正天气请求的
Host字段。当前代码拼入的是端口80,这里应使用主机名api.seniverse.com;同时给连接失败路径补上明确的返回结果,并检查 HTTP 状态和 JSON 解析结果,再更新 UI。 - 修正时间初始化顺序和星期二拼写,核对 LVGL 配置、时基、任务间同步与引脚,然后运行构建命令。
- 构建通过后连接对应硬件,再上传固件并打开串口。串口初始化速率是 9600,要手动选择一致的监视速率。
- 依次检查图案显示、触摸坐标、点击切页、天气请求、时间读取和 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() 也没有接到运行循环中。
想让它接入自己的消息服务,可以从这里继续做,把连接、回调、客户端处理和断线重连补齐,再决定哪些数据应该更新页面。当前工程还不能直接算作已经完成智能家居联动。
我更愿意把这块屏幕看成一个可以继续动手的小底座。先让天气与时间各守住一页,再接本地温湿度,或者换上自己画的图案。功能每多一个,就把数据、交互和刷新之间的交接再理顺一点,也问问这条信息是否值得让屏幕再刷一次。

粉色外壳里的黑白头像,依然可以留着。天气该更新时换一页,其他时候就让画面安静地待在那里。桌面上这个小东西的意思,也在这里,实用之外,还有一点属于自己的样子。
参考资料
鸿尘客 Honchic · 把脑洞焊到电路板上