跳转至

让 Kindle 常显一块 Token 看板:先有设计,越狱只是手段

一台闲置的 Kindle Paperwhite 4 挂在桌上,常显团队的 Token 用量与各家 CLI 的额度余量。数据在树莓派上采集、渲染成灰度 PNG,经 USB 网络推给 Kindle 显示。

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 验证、以及从电脑原型迁移到树莓派的顺序和验收清单,都在:

PW4 / 5.12.4 越狱与显示通道实操

那是一份只对这台设备成立的记录,不是通用教程 —— 越狱利用的是特定漏洞,机型或固件对不上就该停下来重新选方法。

还没做的

  • Ring.pct 表达不了"无数据"。现在 available: false 按 0% 记(对 Codex 的 5 小时窗口而言 0% 恰好就是真相),但要区分"没用过"和"查不到",得把它改成 float | None 并同步改两个渲染端。
  • 多机上报假设共用同一个账号(取最后收到的那份)。若是各自独立账号,得按 host 拆 metric 命名并重做布局 —— 画布只剩约 100px 余量,机器多了必须聚合而不是并排画。
  • 长跑观察。差分窗口需要 15 分钟以上历史才非零,冷启动那几轮 delta 恒为 0(符合预期),但连续跑几周会不会有别的问题还没验过。

做了再回来补。项目从头到尾的取舍按时间记在决策日志里,包括几条推翻重来的。