让 Kindle 常显一块 Token 看板:先有设计,越狱只是手段
一台闲置的 Kindle Paperwhite 4 挂在桌上,常显团队的 Token 用量与各家 CLI 的额度余量。数据在树莓派上采集、渲染成灰度 PNG,经 USB 网络推给 Kindle 显示。

这篇文章记的是为什么这么设计。越狱、KUAL、USBNetwork、FBInk 全都只是"把一张 PNG 显示到那块屏上"的手段,它们不该影响上面任何一层的结构 —— 所以那部分单独拆成了越狱与显示通道实操。
墨水屏不是一块小号的显示器,它把两条平常不用管的约束变成了硬约束:刷新一次会黑一下(闪屏是可见的代价),灰度只有 16 级(颜色不能用来编码信息)。整套系统的形状基本是从这两条反推出来的。
什么算"合格的常显看板":五条达标线
先把标准立成可检验的问题,后文每个决策都对着这张表交代。
| 问题 | 达标线 |
|---|---|
| 不闪 | 屏幕每一次刷新都对应一次真实的数据变化;时间戳走动、渲染抖动都不许触发刷新 |
| 不骗 | 屏上写的时间是数据的时刻而不是拉取的时刻;某个维度查不到就不画,绝不拿几小时前的旧值凑一个看不出是旧的百分比 |
| 不塌 | 单个数据源失败只影响它那一块;进程重启后不空屏,也不把一份旧数据显示成刚更新 |
| 不绑上游 | 加一台上报机、加一个额度维度,看板这边不用改代码;上游改字段也不用 |
| 单源渲染 | Web 页面与墨水屏两个渲染端消费同一份数据契约,视觉规格一处定义,绝不双写 |
这五条各自否掉了一种看起来更省事的做法:定时无条件刷屏(卡"不闪")、拉取失败时显示 0(卡"不骗")、把采集器直接塞进渲染脚本(卡"不塌")、给每个上游写一个专用接口(卡"不绑上游")、Web 一套 HTML、Kindle 一套 Pillow 各画各的(卡"单源渲染")。
系统长什么样
职责切得很干净:树莓派是可替换的控制器,Kindle 是可替换的显示终端,中间只过一张最终 PNG。
flowchart LR
D[datain SQL<br>今日/本周 token] -->|拉取| B
CC[Claude Code 所在机器] -->|POST /api/report| B
CX[Codex 所在机器] -->|POST /api/report| B
B[树莓派 · FastAPI<br>每 60s 采样 → SQLite → 快照] -->|数据指纹变了才渲染| R[Pillow 灰度渲染]
R -->|SCP + FBInk| K[Kindle PW4<br>1072×1448 墨水屏]
B -->|GET /| W[Web 看板]
D -.->|直连,不经过树莓派| P[iPhone Scriptable 小组件]
一轮 tick 里发生的事,顺序本身就是设计:
gather 并发拉各源 → Reading(值 + 重置时刻) 单源失败只标 stale,沿用上一份
↓
SampleStore.append() 分钟级采样落 SQLite,重启后差分不归零
↓
build_snapshot() latest + delta + 重置文案/时间线 → 一份 Snapshot
↓
should_push() 数据指纹没变,到此为止(连渲染都省)
↓
render → push_now() Pillow 画横屏 → 旋转成竖屏 → SCP → FBInk
数据源三种形态混在一起:datain 是主动拉的 SQL,Claude Code 和 Codex 的额度是外部推进来的,Scriptable 小组件干脆绕开树莓派直连 SQL。下面几节讲的就是怎么让它们共用同一条流水线。
决策一:额度靠上报,不靠拉取
只有正在跑 Claude Code 的那台机器才能查到它自己的额度。树莓派要拉,就得在树莓派上装一整套 CLI、配好各家的登录态,或者 ssh 到每台机器上去执行 —— 两条路都是把看板绑死在上游的运行环境上。
所以反过来:树莓派只开一个接收口,各机器把自己的额度 POST 进来。
claude -p '/usage' | curl -sS -X POST --data-binary @- \
-H "X-Report-Token: <口令>" \
"http://<看板地址>:8000/api/report/claude_code/raw?host=$(hostname)"
新增一台机器不用改看板的任何代码。这件事在实现上几乎是免费的:PushSource 缓存最近一次上报,fetch() 直接把它返回,于是采样、差分、快照组装、状态跟踪全都不用知道这个源是推来的还是拉来的 —— 上报型源伪装成拉取型源,接进同一条流水线。
两个配套的规矩:
- 只有一个正式接口
POST /api/report,所有源共用同一个信封,区别只在limits里报几条。曾经给 Codex 单开过一个专用端点,已删 —— 那等于把看板绑死在上游的字段名上,上游改字段还得改看板。上游格式与信封的差异由上报脚本消化。 - 唯一例外是
claude_code/raw:Claude Code 的用量输出是给人看的纯文本,管道里没法用纯 curl 转成 JSON,不特事特办就做不到"一行命令上报"。解析放在看板侧(sources/claude_usage.py),上报机不需要装 python,CC 哪天改了文案也只改这一处。
不给 claude -p '/usage' 挂 cron
它每跑一次自己就要起一个非交互会话、吃掉一点额度 —— 定时跑等于"为了看用量而制造用量",观测本身污染了被观测的数字。想看最新的手动敲一次就行,看板不做保鲜期判定(见决策四)。
接入方要看的完整契约在上报接口交接文档,可以直接发给上报方。
决策二:一次 fetch 只有一条通道
数据源的抽象最初是四个方法平行输出:fetch() 给值、notes() 给重置文案、resets() 给重置时刻、data_at() 给数据时刻。它坏在一个不显眼的地方:
三份字典在调度层被 dict.update() 进全局状态,键只增不删。于是"resets() 刻意不返回已过期的维度"这个设计被彻底架空 —— 过期的键还挂在全局 dict 里,环上那条时间线会永远钉在 100%。加上 notes 用的键约定(*_reset)跟另外两个(metric 名)还不一样,要靠 note_key / note_map / note_from 一整套胶水去对齐。
收敛成一条通道之后,这些问题一起消失:
@dataclass(frozen=True)
class Sample:
"""一个 metric 的一次采样:数值 + (可选的)下次重置时刻。
resets_at 跟着数值走,不单独开通道:额度百分比和它的重置时刻本来就是
同一条记录的两个字段。已过重置时刻的采样,数值由源归零、resets_at 保留
原值(过去时刻)——快照层据此写"已重置 · 待上报"并隐藏时间线。
"""
value: float
resets_at: datetime | None = None
@dataclass(frozen=True)
class Reading:
"""一轮 fetch 的完整结果。
data_at: 这批数据本身的时刻;None = 就是"刚拉到的",由调用方用当前时刻。
上报型源必须填:它手里的数据可能是几小时前报上来的(尤其重启后回放的那份),
状态条要显示那个时刻,否则屏上写着刚更新过,其实是旧数据。
"""
samples: dict[str, Sample]
data_at: datetime | None = None
def values(self) -> dict[str, float]:
"""{metric: 数值},给 SampleStore 落盘与源状态指纹用。"""
return {metric: sample.value for metric, sample in self.samples.items()}
fetch() 返回 Reading(samples={metric: Sample(值, 重置时刻)}, data_at),调度器按源整体替换 state.readings —— 源不再产出的键自然消失,不需要谁去主动删。重置文案不再由源产出,它本来就是展示逻辑,改由快照层从 resets_at 现算。净删约一百行胶水。
重置文案必须现算,不能存
存下来的"明日 18:00 重置"过一夜就成了错话。同理,跨天的时间要带上日期(07-25 18:36)—— 否则一份昨天的数据只显示时分,跟刚更新的没区别,这直接卡"不骗"那条线。
数据源自己保持无状态:返回累计值,不返回增量。差分统一由 SampleStore 拿历史算,采样落 SQLite 而不是内存缓冲,重启后 15 分钟差分不归零。
sources/base.py —— 数据源抽象(真身)
"""数据源抽象:一个源 = 一次外部拉取,产出若干 metric 的结构化采样。
# [新: 2026-07-26] fetch 只此一条通道。曾经是 fetch()/notes()/resets()/data_at()
# 四个方法平行输出,且 notes 用的键约定(*_reset)和 fetch/resets(metric 名)还不
# 一样——于是需要 note_key/note_map/note_from 一整套胶水,而三份字典在调度层被
# update() 进全局状态、键只增不删,"过期的重置时刻该消失"这类语义根本落不了地
# (时间线会永远停在 100%)。收敛成"每个 metric 一条完整记录"后,调度层按源整体
# 替换,源不再产出的键自然消失;重置文案是展示逻辑,由快照层从 resets_at 现算。
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import ClassVar
@dataclass(frozen=True)
class Sample:
"""一个 metric 的一次采样:数值 + (可选的)下次重置时刻。
resets_at 跟着数值走,不单独开通道:额度百分比和它的重置时刻本来就是
同一条记录的两个字段。已过重置时刻的采样,数值由源归零、resets_at 保留
原值(过去时刻)——快照层据此写"已重置 · 待上报"并隐藏时间线。
"""
value: float
resets_at: datetime | None = None
@dataclass(frozen=True)
class Reading:
"""一轮 fetch 的完整结果。
data_at: 这批数据本身的时刻;None = 就是"刚拉到的",由调用方用当前时刻。
上报型源必须填:它手里的数据可能是几小时前报上来的(尤其重启后回放的那份),
状态条要显示那个时刻,否则屏上写着刚更新过,其实是旧数据。
"""
samples: dict[str, Sample]
data_at: datetime | None = None
def values(self) -> dict[str, float]:
"""{metric: 数值},给 SampleStore 落盘与源状态指纹用。"""
return {metric: sample.value for metric, sample in self.samples.items()}
class DataSource(ABC):
"""所有数据源的基类。
调用方约束:
- fetch() 由调度器每个采样周期调用一次,多个源之间用 gather 并发;
单源抛异常只把该源标为 error/stale,绝不阻塞其他源。
- fetch() 内部必须设超时(建议 <= 30s,小于采样周期)。
- samples 的 metric 键全局唯一(跨源不得重名),是 SampleStore 的主键之一。
- 源只要活着,每周期都要把它持有的全部 metric 重新给一遍——
store.latest(max_age) 靠这个约定识别"某维度不再被采集"。
"""
key: ClassVar[str] # 源标识,进 SourceStatus.key
label: ClassVar[str] # 展示名,进 SourceStatus.label
@abstractmethod
async def fetch(self) -> Reading:
"""拉取一次。数值是"累计值"而非增量——差分统一由 SampleStore
用历史算,源本身保持无状态。
"""
...
决策三:画什么、怎么排,看板说了算
额度维度的顺序、标题、显示已用还是余额,全都写死在看板的一张声明表里,不跟随上报。
# 顺序即外圈→内圈
CLAUDE_DIMENSIONS: list[Dimension] = [
Dimension("session_5h", "claude_session_pct", "时段限额 (5h)", window=_5H),
Dimension("week_fable", "claude_fable_pct", "Fable 模型",
resets_from="week", window=_WEEK, delta_mode="today"),
Dimension("week", "claude_week_pct", "周限额", window=_WEEK, delta_mode="today"),
]
CODEX_DIMENSIONS: list[Dimension] = [
Dimension("session_5h", "codex_session_pct", "时段限额 (5h)", window=_5H),
# Codex 那边关心的是"还剩多少",环本身仍画已用,只有图例大数字显示余额
Dimension("week", "codex_week_pct", "周余额",
show="remaining", window=_WEEK, delta_mode="today"),
]
理由有三条,一条比一条硬:
- "重置周期短的放外圈"是展示决策,上报方没有这个上下文 —— 外圈周长大,同样的变化弧长最长、最醒目(秒针在最外)。
- 三环是物理上限。半径按
148 - i*44递减,第四环只剩 16、几乎是个点;灰度档位G_RING只有三档,第四环会撞回最外圈的黑色。跟随上报等于让上游决定画不画得下。 - 上报偶发少一条会让环数抖动(网络重试半截、CLI 输出变化),墨水屏跟着刷 —— 直接违反"不闪"。
这张表以前散在两处:sources/push.py 的映射表和 snapshot.py 的环规格。加删一个维度要同时改两个文件,漏一处的后果很隐蔽 —— 上报侧删了、展示侧没删,那个环会挂着几天前的历史值,永不更新也不报错。合成一张表之后,换顺序 = 挪一行,加维度 = 加一行。
配套一条兜底:store.latest() 带 max_age,某个 metric 连续三个周期没进采样就当它不再被采集,那个环直接不画 —— 而不是拿旧值凑一个永不更新的百分比。这条只对限额环生效,两条主进度条不用:datain 断几分钟不代表今日 token 累计值失效,而且它们一消失双栏布局会塌。
dimensions.py —— 额度维度声明表(真身)
"""额度维度声明表 —— 限额类数据的唯一配置源头。
# [新: 2026-07-26] 这张表以前散在两处:sources/push.py 的 metric_map/note_map
# (上报 key → 内部 metric 名) 和 snapshot.py 的 _RING_SPECS (顺序/标题/已用还是余额)。
# 加、删、换一个维度要同时改两个文件,漏一处的后果很隐蔽:上报侧删了、
# 展示侧没删 → 那个环会挂着 store 里几天前的历史值,永不更新也不报错。
# 合成一张表后:换顺序 = 挪一行,去掉一个维度 = 删一行,加维度 = 加一行。
⚠️ 列表顺序 = 环的 外圈 → 内圈。排序规则见 README 决策日志:
重置周期短(变化快)的放外圈——外圈周长大,同样的变化弧长最长、最醒目。
⚠️ 最多三环。半径是 `148 - i*44`(viewBox 坐标),第四环只剩 16、几乎是个点;
灰度也只有三档 G_RING,第四环会 `i % 3` 撞回最外圈的黑色。
"""
from dataclasses import dataclass
from datetime import timedelta
from typing import Literal
@dataclass(frozen=True)
class Dimension:
"""一个额度维度:从上报 key 一路到环上那一圈。"""
report_key: str # 上报方在 limits[].key 里写的名字,见 docs/report-api.md
metric: str # 内部 metric 名,SampleStore 的主键之一,全局唯一
title: str # 环图例上的展示名
show: Literal["used", "remaining"] = "used" # 图例大数字显示已用还是余额
# 差分弧(斜纹那截)的口径。窗口越长,差分也该跟着放大:
# "rolling" = 最近 settings.delta_window_min 分钟,适合 5 小时这种短窗口;
# "today" = 今天 00:00 起的增量,适合周限额——拿 15 分钟去切一个 7 天的
# 额度看不出任何东西(这跟两条 tier-bar 的取舍是同一个道理)。
delta_mode: Literal["rolling", "today"] = "rolling"
# 自己的 resets_at 缺失时,借哪个维度的(填对方的 report_key)。
# Fable 和周限额同时重置:正常情况 CC 的输出里两行各带一份重置时刻,
# 各用各的;只有上报缺了 Fable 那行的 resets 段时才借用,不必要求
# 上报方一定报两遍。重置文案与时间线都由快照层从 resets_at 现算。
resets_from: str | None = None
# 额度窗口时长,用来在环上画"时间走到哪儿了"那条线:窗口起点 = resets_at - window。
# [新: 2026-07-26] 写死而不是让上报方报:这是产品常量(5 小时 / 7 天),
# 而且 CC 的用量输出里根本没有窗口长度这个信息,只有 session/week 这种名字。
window: timedelta | None = None
_5H = timedelta(hours=5)
_WEEK = timedelta(days=7)
# 顺序即外圈→内圈
CLAUDE_DIMENSIONS: list[Dimension] = [
Dimension("session_5h", "claude_session_pct", "时段限额 (5h)", window=_5H),
Dimension("week_fable", "claude_fable_pct", "Fable 模型",
resets_from="week", window=_WEEK, delta_mode="today"),
Dimension("week", "claude_week_pct", "周限额", window=_WEEK, delta_mode="today"),
]
CODEX_DIMENSIONS: list[Dimension] = [
Dimension("session_5h", "codex_session_pct", "时段限额 (5h)", window=_5H),
# Codex 那边关心的是"还剩多少",环本身仍画已用,只有图例大数字显示余额
Dimension("week", "codex_week_pct", "周余额",
show="remaining", window=_WEEK, delta_mode="today"),
]
# 看板内部源标识 → 该源的维度表
SOURCE_DIMENSIONS: dict[str, list[Dimension]] = {
"claude_limits": CLAUDE_DIMENSIONS,
"codex_limits": CODEX_DIMENSIONS,
}
def metric_map(dims: list[Dimension]) -> dict[str, str]:
"""上报 key → 内部 metric 名,给 PushSource 用。"""
return {d.report_key: d.metric for d in dims}
def reset_metric_for(dim: Dimension, dims: list[Dimension]) -> str:
"""这个维度的重置时刻从哪个 metric 上读(可能借用别的维度的)。
只在自己缺 resets_at 时才需要借用,借用的判断在快照层做——
这里只负责"借谁":resets_from 指向的维度被删了就退回自己。
"""
if dim.resets_from is None:
return dim.metric
source = next((d for d in dims if d.report_key == dim.resets_from), None)
return source.metric if source else dim.metric
决策四:按数据指纹去重,不按图去重
这是"不闪"那条线的全部实现,也是整个项目返工次数最多的地方。
最直觉的做法是比对渲染出的 PNG 字节 —— 错的。图上印着"更新 HH:MM",只要那个时间在走,PNG 哈希每分钟都不一样,墨水屏就为了一个走动的时间戳闪一次。
绕过它的第一版是约定"时间戳记的是数据最后变化时刻而不是拉取时刻",让 PNG 在数据不变时保持字节一致。这个约定本身是对的(它同时满足"不骗"),但拿它兜推送去重是用一个约定兜另一层的漏洞:任何展示性文案变动(重置提示、状态文字)都会误触发闪刷。
现在的判据是快照的数据指纹:
payload = snapshot.model_dump()
payload.pop("generated_at", None) # 展示性时间
for source in payload.get("sources", []):
source.pop("updated_at", None) # 展示性时间
for metric in payload.get("metrics", {}).values():
for ring in (metric or {}).get("rings", []) or []:
ring.pop("time_pct", None) # 每分钟都在走的时间线
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
metrics / layout / source.status 全部纳入 —— 它们变了看板内容就真的变了。剔除的三项都是"自己会走"的量。
时间线 time_pct 被剔除带来一个新问题:它每分钟都在动,不进指纹就永远不更新。解法不是把它加回去,而是加一条 10 分钟的强制刷新兜底 —— 数据没变也隔 10 分钟推一次,让线走起来,顺带治了墨水屏长期不刷积累残影的老毛病。上次推送时刻直接读 last-sent.sha256 的 mtime,不用多存一个文件。
还有一处顺序上的收益:指纹判断前置到渲染之前(push_if_changed 拆成 should_push + push_now)。原先每分钟无条件跑一次 2× 超采样的整屏绘制、再判断要不要推,数据没变的那些分钟全是白烧的树莓派 CPU。
上报默认不立即刷屏
上报接口默认不唤醒采样,等下一个周期(≤60 秒)自然上屏。只有带 ?refresh=1 才立刻采一轮 —— 这个参数是给手动敲命令的人用的,自动上报本来就该等下一个周期。
实现上是唤醒一个 asyncio.Event 而不是让接口自己跑一次 tick:那样两个 tick 会并发,同时 scp 到 Kindle 的临时文件会互相覆盖,FBInk 可能读到写了一半的图。再配 5 秒最小间隔防抖,免得上报端抽风重试就把外部拉取和墨水屏刷新一起带成高频。
不做「保鲜期」判定
原本设计过一个 report_max_age_s:多久没收到上报就认为数据过期。这是错的 —— 上报可能是手动敲的、也可能只在有用量时才发,静默几小时是正常状态,不代表机器掉线。
真正让一份数据失效的是额度自己重置:一份四小时前报的 session 37%,如果它的重置时刻已经过去,那 37% 就不再成立(窗口已归零)。此时该维度归 0、文案改成「已重置 · 待上报」。这个判据与上报频率无关,只跟 resets_at 有关。
重启后回放最近一次上报
上报是稀疏的,重启后干等下一次可能要等几小时 —— 而那期间该维度不进采样,环会被 max_age 判成"不再采集"直接消失(Codex 尤其难受,它没法手动补一次)。原始信封本来就存在 reports 表里,启动时回放成本只有一条 SQL。
回放要逐条向前找可用留档:表里混着解析失败的留档,只取最新一条的话,重启前最后一次上报恰好是坏的,就会把前面那条好数据也挡住。
决策五:一份契约,两个渲染端
Web 页面(HTML + Canvas/SVG)和墨水屏(Pillow)是两套完全不同的绘制技术,但它们消费同一份 Snapshot,视觉规格也只定义一次。
flowchart TB
S[Snapshot 契约<br>models.py] --> C[components.js<br>组件库 + web/kindle 两套主题]
S --> P[render/kindle.py<br>Pillow Widget 按 components.js 规格 1:1 复刻]
C --> W[GET / 彩色 Web 看板]
C --> H[GET /kindle.html 灰度构图预览]
P --> PNG[GET /kindle.png → 推给墨水屏]
落地靠三条约定:
- 契约字段名两端严格一致(
value/delta/goals/rings/pct/note/show/layout),改名必须同步改。前端组件直接按这些字段取值。 - 布局即配置:
LAYOUT是一个[{type: "tier-bars"...}, {type: "rings"...}]的列表,两个渲染端各自按type查组件注册表。加一个组件 = 注册表加一个函数 +LAYOUT加一行 +kindle.py实现同名 Widget。 - 口径文案由后端统一格式化。差分窗口按维度不同(5 小时窗口看"最近 15 分钟"、周窗口看"今日新增"),让两个渲染端各自拼这句话就一定会拼错 —— 事实上之前两边都硬编码着"最近 15 分钟"。所以
delta_label直接进契约。
kindle.html 那个纯浏览器的灰度预览页是省时间的关键:改构图不用等推送,浏览器里就能看 1448×1072 的排版,它还自带"内容放不放得下画布"的容量检测,超高标红。
models.py —— Snapshot 契约(真身)
"""Snapshot 契约模型 —— 前后端唯一契约源头。
骨架约定:这一层完整实现、不留骨架。由它生成 openapi.json,
Web 端(test/components.js)与 Kindle 渲染端(app/render/kindle.py)都对齐到这里。
⚠️ 字段名刻意与 test/mock.js 完全一致(value/delta/goals/delta_label/
rings/pct/note/show/layout...)——这不是随意起的名:前端组件直接按这些
字段取值,改名必须两端同步。
"""
from typing import Literal
from pydantic import BaseModel
class SourceStatus(BaseModel):
"""单个数据源的健康状态,渲染为看板底部的状态条。"""
key: str
label: str
status: Literal["ok", "stale", "error"]
updated_at: str # "HH:MM",展示用;stale 时前端会标"过期"
class TierBarMetric(BaseModel):
"""多档位进度条数据。value/delta/goals 单位是 token 个数(不是亿)。"""
value: float
delta: float # 最近 delta_window 窗口内的增量,渲染为条末端的差分段
goals: list[float] # 递增档位;当前档 = 已达成个数,上限 = 下一个未达成档
delta_label: str # 如 "最近15分钟 +0.06亿",由后端统一格式化
class Ring(BaseModel):
"""叠环中的一环。pct/delta 都是 0~100 的百分数。"""
name: str
pct: float # 已用比例;环的填充始终画"已用"
delta: float # 差分弧(斜纹那截)的百分点,口径见 Dimension.delta_mode
# 差分口径的说明文字,如 "最近15分钟 +3%" / "今日 +2%"。
# 由后端统一格式化(同 TierBarMetric.delta_label):口径按维度不同,
# 让两个渲染端各自拼这句话就一定会拼错。
delta_label: str = ""
note: str # 重置说明,如 "21:00 重置"
# "remaining":图例大数字显示剩余(如 Codex 周余额);环本身仍表示已用
show: Literal["used", "remaining"] = "used"
# 这个额度窗口"时间走到哪儿了"(0~100),渲染成环上一条细线:
# 线在填充前面 = 用得比时间慢(有富余),被填充盖过 = 超前消耗。
# 缺 resets_at 或窗口时长时为 None(不画线)。
# ⚠️ 它每分钟都在变,**不进推送去重的数据指纹**(见 snapshot.data_digest),
# 否则墨水屏会为了一条走动的线每分钟闪一次。
time_pct: float | None = None
class RingsMetric(BaseModel):
"""叠环数据。约定:rings 按 外圈→内圈 排列,重置周期短(变化快)的放外圈
——外圈周长大,同样的变化弧长最长、最醒目(秒针在最外)。"""
rings: list[Ring]
class TierBarItem(BaseModel):
"""tier-bars 合卡里的一条:引用 metrics 里的 key + 显示标题。"""
metric: str
title: str
class LayoutItem(BaseModel):
"""布局即配置:渲染端按 type 查组件注册表,语义与 test/components.js 一致。"""
type: Literal["tier-bars", "rings", "sources"]
metric: str | None = None # rings 用:指向 metrics 的 key
title: str | None = None
items: list[TierBarItem] | None = None # tier-bars 用
Metric = TierBarMetric | RingsMetric
class Snapshot(BaseModel):
"""GET /api/snapshot 的返回。两种渲染端只消费这一份,不各自拉数据源。"""
generated_at: str # "YYYY-MM-DD HH:MM"
sources: list[SourceStatus]
metrics: dict[str, Metric]
layout: list[LayoutItem]
# ---------- 上报接口契约 [新: 2026-07-26] ----------
class LimitReport(BaseModel):
"""单个额度维度的一次上报。
⚠️ limits 刻意做成"列表 + key"而不是固定字段:Codex 报 2 个维度、
Claude Code 报 3 个,以后加维度不用动接口,上报端也不需要知道
看板内部的 metric 命名(映射表在 sources/push.py)。
"""
key: str # session_5h / week / week_fable ...
available: bool = True # False = 这个维度当前没数据(如时段还没开始用)
used_percent: float | None = None # 已用百分比 0~100;available=False 时为 None
resets_at: str | None = None # ISO 时刻,展示文案由后端格式化
class ReportEnvelope(BaseModel):
"""POST /api/report 的请求体。"""
source: str # claude_code / codex,决定映射到哪套 metric
host: str # 上报机标识,多台机器时用于分辨谁掉线了
# 上报机自己的时钟,仅随 payload 原样留档、当前不参与任何逻辑。
# 数据时刻一律用 Pi 收到的时刻(received_at)——否则某台机器时钟偏了
# 就会永远压住其他机器
reported_at: str | None = None
limits: list[LimitReport]
meta: dict = {} # plan_type/credits 之类,原样留档,暂不渲染
墨水屏把视觉规格变成了硬约束
彩色屏上随手能用的表达,在这块屏上全部要重做一遍。这几条是实机反复调出来的:
- 差分用 45° 斜纹,不用颜色。屏上没有彩色,"最近新增的这一截"只能靠纹理跟主体区分开。
- 只有三档灰 + 一档轨道(
#000/#5a/#9c/ 轨道#dc)。第一版用了#ab内圈配#e6轨道,在 16 级灰的屏上直接糊成一片。 - 极短的差分弧要再加一条白色分隔线。内圈 +2% 这种弧连一条完整斜纹都放不下,只有分隔线能表达"这里起是新增"。
- 整图 2× 超采样后 LANCZOS 缩回。Pillow 不做抗锯齿,圆弧直接画毛刺明显。单帧约 90ms,可接受。
- 时间线画描边而不是换色。环上那条"额度窗口走到哪儿了"的细线,用量超前时会被深色填充盖住 —— 而那恰恰是最该看清的时候。先粗白底、再压深色细芯:压在深填充上靠白边显形,压在浅轨道上靠黑芯显形,不必在临界点上反复换色。
- 时间线与用量末端之间连一串稀疏的点,这段弧长就是"用量领先/落后时间多少"。用点线而非实线,因为它只是辅助刻度,实线会跟主体弧抢注意力;点长与间隔都按弧长换算成角度,否则内圈的点会比外圈密得多。只在用量落后于时间时画 —— 超前时那段已经被填充盖住,再叠一条纯属干扰。
旋转放在推送侧,不放在渲染侧
fbink -i 按 framebuffer 的原生方向(竖屏 1072×1448)贴图、自己不转,横图会被裁掉右边。所以渲染仍产出横屏 1448×1072(Web 预览可读),推送前再转成竖屏。角度一并计入推送指纹 —— 改了方向配置能立刻重推,不会被"内容没变"挡掉。
越狱:只是显示通道的一部分
到这里为止,整套设计跟 Kindle 是什么型号、怎么越狱都没关系 —— 它需要的只是"一个能接收 PNG 并把它显示出来的终端"。越狱、Hotfix、KUAL、MRPI、USBNetwork、FBInk 全部落在这条边界的另一侧,不应侵入数据采集层。
Kindle 侧的职责只有四件事:接收 PNG、原子替换当前图片、调用 FBInk 刷新、提供 USB 网络上的 SSH。API 密钥全部留在树莓派,不进 Kindle。
具体到这台 PW4(固件 5.12.4)的越狱路线、USBNetwork 参数、公钥登录、FBInk 验证、以及从电脑原型迁移到树莓派的顺序和验收清单,都在:
那是一份只对这台设备成立的记录,不是通用教程 —— 越狱利用的是特定漏洞,机型或固件对不上就该停下来重新选方法。
还没做的
Ring.pct表达不了"无数据"。现在available: false按 0% 记(对 Codex 的 5 小时窗口而言 0% 恰好就是真相),但要区分"没用过"和"查不到",得把它改成float | None并同步改两个渲染端。- 多机上报假设共用同一个账号(取最后收到的那份)。若是各自独立账号,得按 host 拆 metric 命名并重做布局 —— 画布只剩约 100px 余量,机器多了必须聚合而不是并排画。
- 长跑观察。差分窗口需要 15 分钟以上历史才非零,冷启动那几轮 delta 恒为 0(符合预期),但连续跑几周会不会有别的问题还没验过。
做了再回来补。项目从头到尾的取舍按时间记在决策日志里,包括几条推翻重来的。