A token/quota meter for Claude Code, displayed in near real time on an e-ink screen: remaining 5-hour quota, burn rate, exhaustion ETA, and next reset time. A second bar shows the weekly quota.
- Raspberry Pi Zero 2 W, Raspberry Pi OS.
- Waveshare 2.13" e-Paper HAT, V3 (
epd2in13_V3).
Reference setup for the collector side: Arch Linux (Hyprland) on the
workstation running Claude Code. Nothing here is Arch-specific though —
the collector is plain bash + jq + curl, so any Linux machine with
Claude Code logged in works.
[workstation] tokenmeter-collect (systemd timer, 60s)
├─ curl api.anthropic.com/api/oauth/usage → quota % and reset times
├─ ccusage blocks --active --json → tokens/cost
└─ POST ──> [Pi Zero] :8080/ingest ──> render.py ──> e-ink
- The Pi is dumb. It holds no credentials, just receives JSON and draws it.
- The collector runs on the workstation where Claude Code is logged in. Since the rate limit is per-account, a single collector covers every machine.
- Push, not poll. The collector POSTs to an HTTP ingest on the Pi. The Pi only refreshes the panel when the payload's content hash changes — this preserves the e-ink and avoids needless partial-refresh wear.
{
"ts": 1755750000,
"status": "ok",
"five_hour": { "used": 41.2, "resets_at": 1755763800,
"burn_pct_h": 9.4, "eta_exhaust": 1755772500 },
"week": { "used": 63.0, "resets_at": 1756108800 },
"tokens": { "total": 184320, "cost": 2.31 },
"opencode": { "total": 1300000, "cost": 0.90 }
}status is ok or stale. In stale, the panel shows the last known
good reading with an age indicator instead of failing silently — this
happens whenever the collector can't reach /api/oauth/usage (expired
OAuth token, rate limiting, network down).
opencode is optional — the collector omits it entirely if the
opencode binary isn't found. It only carries
total/cost (like ccusage's numbers), since opencode is
multi-provider and has no equivalent to Claude's 5h/weekly plan quota to
show a gauge for.
The collector reads these numbers with a direct SQL query against
opencode's own SQLite database (opencode db '<query>' --format json),
summing tokens.total and cost out of the JSON blob in each
message.data row for role: "assistant" messages from the last 24h
(time_created). This replaced an earlier version that parsed opencode stats's text table — that broke the first time real usage showed up,
since the table formats large numbers with K/M suffixes ("28.2K") that a
naive digit regex mangled. The SQL approach avoids reformatted/rounded
numbers entirely. time_created > now - 24h is a loose stand-in for
"recent activity", not an equivalent to Claude's 5h active block —
opencode has no such window.
collector/tokenmeter-collect— bash script that reads/api/oauth/usage(Bearer token from~/.claude/.credentials.json) andccusage blocks --active --json, computes burn rate and exhaustion ETA in purejq, and POSTs the result to the Pi. Falls back to the last known-good reading (status: stale) on any failure.pi/ingest.py— stdlib-only HTTP server on the Pi. Decides whether a redraw is needed (content-hash gate, periodic forced full refresh every ~20 partials or 1h) and calls intorender.py.pi/render.py— draws the dashboard with PIL and pushes it to the e-ink panel via the Waveshareepd2in13_V3driver. Each call is a self-contained init → draw → sleep cycle; partial refreshes explicitly reseed the controller's comparison buffer from the last frame actually drawn (persisted to disk), since the controller's own internal RAM does not reliably survive the sleep/reset boundary between cycles. Draws an extra line for the optionalopencodepayload field when present.systemd/— user-level systemd units: a timer + oneshot service for the collector on the workstation, and a long-running service for the ingest server on the Pi (needsloginctl enable-linger <user>so it survives without an active login session).
Dependencies: jq, curl, and ccusage
on the workstation; Python 3 with Pillow and the Waveshare waveshare_epd
package on the Pi. The Pi user needs to be in the spi group so
/dev/spidev0.0 is accessible without root.
# workstation
mkdir -p ~/.config/systemd/user
cp systemd/tokenmeter-collect.{service,timer} ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now tokenmeter-collect.timer
# Pi
scp pi/ingest.py pi/render.py pi-host:~/tokenmeter/
scp systemd/tokenmeter-ingest.service pi-host:~/.config/systemd/user/
ssh pi-host 'systemctl --user daemon-reload && systemctl --user enable --now tokenmeter-ingest.service'
ssh pi-host 'sudo loginctl enable-linger $(whoami)'Two machine-specific values are hardcoded for the original setup — this is everything you need to adjust to run tokenmeter elsewhere:
- The Pi's LAN IP —
collector/tokenmeter-collectdefaultsTOKENMETER_INGEST_URLtohttp://172.22.2.105:8080/ingest. Either exportTOKENMETER_INGEST_URLin the systemd service's environment, or just edit the default in the script. - The repo path in the collector's systemd unit —
systemd/tokenmeter-collect.servicerunsExecStart=%h/GIT/tokenmeter/collector/tokenmeter-collect. If you clone this somewhere other than~/GIT/tokenmeter, update that path (or drop the%h/GIT/prefix and use an absolute path).
systemd/tokenmeter-ingest.service (Pi side) expects the code at
~/tokenmeter/, matching the scp command above — no change needed if you
follow it as-is.
- No rootfs overlay. The Pi's root filesystem is plain writable ext4
on the SD card — there's no protection against wear from continuous
writes on an always-on device. Raspberry Pi OS ships
raspi-config's overlay option (sudo raspi-config nonint do_overlayfs 0) for this, but it makes every write to/disposable on next reboot (viaoverlayroot=tmpfs), which is a real tradeoff if you run anything else persistent on the same Pi. Left as a manual decision per setup rather than applied by default here.
- Partial refresh by default; full refresh every ~20 partials or once an hour to clear ghosting.
epd.sleep()at the end of every cycle — the panel is never left in drive state.- Minimum cadence of 60s.
- The on-screen timestamp is always drawn — e-ink retains its image with no power, so without an age marker a 6-hour-old reading looks current.



