3626 字
18 分钟
深睡的墨水屏没有"旧帧":TRMNL 自建系统第二轮升级

昨天那篇收尾时,夜间模式刚在凌晨一点自动触发,整套”服务端渲染、设备只当画框”的系统算是站住了。今天给它做第二轮升级,一共五件事:

  1. 服务域名搬家——status.example.com 要让给真正的状态页,显示服务搬去 display.example.com
  2. 固件 partial refresh——深睡架构下墨水屏控制器里根本没有”旧帧”,差分刷新是空中楼阁
  3. 消息 API 从显示服务里抽出来,变成平台级的消息总线
  4. 夜间屏重做——静态时钟改成”到天亮”进度条 + 电量续航估计
  5. 部署一个不住在自己服务器上的 uptime 状态页

前四件是常规工程,第二件值得展开写——它是这类深睡设备做局部刷新绕不开的问题,网上完整讲清楚的资料不多。

域名搬家:先修临时路,再拆旧桥#

固件里烤死的 API_BASE_URL 指向 status.example.com。这个名字其实名不副实:它是给设备提供图片的显示服务,不是状态页。现在要部署真的 uptime 状态页了,得把名字让出来。

麻烦在于设备的特性:它深睡 30 分钟才醒一次,醒来只认烤在固件里的旧域名。如果直接把路由切走,设备下次醒来就失联,而重新刷固件需要物理接触——这是个先有鸡还是先有蛋的时序问题。

解法分四步,顺序不能乱:

第一步,新域名先上线。DNS 记录加好,部署清单里把挂载点从 status 改成 display 子域,push 之后部署管线自动改写 Caddy 配置。此时新旧两个域名其实都通——旧域名还在旧的 Caddy snippet 里。

第二步是真正容易踩坑的地方。部署管线重写 snippet 后旧域名路由会消失,所以要预先放一个”过渡 snippet”让 status.example.com 继续反代显示服务。但不能在部署前就启用它——同一个 host 出现在两个 site block 里,Caddy 直接拒绝整份配置:

Error: ambiguous site definition: status.example.com

而部署管线里随便哪个服务部署时都会 caddy reload,一旦 reload 失败,连带那个无辜服务的部署也挂了。所以过渡文件先以 .caddy.disabled 后缀放好(Caddyfile 只 import *.caddy),等部署管线把旧 snippet 改写成新域名之后,立刻 mv 掉后缀再 reload:

Terminal window
# 部署完成后立刻执行,中断窗口只有几秒
sudo mv /etc/caddy/services/status-legacy.caddy.disabled \
/etc/caddy/services/status-legacy.caddy
sudo caddy reload --config /etc/caddy/Caddyfile

第三步,验证两条路都通。模拟设备请求打旧域名:

Terminal window
curl -s https://status.example.com/api/display \
-H "ID: <MAC>" -H "Access-Token: probe" -H "FW-Version: 1.6.7"
{"status": 0, "image_url": "https://display.example.com/image/1520c4ba....bmp", ...}

注意 image_url 已经是新域名——设备从旧域名拿响应、去新域名下图,两边无缝混用,因为固件下载图片走的是响应里的完整 URL。

第四步才是刷固件换烤死的域名(见后文),刷完确认遥测里 FW-Version 变成新版本号,过渡 snippet 就可以删了。

消息总线:从一张内嵌表到平台服务#

显示服务里原来有一套消息 API:POST /api/messages 收消息,priority ≥ 1 的消息触发 P0 全屏接管。这套东西其实和”显示”没有本质绑定——部署管线想发通知、cron 任务想报告结果,都该有个统一的投递处,而不是都打到墨水屏的服务上。

于是抽成独立的 messageservice:命名 channel、priority、TTL、已读追踪,挂在 API 网关的 /message/ 路径下。鉴权两层——平台用户走 SDK 的 ACL,机器调用方用一个独立的 ingest token。优先消息强制默认 1 小时 TTL,这条语义从旧实现原样搬过来:一条没人确认的告警不能把屏幕永远卡在全屏模式

