Skip to content

Latest commit

 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tokenmeter

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.

tokenmeter panel

Hardware

  • 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.

Architecture

[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.

Payload contract

{
  "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.

Components

  • collector/tokenmeter-collect — bash script that reads /api/oauth/usage (Bearer token from ~/.claude/.credentials.json) and ccusage blocks --active --json, computes burn rate and exhaustion ETA in pure jq, 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 into render.py.
  • pi/render.py — draws the dashboard with PIL and pushes it to the e-ink panel via the Waveshare epd2in13_V3 driver. 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 optional opencode payload 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 (needs loginctl enable-linger <user> so it survives without an active login session).

Setup

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)'

Configuration you need to change

Two machine-specific values are hardcoded for the original setup — this is everything you need to adjust to run tokenmeter elsewhere:

  1. The Pi's LAN IPcollector/tokenmeter-collect defaults TOKENMETER_INGEST_URL to http://172.22.2.105:8080/ingest. Either export TOKENMETER_INGEST_URL in the systemd service's environment, or just edit the default in the script.
  2. The repo path in the collector's systemd unitsystemd/tokenmeter-collect.service runs ExecStart=%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.

Known limitations

  • 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 (via overlayroot=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.

e-ink rules

  • 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.

More photos

Claude and opencode usage shown together tokenmeter panel on the desk an earlier layout iteration, before the terminal-style header and opencode line

License

MIT

Used by

Contributors

Languages