我第一版看板的做法很"正统":ESP32-C3 自己连 WiFi,取 JSON,自己排版,自己调字体画字。结果一个礼拜里大部分时间花在两件事上——把中文字库塞进 flash、以及每次想改一下版式就要重新编译烧录。后来我把整个思路反过来:画面不在单片机上画,全部交给服务器。
C3 端只做三件事
现在的固件只剩下三个动作:醒来联网、把服务器生成好的点阵下载下来、刷到屏幕上,然后立刻进入深度休眠。它不知道今天是几号,不知道天气多少度,也不知道某一栏要画在哪里——这些它都不需要知道。
这么做的好处很直接:改版式、改字号、加一栏内容,只要在服务器上改 Python 代码,设备下次醒来拿到的就是新画面,一行固件都不用重新烧。对一个要用一整年、还挂在墙上的东西来说,这一点太重要了。代价是设备必须联网才能更新画面,断网时只能保持上一次的显示,这我接受。
点阵数据长什么样
屏幕是 400×300 的三色屏,服务器渲染出来严格是 30000 字节:黑面 15000 字节,红面 15000 字节。400×300 是 120000 个像素,每字节 8 像素,所以每面正好 15000 字节。每字节里最高位对应最左边那个像素,按行从左到右填,一行 400 像素就是 50 字节,300 行正好 15000。
我把两个平面拼成一个二进制包,前面 15000 字节是黑面,后面 15000 是红面。C3 端一次性下载、一次性解到两个缓冲区里,不需要在设备上做任何图像处理。这个约定很关键,两边只要有一个地方理解错(比如字节内位序反了),结果就是满屏雪花或者整片涂黑。
服务器端:Pillow 渲染后直接取点阵
服务器上是一个很短的脚本,用 Pillow 画好 400×300 的图,然后转成两个 '1' 模式的黑白图,各自 tobytes() 拼起来交给 HTTP 服务。设备端就是一个普通的 GET:
import io
import requests
from PIL import Image
W, H = 400, 300
def render():
black = Image.new("1", (W, H), 1) # 1 = 白底
red = Image.new("1", (W, H), 1)
# ... 在 black / red 上画字和线,红元素只画在 red 上
return black.tobytes() + red.tobytes()
def serve():
body = render()
assert len(body) == W * H // 8 * 2, len(body)
# 交给 HTTP 服务发出,Content-Type: application/octet-stream
return body
# 设备侧:一次 GET 拿到 30000 字节
r = requests.get("https://520games.cn/eink/frame.bin", timeout=20)
buf = r.content
assert len(buf) == 30000
black_bits, red_bits = buf[:15000], buf[15000:]
这里我一开始踩过坑:以为 '1' 模式里 1 表示黑,结果画出来的白底是黑的。Pillow 的 '1' 模式里 255(写 1 也行)是白,0 才是黑,画的时候要按这个来想。
C3 端:GxEPD2 循环刷两遍
设备端用 GxEPD2。三色屏的刷法是:先声明整窗刷新,然后首页、把黑面画进 page buffer、把红面画进颜色 page buffer,之后翻页,最后一页提交。核心是 setFullWindow、firstPage、drawBitmap、nextPage 这个循环:
display.setFullWindow();
display.firstPage();
do {
display.drawBitmap(0, 0, blackBits, 400, 300, GxEPD_BLACK);
display.drawBitmap(0, 0, redBits, 400, 300, GxEPD_RED);
} while (display.nextPage());
// 刷完立刻休眠,醒来再取下一帧
esp_deep_sleep_start();
drawBitmap 接受的就是这种逐行、每字节 8 像素、最高位在左的位图数组,和服务器拼出来的格式正好对上,不需要再转一次。两个平面的地址分别指向下载缓冲区的前半和后半即可。
这样拆之后省掉了什么
固件从几百 KB 掉到很小,因为没有字库、没有 JSON 解析、没有排版逻辑。启动到刷完只要几秒,醒来、联网、GET、画、睡,电量几乎全花在联网那一下。更重要的是调试变简单了:画面不对,先在电脑上把同一个脚本跑一遍存成 PNG 看,问题基本就锁定在传输或者刷屏参数上,不用再瞎猜是字体还是布局。
唯一要守的纪律是两边的数据格式必须写死在文档里,谁改都要同步改另一边。我把它写在了仓库的 README 第一行,免得半年后自己又不记得。