显示服务变成纯消费者,迁移策略是双模式:

# 环境变量没配 → 旧的本地表路径,测试零改动
# 配了 → 转发写入、轮询读取,消息的真身在总线上
app.state.messages_client = (
MessagesClient(msgs_url, token, channel="display")
if msgs_url else None
)

读取端有个细节值得记:两个服务在同一台机器上,读取是 localhost 同步调用(2 秒超时),但失败时退回上一次的缓存——消息服务重启的瞬间不能让设备拿到一块白屏。缓存还有个二次校验:缓存里的优先消息要在本地重新核对 expires_at,否则消息服务挂掉期间,一条早该过期的 P0 告警会把屏幕卡死在全屏模式,恰好违反上面那条语义。

旧数据迁移用 sqlite 的 ATTACH 一条 SQL 搞定:

ATTACH 'file:displayservice.db?mode=ro' AS old;
INSERT INTO messages (channel, ts, source, text, priority, read_at, expires_at)
SELECT 'display', ts, source, text, priority, read_at, expires_at FROM old.messages;

夜间屏:静态时钟是个蠢材#

昨天的夜间模式是个大号静态时钟,60 分钟刷一次。问题显而易见:屏幕上显示 03:24,实际可能已经 04:20 了——一个停着的钟比没有钟更糟

但提高刷新频率治标不治本(墨水屏 + 电池,每次唤醒都是开销)。正确的思路是换显示内容:把”时刻”换成”进度”。进度条天然耐放——半小时前的进度条依然大体正确,停着的时钟则完全错误。

新夜间屏三个元素:月牙 + 时钟照旧(弱化),中间一根 01:00 → 07:00 的夜晚进度条,下面标注 “3h36m to dawn”;底部一根电量条,带续航估计 “~58d left”。续航数字来自遥测表里电池电压的线性回归——这个放电斜率计算原来就在 /telemetry/summary 调试端点里写过,抽成共享函数两边复用:

def battery_projection(conn, *, days: int = 14) -> dict:
"""最近 N 天电压做线性回归 → 投影到 3.3V 截止的剩余天数"""
# slope = Σ(x-x̄)(y-ȳ) / Σ(x-x̄)² ; projected = (V_now - 3.3) / -slope

刷新间隔从 60 分钟改到 30 分钟。一晚上多醒 6 次,对按周计的续航是噪声,换来进度条最多半小时的滞后。

主菜:深睡的屏幕没有”旧帧”#

墨水屏全刷有黑白闪烁,体验糟糕;局部刷新(partial refresh)无闪烁,但它有个前提条件,在深睡架构下默认不成立。

先讲机制。这块 7.5 寸屏的控制器是 UC8179 家族,内部有两个帧缓冲:DTM1(命令 0x10,“旧帧”)和 DTM2(命令 0x13,“新帧”)。partial refresh 的差分波形靠对比两个缓冲来决定每个像素动不动——不变的像素不驱动,所以不闪。

问题来了:固件每次刷完屏就给面板发 deep sleep 命令(DSLP),然后主控自己也深睡 30 分钟。面板深睡后内部 RAM 里的内容不再可靠——数据手册没有明文承诺保留,驱动社区的共识做法就是醒来后重传旧帧。也就是说,下次唤醒时 DTM1 里根本没有上一帧,差分对比的参照物是垃圾数据。

读固件源码发现,原作者显然撞过这个问题——旧帧恢复的代码框架就在那里,被注释掉了:

src/bl.cpp(改动前)
uint8_t *buffer_old = nullptr; // Disable partial update for now
// ...
// Disable partial update (for now)
// if (filesystem_file_exists("/last.png")) {
// buffer_old = display_read_file("/last.png", &file_size_old);

而且 SPIFFS 里的素材是现成的:固件本来就会把上一张图 rename 成 /last.bmp 留底。整条路是铺好的,只差最后一段。

