桌上有一块 TRMNL 7.5 英寸墨水屏(OG DIY Kit,主控是 Seeed XIAO ESP32-S3),出厂逻辑是连官方云、半小时拉一张图刷屏。我想让它显示自己的东西:GitHub 贡献、LeetCode 进度、AI 用量、3D 打印机状态、自建服务的通知消息。
这篇记录从早上刷固件开始,到凌晨夜间模式自动触发为止的完整过程。核心架构决策一句话能说完——所有逻辑放服务端,设备只当画框——但路上踩的坑值得每一个都写下来:ESP32-S3 退不出下载模式的 bootloader bug、light sleep 杀掉 USB 串口的”假死”、GitHub events API 对私有仓库返回空 payload、LeetCode GraphQL 的字段名别名陷阱、部署 webhook 在并发 push 时静默丢失、还有 1-bit 屏幕上”什么样的图形语言不是噪声”这个设计问题。
设备与架构:为什么所有逻辑都放服务端
TRMNL 的固件是一个深睡驱动的极简循环:
唤醒 → 连 WiFi → GET /api/display → 下载一张 800×480 的 1-bit 位图 → 推到墨水屏 → 按响应里的 refresh_rate 深睡 N 秒没有常驻运行的逻辑,没有本地渲染,没有布局代码。设备醒着的时间大概十几秒,剩下全在深睡。2000mAh 电池配 30 分钟刷新间隔,目标续航是按周算的。
这个架构决定了一件事:屏幕上显示什么,完全由服务端说了算。固件只认识”一张位图 + 一个睡眠时长”。这意味着:
- 改布局、加数据源、调刷新策略,全部是服务端
git push,设备永远不用重新刷机 - 状态判断(现在该显示仪表盘还是优先消息?)在服务端做,设备无感知
- 设备端的电量消耗只跟”醒几次、每次醒多久”有关,跟显示内容复杂度无关
TRMNL 官方把这个模式叫 BYOS(Bring Your Own Server),有官方文档和配套的自建服务端实现(Terminus 等)。配网页面里就藏着一个自定义 API server 字段。我没有用现成的 BYOS 服务端,而是从零写了一个——因为我要把它集成进自己已有的服务平台(统一的部署管线、注册中心、鉴权),而且渲染逻辑本来就是这个项目的主体,没什么可复用的。
NOTE设备跑的固件是 Seeed 维护的开源 fork(这块 OG DIY Kit 是 Seeed 的 ESP32-S3 硬件方案,与官方 ESP32-C3 版硬件不同)。当天上午刚把它从出厂的 v1.5.12 升到 v1.6.7——app-only 刷写在
0x10000,保留 NVS 里的 WiFi 凭据。这段相对常规,不展开。
第一步:从固件源码提取协议
要让设备认我的服务器,先得知道它说什么话。协议没有完整文档,但固件是开源的,直接读源码最准。关键文件三个:
src/api-client/setup.cpp/display.cpp— 请求怎么发、带什么 headerlib/trmnl/src/parse_response_api_display.cpp— 响应怎么解析,这是真正的 schema 定义src/bl.cpp— 主循环,图片下载和刷屏逻辑
提取出来的协议是四个端点:
GET /api/setup/(注意尾部斜杠,固件就是这么拼的)。设备首次注册用,header 里带 ID(MAC 地址)和 FW-Version,期望响应:
{ "status": 200, "api_key": "...", "friendly_id": "...", "image_url": "https://.../setup.bmp", "message": "欢迎语"}status 不是 HTTP 状态码,是 JSON 字段。非 200 固件按注册失败处理。
GET /api/display。每次唤醒的主请求,header 是设备遥测的运输通道:ID、Access-Token(setup 拿到的 api_key)、Battery-Voltage、RSSI、FW-Version、Refresh-Rate、Width、Height。响应:
{ "status": 0, "image_url": "https://.../image/abc123.bmp", "filename": "abc123", "image_url_timeout": 30, "refresh_rate": 1800, "update_firmware": false, "firmware_url": "", "reset_firmware": false, "special_function": "", "action": ""}这里有个反直觉的点:成功是 status: 0,不是 200。setup 用 200 表示成功,display 用 0,两个端点约定不一致,照抄固件解析代码就对了。filename 是去重键——固件会记住上次的 filename,相同就跳过刷屏(墨水屏刷新有物理损耗,能省则省)。refresh_rate 就是接下来深睡的秒数,服务端用它控制设备节奏。
GET <image_url>。单独的请求拉图片。固件通过 Content-Type: image/png 或文件头 BM 魔数判断格式,有 90000 字节的大小上限。
POST /api/logs。设备日志上传,离线时缓存在 NVS,联网后补传。
1-bit BMP 合约:48062 字节
固件期望的图片格式值得专门算一遍,因为这是服务端渲染的硬约束:
- BMP 文件头 14 字节 + DIB 头(BITMAPINFOHEADER)40 字节 + 1bpp 调色板 2×4=8 字节 = 62 字节头部
- 800 像素 ÷ 8 = 100 字节/行。BMP 要求行对齐到 4 字节,100 正好整除,零填充
- 480 行 × 100 = 48000 字节像素数据
- 合计 48062 字节,一个字节不多不少
巧的是 Pillow 的 1-bit 模式直接命中这个格式:
img = Image.new("1", (800, 480), 255)buf = io.BytesIO()img.save(buf, format="BMP")assert len(buf.getvalue()) == 48062 # 恒成立BMP 的行序是 bottom-up,但固件自己处理了翻转,Pillow 的标准输出直接可用。这个 48062 后来成了测试套件里钉死的合约——每种屏幕模式渲染出来必须正好这个长度,多一个字节就是格式坏了。
图片分发:内容寻址 + 缓存兜底
/api/display 和图片下载是两个独立请求,中间隔着几百毫秒到几秒。服务端的处理是渲染时就把图片放进内存 LRU,key 用内容的 sha1 前 16 位:
blob = render.render_screen(scr.mode, data)key = hashlib.sha1(blob).hexdigest()[:16]cache_image(key, blob) # OrderedDict LRU,上限 16 张return {"image_url": f"{public}/image/{key}.bmp", "filename": key, ...}内容寻址在这里一石三鸟:
- filename 去重不用专门实现——固件按 filename 判断”画面没变就不刷屏”,而内容相同的两帧 sha1 必然相同。数据没更新的两次唤醒,设备自动跳过刷新,省下墨水屏寿命
- URL 不可猜测——16 位十六进制的 key 本身就是个 capability,后面做鉴权时图片端点可以放心保持匿名
- 缓存丢失可恢复——如果服务恰好在 display 响应和图片请求之间重启(部署日常发生),内存缓存清空,设备拿着旧 key 来取图。处理方式是 cache-miss 时现场重新渲染当前状态返回。画面可能比 key 对应的那帧新几秒,但设备永远能拿到一张合法图片,不会黑屏
第三点是部署频繁的自建服务的刚需:这一天服务重启了十几次,没有一次造成设备端异常。
服务端骨架
服务端是个 FastAPI 应用,挂在我自己的服务平台上(统一的 systemd + Caddy + git push 自动部署管线,这套平台本身是另一个故事)。存储用 SQLite(WAL 模式),四张表:设备、遥测时间序列、设备日志、消息。
设备注册用 MAC 白名单:
DEFAULT_ALLOWED_MACS = "E0:xx:xx:xx:xx:xx" # 只有我这一块屏
@app.get("/api/setup/")def api_setup(request: Request) -> dict: mac = request.headers.get("ID", "") if not mac or normalize_mac(mac) not in allowed_macs(): return {"status": 404, "message": "MAC address not registered"} device = get_or_create_device(db, mac) return {"status": 200, "api_key": device["api_key"], ...}写这段的时候预判了一个切换服务器特有的死角:设备 NVS 里存着旧服务器发的 api_key,而且拿到 key 之后永远不会再调 setup。也就是说切到新服务器后,设备会拿着一个我从没见过的 token 来请求 /api/display。如果只认 token,设备就永久卡死在”401 但又不会重新注册”的状态。解法是给 display 加一个收养(adoption)回退:
device = device_by_api_key(db, api_key)if device is None: # 设备保留着前一个服务器发的 key,永远不会重跑 setup—— # 白名单内的 MAC 直接收养,而不是拒绝 mac = request.headers.get("ID", "") if mac and normalize_mac(mac) in allowed_macs(): device = get_or_create_device(db, mac)后面实测证明这个预判完全正确——不过是以一种迂回的方式(设备最后走了全新注册路径),这个后面说。
状态机:P0–P3
屏幕显示什么由一个纯函数决定,每次设备唤醒时评估:
| 优先级 | 模式 | 触发条件 | 刷新间隔 |
|---|---|---|---|
| P0 | message | 存在未读、未过期的优先消息 → 整屏反白显示 | 15 分钟 |
| P1 | printing | 3D 打印机有活动任务 → 大进度条 | 5 分钟 |
| P3 | night | 当地时间 01:00–07:00 → 静态大时钟 | 60 分钟 |
| P2 | dashboard | 默认 | 30 分钟 |
def pick_state(now, *, has_priority_message=False, printing_active=False) -> ScreenState: if has_priority_message: return ScreenState(MODE_MESSAGE, 15 * 60) if printing_active: return ScreenState(MODE_PRINTING, 5 * 60) if NIGHT_START_HOUR <= now.hour < NIGHT_END_HOUR: return ScreenState(MODE_NIGHT, 60 * 60) return ScreenState(MODE_DASHBOARD, 30 * 60)纯函数的好处是测试不用 mock 任何东西,传时间和标志位进去断言就行。注意 refresh_rate 跟着模式走:打印中 5 分钟一刷盯进度,夜里 1 小时一刷省电。刷新策略本身也是服务端逻辑,这是这套架构最舒服的地方。
部署插曲:DNS 负缓存
服务部署上线后配了一个子域名。我在 DNS 记录添加之前手贱探测了一次域名——macOS 的 mDNSResolver 把 NXDOMAIN 缓存了下来。之后记录生效,dig @1.1.1.1 能解析,但 curl(走系统 getaddrinfo)一直报”无法解析”。
dig +short status.example.com @1.1.1.1 # 有结果curl https://status.example.com/health # curl: (6) Could not resolve host非特权的 dscacheutil -flushcache 清不掉,要 sudo 加 killall -HUP mDNSResponder,或者干等过期。期间用 --resolve 绕过本地解析做了完整验证:
IP=$(dig +short status.example.com @1.1.1.1 | head -1)curl --resolve status.example.com:443:$IP https://status.example.com/health教训:给新域名做”是否生效”探测时,第一发 query 永远打权威或公共 DNS,不要打系统解析器——你的负缓存会比 DNS 传播更持久。
把设备指过来:一段曲折
服务端就绪,剩下让设备改连我的服务器。理论上有不刷机的路:固件从 NVS 读 API 地址(preferences.getString("api_url", API_BASE_URL)),captive portal 配网页面的 Advanced Configuration 里有 Custom Server 字段。
先确认 portal 真有这个能力。配网页面的 HTML 在固件源码里不是明文——是 gzip 压缩后的字节数组(const uint8_t ADVANCED_HTML[] PROGMEM = { 0x1f, 0x8b, ... },0x1f 0x8b 是 gzip 魔数)。用正则把字节数组抠出来解压:
arrays = re.findall(r'const uint8_t (\w+)\[\] PROGMEM = \{(.*?)\};', src, re.S)for name, body in arrays: data = bytes(int(x, 16) for x in re.findall(r'0x[0-9a-fA-F]+', body)) html = gzip.decompress(data).decode()解压出来的页面确认了两件事:Custom Server 输入框存在,但藏在 Advanced Configuration → 一个警告弹窗确认之后——手动配网时非常容易漏掉;而 /connect 这个 POST 接口直接接受 {ssid, pswd, server} 三个字段,server 写进 NVS 的 api_url。
于是第一次尝试走 portal 自动化:长按设备按钮 5 秒清除 WiFi 凭据(源码里按键时长分三档:1 秒内单击、5–15 秒清 WiFi、15 秒以上全擦 NVS),设备开出名为 TRMNL 的开放热点,我让 Mac 临时切到这个热点,直接 POST 配网接口,跳过整个网页 UI。
这里有个真实的风险点:Mac 切到设备热点的瞬间就断网了,而我自己跑在这台 Mac 上。解法是把”切热点 → 等 DHCP → POST → 切回家里 WiFi”打包成一个带 trap 兜底的脚本原子执行,执行期间不需要任何网络往返,结束时网络已恢复。
脚本失败了,但失败得很有信息量:
joined TRMNL AP on attempt 1lease: 4.3.2.2ERROR: no portal lease我在脚本里断言 portal 网关是 192.168.4.1(ESP32 SoftAP 的常见默认值),实际拿到的 DHCP 租约是 4.3.2.2——这个 portal 的网关是 4.3.2.1。一些 captive portal 实现故意用这种”看起来像公网”的地址段来触发系统的强制门户检测。脚本判断错了租约就提前退出,没发出那一发 POST。
正要修脚本,需求本身变了:手动在 portal 里完成了 WiFi 登录,但 Custom Server 藏在 Advanced 入口后面容易漏掉,与其再折腾一轮 portal,不如直接走更彻底的路——把服务器地址烤进固件。反正这块设备的固件本来就是自己从源码编译的:
#define API_BASE_URL "https://status.example.com"一行改动,编译,app-only 刷写到 0x10000(NVS 不动,刚配好的 WiFi 凭据保留)。NVS 里的 api_url 如果存在仍然优先,所以 portal 那条路也没堵死。
下载模式:退不出去的 boot:0x21
刷写本身 9 秒完成,hash 校验通过,esptool 照例”Hard resetting via RTS pin…”。然后设备就没动静了。接串口监视一看:
rst:0x15 (USB_UART_CHIP_RESET),boot:0x21 (DOWNLOAD(USB/UART0))waiting for download设备复位了,但又进了下载模式。用 esptool 再做一次标准 hard-reset,还是 boot:0x21。RTS/DTR 怎么拉都出不来。
这是 ESP32-S3 的一个已记录问题(esp-idf issue IDFGH-12237):通过按住 BOOT 上电进入的 USB Serial/JTAG 下载模式,在某些情况下软复位无法退出——按设计 RTS/DTR 序列应该能正常退出,实际上 bootloader 状态没有被正确带出来,这是个 bootloader 侧的 bug 而非 strapping 引脚电平问题。Workaround 简单粗暴:物理断电重启,不碰 BOOT。断电一次,设备正常启动,问题消失。
一个差点误诊的”假死”
这天早些时候升级固件时还撞过另一个 ESP32-S3 特有的坑,顺便记在这:串口日志在 EPD refresh mode: 之后突然静默,看起来像固件挂死。实际上是这块屏的驱动在等待墨水屏 BUSY 信号时调用了 esp_light_sleep_start() 省电——light sleep 会让 USB Serial/JTAG 外设停止工作(APB 和 USB PHY 时钟被门控,这在 Espressif 文档和 arduino-esp32 #6581 等 issue 里都有记录)。固件活得好好的,只是 USB 串口死了。判断依据最后是屏幕本身刷出了新版本号。
用 USB-CDC 调试带深睡/浅睡逻辑的固件,“日志静默”不等于”程序挂死”,这条价值很高。
首次连通
断电重启后,服务端日志里依次出现了教科书般的四步:
GET /api/setup/ 200 ← 设备注册(NVS 被之前的折腾清过,走了全新注册)GET /image/setup.bmp 200 ← 欢迎屏GET /api/display 200 ← 拿到 dashboard 指令,refresh_rate 1800GET /image/d04a43....bmp 200 ← 下载 48062 字节的首帧随后遥测入库:电压 4.09V、RSSI -47dBm,然后设备深睡——USB 串口设备从系统里消失(深睡时 USB 外设下电,这是正常现象,也是判断”它真的睡了”的旁证)。
从这一刻起,USB 线就可以拔掉了。之后的一切变更都不再碰设备。
渲染器:从文字到图形的三轮迭代
v4:文字版
第一版 dashboard 按一张手绘 mockup 实现:32px header(日期时间、天气、Tailscale 在线数、年进度、电池),顶排三列(GitHub / LeetCode / AI 用量,5:5:4),Messages 全宽行,Bambu 打印机全宽行。内容以文字行为主,配一个 7 日提交小柱状图。
渲染管线有个值得说的细节:文字先画在灰度(“L”模式)画布上吃抗锯齿,再按阈值二值化:
img = Image.new("L", (WIDTH, HEIGHT), 255)# ... 所有绘制 ...bw = img.point(lambda v: 0 if v < 160 else 255)bw.convert("1").save(buf, format="BMP")直接在 “1” 模式画布上画字会得到无抗锯齿的毛边;先灰度后阈值,笔画边缘吃进部分灰阶像素,小字号下明显更饱满。阈值 160 偏向”宁可粗一点”——墨水屏的阅读距离比显示器远。
所有图标——太阳、对勾、电池、带圈数字——全部用图元手绘而不是字体字符。原因:服务器的 DejaVuSansMono 和本地 Menlo 的 glyph 覆盖范围不一致,一个 ✓ 在 A 字体里正常、在 B 字体里渲染成豆腐块,这种事故只需要发生一次就够了。
v4 的像素级返工
mockup 对齐从来不是一次到位的。第一版渲染出来对照设计稿,三处不对:
- LeetCode 的题目名”Most Profitable Path in a Tree”被截断成
…——30 个字符在 16px 等宽下要 289px,列宽只有 258px。降到 14px 正好放下 - GitHub 第二行的
CI ✓整个消失,变成一个像下划线的省略号——截断函数把字符串截短时连同手绘对勾的占位字符一起吃掉了,同样靠降字号让整行放下解决 - AI 面板的文字右边缘贴到了面板边框,16px 时整行 202px、列内宽 204px,留 2px 余量等于没有余量,降到 15px
这类问题没有捷径,就是渲染 → 放大看 → 量像素 → 调字号,循环到每行都有呼吸感为止。等宽字体唯一的好处是宽度可预算:字符数 × 单字宽,心算就能预判会不会爆。
消息系统的语义设计
Messages 行和 P0 抢占共享一套存储,语义上有几个值得记录的决定:
- priority ≥ 1 的消息必须有 TTL(默认 1 小时)。一条优先消息会让设备以 15 分钟间隔反复刷整屏反白——如果没人去标已读,它不能永远霸屏。普通消息没有 TTL,自然滚出显示窗口即可
- “已读”是显式操作(
POST /api/messages/{id}/read),设备显示过不算已读。墨水屏挂在墙上,“屏幕显示过”和”人看到了”是两回事 - dashboard 的消息行分两层:未读未过期的取最近 3 条加粗显示,其余的压缩成一行小字历史。博客超过 14 天没更新时,提醒也混进历史行——它本质上也是一条”陈旧但不紧急”的信息
数据采集层:六路数据源,六种坑
dashboard 上线时所有面板都是占位符。数据采集层的架构是后台快照刷新:一个 asyncio 任务每 5 分钟并发拉取全部数据源写入内存快照,设备请求只读快照——上游再慢再挂,都不可能阻塞设备那十几秒的唤醒窗口。每个源失败时保留上一次的好值(stale 优于空白)。
每个数据源都有自己的故事:
天气:Open-Meteo,免费免 key(非商用,30 万次/月限额),传经纬度返回当前温度和当日高低。没什么坑,是唯一一个一次写对的。
Tailscale:本来以为要申请 API key,后来意识到服务器自己就在 tailnet 里——直接在服务器上执行 tailscale status --json 数在线设备就行。唯一的坑是 tailscaled 的 socket 默认只允许 root 访问,给服务的运行用户开个 operator 即可:
tailscale set --operator=displayserviceGitHub:两个数据要拿——贡献日历(大数字 + 热力图 + streak)和”今天往哪些仓库推了几个 commit”。日历走 GraphQL contributionsCollection 没问题;今日 per-repo 计数最初用 REST events API 数 PushEvent,结果屏幕上显示 api +0——events API 对私有仓库返回的 payload 是空的,commits 数组、distinct_size 都没有。换成 GraphQL 一条查询全解决,用别名同时要两份 contributionsCollection:
query($login: String!, $from: DateTime!, $to: DateTime!) { user(login: $login) { contributionsCollection { contributionCalendar { weeks { contributionDays { date contributionCount } } } } today: contributionsCollection(from: $from, to: $to) { commitContributionsByRepository(maxRepositories: 2) { repository { name } contributions { totalCount } } } }}LeetCode:公开 GraphQL(leetcode.com/graphql),每日一题不用登录就能查。第一版查询直接 400,报错信息说 frontendQuestionId 字段不存在。真相比较微妙:schema 里的真实字段是 questionFrontendId,而社区里流传的查询大量写成 frontendQuestionId: questionFrontendId——那是个别名。如果你从别人的响应 JSON 里抄字段名,抄到的是别名,直接当字段用就会 400。
博客 RSS:dashboard 要显示”距上次发文 N 天”(超过 14 天就上屏提醒我),直接拉自己博客的 RSS 解析最后一篇的 pubDate,正则就够了,不值得引依赖。
Bambu 打印机:Bambu Cloud 的任务列表 API 需要账号 token。坑在登录方式——账号是 Google OAuth 注册的,没有密码,密码登录流走不通。解法是 Bambu 的邮箱验证码登录:请求验证码 → 邮箱收码 → 用 {account, code} 换 accessToken,OAuth 账号同样适用。写了个交互式脚本处理这条流,token 直接写进服务器的环境文件。
顺带一提,任务列表返回的字段也有个小坑:title 是打印参数描述(“0.2mm layer, 2 walls, 15% infill”),真正的模型名在 designTitle,取名优先级要对调。
AI 用量(Claude Code + Codex 的 token 消耗):这个数据没有公开 API,数据在本地机器上,所以方向反过来——本地 cron 半小时上报一次。原计划用 ccusage 统计,但在我这台机器上新版直接起不来:
dyld[61122]: Library not loaded: /nix/store/xvmh...-libiconv-109.100.2/lib/libiconv.2.dylib下载到的 darwin-arm64 预编译二进制链接了 /nix/store 路径的动态库,没装 Nix 的机器必死。与其降级版本,不如自己解析——这两个工具的用量数据本来就是本地明文 JSONL:
- Claude Code:
~/.claude/projects/**/*.jsonl,每行带message.usage(四种 token 计数)和时间戳,用(message.id, requestId)去重 - Codex:
~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl,token_count事件里的total_token_usage是累计值,每个文件取最后一条即该会话总量;目录按日期分层,天然好统计
一百多行标准库 Python,零依赖,crontab 每 30 分钟跑一次 POST 到服务端。
P0 真机实测
数据通了之后顺手做了一次端到端验证:往消息接口 POST 一条 priority: 1 的测试消息,等设备下一次唤醒。遥测里如期出现:
{"ts": ..., "v": 4.17, "rssi": -55, "mode": "message"}设备在毫不知情的情况下,这一觉醒来拿到的是一张整屏反白的优先消息图,下一次唤醒间隔自动变成 15 分钟。消息标记已读后,下一帧又回到 dashboard。整个抢占-恢复流程,设备端零参与。
v5:图形化(以及为什么它很丑)
文字版 dashboard 跑通后,收到明确反馈:“我不喜欢文字,使用可视化”,外加一张参考图——GitHub 贡献热力图、LeetCode 圆环图、百分比图表。于是 v5 把每个面板都图形化了:16 周贡献热力格、E/M/H 解题圆环 + 进度条、AI 用量占比环、AMS 料槽液位条、消息时间线。
图形化让面板的数据需求也变了:热力格要 112 天的逐日计数(GraphQL 日历本来就有全年数据,取最后 112 天即可);LeetCode 进度条要”已解 / 题库总量”的分母,加一个 allQuestionsCount 查询就有(E 949 / M 2067 / H 942);圆环的弧段绘制用 Pillow 的 arc,从 12 点钟方向起算:
angle = -90.0for frac, style in segments: sweep = 360.0 * frac draw.arc(box, angle, angle + sweep - 8, fill=BLACK, width=9) # 8° 留白分段 angle += sweep1-bit 没有颜色,我用纹理区分层级:热力格四档密度(空心描边 / 中心点 / 棋盘格 / 实心),圆环分段用实线 / 虚线 / 细线。
放大一看,丑得很诚实。下一条反馈原话:“你不觉得丑吗?”
确实丑。问题出在设计语言本身:
- 每个空格子都带 1px 黑描边,112 个格子排在一起像一张二维码,不像热力图。GitHub 的原版能用浅灰格子是因为它有 256 级灰度,空格子近乎隐形;1-bit 下描边就是实打实的黑线
- 棋盘格纹理在 7px 的格子里就是噪声,没有”50% 灰”的视觉效果,只有脏
- 虚线圆环像齿轮,3° 的段间隙看起来像渲染 bug
v6:1-bit 的正确设计语言
重写的原则一句话:用尺寸编码强度,不用纹理;少描边,多留白。
- 热力格:删掉全部描边。空 = 1 像素锚点(保持网格形状),低/中/高 = 3/5/7px 实心方块。强度由方块大小表达,远看就是 GitHub 热力图的质感
- 分档从”全局峰值线性分三档”改成非零值的三分位数——某一天 30 个 commit 的峰值会把其他正常日子全压扁到最低档,分位数才能保住层次(GitHub 自己用的是四分位数,同一个道理)
- 圆环:全部实心弧,段间留 8° 白缝区分;次要数据(Codex 相对 Claude Code)用细弧而不是虚线
- 进度条全实心,E/M/H 用字母标注就够了,纹理是冗余信息
- 手绘的火焰 streak 图标——在 14px 下怎么画都像墨团——删掉,回归
94d文字。画不好的图标不如不画
字体宽窄之谜
图形化过程中还有一条反馈:“字体有的宽有的窄。“这个问题的根因藏得比较深:本地预览用 macOS 的 Menlo,服务器渲染用 DejaVuSansMono,两个等宽字体的字符宽度不一样(Menlo 更窄)。用户两边对照着看,自然发现”宽瘦不一”。
解法是把 DejaVuSansMono(含 Bold)直接打包进 Python 包里——DejaVu 的许可证(Bitstream Vera 派生)允许自由再分发和嵌入。字体加载顺序变成:包内捆绑 → 系统 DejaVu → Menlo 兜底。从此本地预览、服务器渲染、墨水屏输出三者逐像素一致。顺手把小字号下限从 10px 收敛到 11px——二值化后 10px 的笔画太瘦,远看发虚。
可视化状态页
JSON 端点看数据反人类,给服务根路径做了个自包含的状态页:原生 JS + SVG,无构建无依赖。四块内容:
- 设备屏幕实时预览(四个模式 × live/sample/empty 三种数据可切换)
- 电池电压曲线(双轴:V 和百分比;每个数据点按模式画不同符号——● dashboard ▲ message ■ printing ◇ night)
- 六路采集器健康状态(实心点 = 正常 + 最后刷新时间,出错直接显示错误摘要)
- 消息流(未读加粗,优先消息带旗标)
放电斜率和续航预测做成了一个独立端点,对遥测点做线性回归:
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den # V/天projected = (latest_v - 3.3) / -slope # 距 3.3V 截止还有几天数据攒满一周后用它校准刷新策略——如果预测续航低于 4 周,就把 dashboard 间隔从 30 分钟放宽。
鉴权:盘点暴露面
上线一天后自查访问控制,结果挺难看——所有读取端点都是匿名 200:
anon / → 200 状态页(含我全部个人数据)anon /messages → 200 消息全文anon /telemetry/battery → 200anon /preview/dashboard?live=1 → 200anon /docs → 200 swagger 全家桶anon POST /api/logs → 200 匿名可写库(!)修复方案按端点性质分三类:
设备协议端点保持无鉴权。固件只会发 MAC 和 api_key 两个 header,没有任何让它做现代鉴权的办法。安全性靠两层:setup/display 有 MAC 白名单(48-bit 空间,不可枚举),图片 URL 的 key 是 sha1 前 16 位——不可猜测的 capability URL,知道 URL 本身就等于持有访问权,这对”一张仪表盘截图”级别的敏感度足够了。
人类端点上 view token,三种递交方式同一个 token:浏览器首次访问 /?key=<token> 换一个 180 天的 HttpOnly cookie 然后重定向(之后无感);curl 用 Authorization: Bearer;临时访问用 ?key= 查询参数。
supplied = ( request.cookies.get("ds_view", "") or request.headers.get("Authorization", "").removeprefix("Bearer ").strip() or request.query_params.get("key", ""))if not supplied or not secrets.compare_digest(supplied, expected): raise HTTPException(status_code=401)写入端点修漏洞:POST /api/logs 原来不验身份就入库,是个白送的存储型 spam 入口。改成必须持有效设备 key 或白名单 MAC。swagger 直接禁用——个人服务不需要给全网开 API 目录。
测试里有个小细节:cookie 设了 secure=True,TestClient 默认的 http://testserver 下 httpx 不会回传 secure cookie,断言”cookie 带上后第二次请求 200”必挂。解法不是去掉 secure(生产在 Cloudflare 后面全程 HTTPS,secure 是对的),而是让测试客户端用 base_url="https://testserver"。
测试策略:合约钉死,纯函数全覆盖
最终测试套件 56 个用例,零网络、零 mock 框架,全程跑完 0.3 秒。分层思路:
格式合约层:四种模式 × 两种数据状态(样例满载 / 全空冷启动)共八种组合,每一种都断言输出是 BM 开头、恰好 48062 字节、PIL 能解析为 800×480 的 mode “1”。冷启动那组尤其重要——采集器全挂时每个面板都拿到 None,渲染器不许崩,必须画出占位符
视觉断言层:不做像素级 golden image(字体微调就会全挂,维护成本不值得),用墨水覆盖率做粗粒度断言——dashboard 样例数据的黑色像素占比应在 2%–50% 之间,P0 反白屏应超过 50%。能抓住”整屏全白”和”整屏全黑”这类灾难性回归,又不脆
纯函数层:状态机、电池电压映射、token 格式化、GitHub 日历解析(streak 跨越”今天还没提交”的边界条件)、LeetCode 响应解析、RSS 日期提取——全部是数据进数据出,不需要任何环境
协议层:FastAPI TestClient 走完整 HTTP 栈:注册幂等性、MAC 收养、P0 抢占后 refresh_rate 变 900、标已读后恢复、鉴权矩阵(匿名 401 / Bearer 200 / 设备协议放行)
外部 API 的真实交互(GitHub GraphQL、LeetCode、Bambu)不进测试——解析函数用会话里抓回来的真实响应做 fixture,网络层的失败由采集器的 stale-on-failure 机制兜底,线上 /collectors/status 端点暴露每个源的最后错误。
部署管线的两个真坑 + 一次自我打脸
这个项目部署在自建平台上,push 到 main 由 webhook 触发部署。这天连续快速 push 时撞出两个平台级问题:
坑一:部署进行中到达的 webhook 会丢。push A 触发部署(rsync + uv sync + systemctl restart,要跑十几秒),这期间 push B 的 webhook 到达——没有队列,直接丢弃。B 的代码永远不会部署,而且没有任何报错。
坑二:空 commit 不触发部署。发现 B 没部署后,我推了个 --allow-empty 的空 commit 想重新触发——webhook 到了,但部署器是按 push 的 diff 决定哪些服务需要重新部署的,空 diff = 无事发生。正确做法是推一个真实的文件变更(部署时 rsync 的是仓库 HEAD,丢失的变更会一起带上)。
然后是自我打脸环节。修完之后我的”部署是否生效”轮询检测连续三轮超时,我一度认定部署管线还有更深的问题,上服务器逐层排查——journalctl 显示部署跑了、work clone 是最新 commit、目标目录的文件 md5 和最新代码完全一致。代码其实早就部署上了。
假阳性有两层:我 grep 服务器文件用的特征字符串大小写写错了(grep 默认大小写敏感);而轮询检测用的”渲染图墨水密度”阈值根本区分不了新旧版本——v5 带描边的格子和 v6 的实心方块在采样区域的总墨量恰好几乎相等,检测值十几分钟纹丝不动,我却把它读成了”还是旧版”。
教训浓缩成一条:写部署检测器时,先拿新旧两个版本的真实输出标定检测指标,再上线检测器。没标定过的检测器给出的”失败”,浪费的排查时间比没有检测器还多。
排查路上还顺手撞见第三个干扰项:Cloudflare 会缓存 /preview/* 的图片响应。轮询同一个 URL 看到的”十几分钟纹丝不动”里有缓存的功劳——迭代调试这类动态生成的图片端点时,要么带 cache-buster 查询参数,要么后端显式发 Cache-Control: no-store。三个因素(grep 大小写、未标定阈值、CDN 缓存)叠在一起,把一次正常部署演绎成了四十分钟的”故障”。
收尾的两个瞬间
凌晨 01:20 查状态,遥测最后一行:
0.4 min ago mode=night 4.1V rssi -47P3 夜间模式在 01:00 整点后的第一次唤醒自动触发,屏幕换成大时钟,刷新间隔拉长到一小时。没有任何人工干预——状态机按设计自己走到了这一步。至此 P0/P2/P3 都在真机上验证过,P1 等下一次真实打印任务自然触发。
另一个瞬间在更早些时候。Bambu 数据源接通后,dashboard 的打印机面板第一次显示出真实数据:
BAMBU P2S idleTRMNL 7.5'' (OG) DIY Kit L-Shapedone 00:22 · 5h03m这台打印机最近打印的任务,是这块墨水屏自己的外壳支架。屏幕汇报的第一条打印记录是它自己的诞生过程,闭环闭得有点超出设计预期。
经验清单
最后把这一天的可复用结论压缩一下:
- 深睡设备的正确架构是服务端全权。设备只认”一张图 + 一个睡眠时长”,所有迭代都变成服务端部署,硬件刷机只发生一次(把服务器地址烤进去那次)
- 协议逆向直接读解析代码,不要读构造代码——
parse_response_*.cpp才是 schema 的事实定义。注意不同端点的成功约定可能不一致(200 vs 0) - 切换服务器要预判 token 死角:设备拿到 key 后永不重新注册,新服务端必须有白名单收养逻辑
- ESP32-S3 三个硬件坑:BOOT 上电进的下载模式可能软复位退不出(IDFGH-12237,断电解决);light sleep 杀 USB-CDC 串口(静默 ≠ 挂死);深睡时 USB 设备消失(是特性不是事故)
- GitHub events API 对私有仓库的 payload 是空的,要 per-repo 计数用 GraphQL
commitContributionsByRepository - LeetCode GraphQL 真实字段是
questionFrontendId,社区查询里的frontendQuestionId是别名——别从响应 JSON 抄字段名 - 1-bit 小尺寸图形的设计语言:尺寸编码 > 纹理编码;描边在密集排列下是噪声;分档用分位数不用全局峰值线性切;画不好的图标不如文字
- 渲染字体打包进应用,让预览和生产逐像素一致;文字先灰度抗锯齿再阈值二值化
- 部署管线:webhook 无队列时连续 push 会静默丢部署;空 commit 不触发 diff 型部署器;检测器要先标定再使用
- DNS 新记录的探测打公共 DNS,别让系统解析器吃进负缓存
设备现在挂在墙上,半小时醒一次,电池预计能撑一个多月。下一次想改屏幕上的任何东西——git push 就行。