补全的逻辑:唤醒后从 SPIFFS 读回 /last.bmp,在写新帧之前先把它写进控制器的旧帧平面。这里有个实现细节,差点踩进库的坑里——bb_epaper 提供 writePlane(PLANE_1) 看起来就是”写旧帧平面”,但读库源码发现它从双倍大小缓冲的后半段取数据,对单帧缓冲直接读越界。正确的调用是 PLANE_0_TO_1:把缓冲起始的数据写到 DTM1,正是所需语义:

src/display.cpp(新增)
static bool display_load_old_frame(uint8_t *old_image_buffer, int old_data_size)
{
// 校验:非空、'BM' 签名、尺寸 >= 62 + 800/8*480 = 48062 字节
flip_image(old_image_buffer + 62, bbep.width(), bbep.height(), false);
bbep.setBuffer(old_image_buffer + 62);
bbep.writePlane(PLANE_0_TO_1); // 旧帧 → DTM1(不是 PLANE_1,那个会越界)
bbep.setBuffer(NULL);
return true;
}

失败路径同样重要:旧帧不存在(首次开机)、读取失败、格式不对,统统降级到 FAST 而不是 FULL——FAST 模式约 600ms 完成且无闪烁,只是长期使用会积累轻微残影;FULL 才是那个两秒黑白闪烁的体验。降级打日志,不静默。

顺手修了一个上游 bug。固件本有”每 8 次刷新强制一次全刷”的防残影兜底,计数器 iUpdateCountRTC_DATA_ATTR(深睡保留)。但 BMP 渲染路径每次都把它重置成 1:

src/display.cpp:671(改动前)
iRefreshMode = REFRESH_PARTIAL;
iUpdateCount = 1; // use partial update ← 每次重置,(count & 7)==0 永远不成立

计数器永远在 1 和 2 之间打转,防残影全刷永远不会触发。删掉这行重置,让计数器跨深睡自然累计,兜底逻辑才真正活了。

一个保留的现状:固件里”刷新间隔 ≥ 30 分钟就用 FAST 防残影”的保护没动,所以真正的 partial 只在高频状态生效——P0 消息(15 分钟)和打印进度(5 分钟)。日常 30 分钟的仪表盘走 FAST,本来也无闪烁。

刷机:守株待兔抓唤醒窗口#

固件改完要刷进设备,而设备在深睡。这里有个 Espressif 官方文档写明的行为:ESP32-S3 深睡期间内置 USB Serial/JTAG 控制器断电,D+ 上拉消失,设备直接从主机上消失ls /dev/cu.usb* 什么都没有,不是线的问题。

设备每 30 分钟醒一次,醒着的窗口十几秒。窗口比完整刷写(1.2MB,约 40 秒)短,但这不要紧——只要 esptool 在窗口内连上并把芯片复位进 bootloader,固件就不再运行,自然也不会再睡,后面想刷多久刷多久。

于是写个守候循环,串口一枚举就出手:

Terminal window
while true; do
PORT=$(find /dev -maxdepth 1 -name 'cu.usbmodem*' | head -1)
if [ -n "$PORT" ]; then
esptool.py --chip esp32s3 --baud 460800 --port "$PORT" \
write_flash 0x10000 firmware_app.bin && break
fi
sleep 0.3
done

app-only 刷在 0x10000,NVS 里的 WiFi 凭据原封不动,刷完自动重启,新固件直接连新域名。验证手段也是现成的:版本号特意从 1.6.7 提到 1.6.8,设备下次心跳的 FW-Version header 就是刷机成功的回执,不用接串口看日志。

实战结果:设备重新上电的瞬间守候脚本就出手了——

PORT DETECTED: /dev/cu.usbmodem2101 at 18:38:42
Wrote 1239632 bytes (792342 compressed) at 0x00010000 in 8.8 seconds
Hash of data verified.
FLASH_OK 18:38:55

13 秒从枚举到刷完。硬复位后 21 秒,服务端遥测表里出现了第一条 FW-Version: 1.6.8 的心跳,Caddy 访问日志确认 /api/display 和图片下载全部打在新域名上。全程没碰设备上的任何按钮。

TIP

第一版守候脚本用了 zsh 的 glob(ls /dev/cu.usbmodem*),glob 不匹配时 zsh 在 ls 执行前就报错,每 0.3 秒刷一行 “no matches found”,几十分钟下来日志文件膨胀到几十 KB。换 find 一行解决。守候类脚本里别用裸 glob。

状态页:监控不能和被监控的住一起#

最后一件事,给整个平台配 uptime 监控。原则只有一条:状态页不能部署在被它监控的那台服务器上——服务器挂的时候,状态页得活着告诉你它挂了。

选了 UptimeFlare:开源,跑在 Cloudflare Workers 上(worker 每分钟巡检写 D1,Pages 出状态页),物理上和我的服务器零重叠,免费额度完全够用。fork 之后改一份 uptime.config.ts 配 14 个监控项,覆盖平台所有公开端点。一个小技巧:鉴权服务没有公开的 /health 路由,就监控一个必然 401 的端点——401 也是活着的证明

{
id: 'auth',
target: 'https://auth.example.com/api/users/me',
expectedCodes: [401], // 没带凭据被拒 = 服务在工作
}

部署走 fork 的 GitHub Actions(Terraform + next-on-pages),需要一个 Cloudflare API token。权限按最小集给:Workers Scripts、Pages、D1 的编辑权 + 账户设置只读。第一次部署在一个意想不到的地方失败——migrate_kv.py,一个给老用户做 KV → D1 数据迁移的脚本(UptimeFlare 在 2026 年 1 月把存储从 Workers KV 换成了 D1)。全新部署根本没有 KV 数据,但脚本列 KV namespace 时因为 token 没给 KV 权限直接报错退出。给 token 加权限是一条路,但更合理的是让脚本认得这个场景:

deploy/migrate_kv.py(fork 补丁)
if not r['success']:
# 没有 KV 权限的 token = 全新 D1 部署,无可迁移
if any(e.get('code') == 10000 for e in r.get('errors', [])):
print("Token has no Workers KV access - fresh deployment, skipping.")
exit(0)

还有个小坑:fork 仓库的 push 事件不会触发它自带的 deploy workflow,每次改完配置要 gh workflow run 手动触发一下。

部署成功后状态页上 14 个监控项全绿,响应时间曲线开始累积。设备刷机落地、过渡 snippet 删掉之后,最后一步交给 Cloudflare Pages 的 custom domain 流程——它会检测到旧 DNS 记录并自动替换成指向 Pages 部署的 CNAME,一个 UI 流程同时解决域名绑定和 DNS 切换。status.example.com 至此做回了它字面上的工作。

迁移完还撞上一个小尾巴:显示服务的网页端用 180 天的 HttpOnly cookie 做查看鉴权,而 cookie 是跟着 host 走的——域名一搬家,浏览器里立刻 401。修复就是在新域名上重新走一次 /?key=<token> 的 cookie 设置流程。换域名清单里值得加上这一条:凡是 cookie 鉴权的站点,迁移后所有客户端都要重新登录一次。

收尾#

昨天那篇的核心论点是”所有逻辑放服务端,改东西永远是 git push 不是刷机”。今天这轮升级恰好是个注脚:五件事里四件纯服务端,唯一动固件的 partial refresh,目的也是把设备端打磨到”以后更不用动”——旧帧恢复 + 防残影兜底落地之后,刷新策略的调整余地(什么状态用什么模式、间隔多长)又全部回到了服务端的 refresh_rate 一个字段上。

设备还是那个设备:醒来、要一张图、贴上、睡觉。它不知道域名搬了家,不知道消息进了总线,不知道有个状态页在 300 个城市的边缘节点上替它守夜。挺好。

深睡的墨水屏没有"旧帧":TRMNL 自建系统第二轮升级
https://blog.lishuyu.app/posts/trmnl墨水屏第二轮升级局部刷新/
作者
猫猫魔女
发布于
2026-06-11
许可协议
CC BY-NC-SA 4.0