TL;DR — git clone … && ./install.sh <clock-ip>. Needs Claude Code signed in. macOS + Linux. Repo: github.com/shavindraSN/claude-meter.
When you're deep in a coding session with Claude Code, the last thing you want is to hit a rate limit unexpectedly. The usage dashboard exists, but it's two clicks away — and checking it constantly breaks focus. Claude Code's paid plans have two rate limits: a rolling 5-hour session budget and a 7-day weekly budget, both shown as percentages in Settings → Usage.
I wanted a passive, always-on display that shows remaining quota the same way a clock shows time: no interaction required, just there.
So I turned a clock into one.
Two percentages: 5-hour session quota and 7-day weekly quota. Updated every minute. Numbers match the Claude app's Settings → Usage exactly.
Here's what took me a weekend, and the two dead ends along the way.
The hardware: GeekMagic SmallTV
I already had one of these on my desk. It's a ~$20 ESP8266-based desk clock with a 240×240 TFT screen. Stock firmware gives you a weather clock, a photo slideshow, and a "customization GIF" slot. It speaks HTTP over your Wi-Fi — no flashing, no soldering.
Out of the box:
What caught my eye: the corner slot where a "GIF" can play. If I could upload any image there, I could repurpose it as a usage meter without losing the stock clock or weather display.
Dead end #1: parsing Claude's JSONL logs
My first attempt avoided the API entirely. Claude Code stores every session as JSONL under ~/.claude/projects/. Each line has usage.input_tokens, output_tokens, cache reads, and cost. Sum them up, divide by the plan limit, done — right?
I had it working in an afternoon. The problem: my numbers drifted from the app. Sometimes 3%, sometimes 10% off. The root cause turned out to be several accounting rules I wasn't modeling:
- Weekly reset is anchored to your account creation date, not a rolling 7 days.
- A "session" starts on the first prompt after a reset, not the oldest prompt in the last 5 hours.
- Cache reads count differently from regular tokens.
- There's an Opus-specific weekly split on the Max plans.
I could have reverse-engineered all of it — but every time Anthropic updates their rate-limit rules, I'd be chasing it.
The unlock: there's an endpoint for this
I went looking at the Claude desktop app's network traffic and found:
GET https://api.anthropic.com/api/oauth/usage
anthropic-beta: oauth-2025-04-20
Authorization: Bearer <oauth-token>
Response:
{
"five_hour": { "utilization": 23.4, "resets_at": "2026-04-19T15:30:00Z" },
"seven_day": { "utilization": 14.1, "resets_at": "2026-04-25T08:30:00Z" }
}
That's exactly the numbers the app shows — pre-computed by Anthropic. No token math, no plan detection, no session heuristics needed. My entire compute_stats() module deleted itself.
But now I needed OAuth
The endpoint requires an OAuth bearer token with user:inference scope. Building a full OAuth flow — browser redirect, PKCE, refresh handling — for a desk clock felt like overkill.
Then I noticed: Claude Code is already doing this. The CLI signs in once, caches tokens, and refreshes them automatically. That cache lives on disk:
- macOS:
security find-generic-password -s "Claude Code-credentials" -w - Linux:
~/.claude/.credentials.json
Both store the same JSON:
{
"claudeAiOauth": {
"accessToken": "...",
"refreshToken": "...",
"expiresAt": 1745123456000
},
"organizationUuid": "..."
}
So claude-meter piggybacks on the CLI's auth. If you already have Claude Code signed in, it works immediately — zero separate login, zero browser redirects. When the token is close to expiry, it refreshes against /v1/oauth/token and writes the new pair back to the same store, keeping the claude CLI in sync.
This ended up being the cleanest part of the design. No separate credential store to manage, no OAuth client to register with Anthropic, nothing for users to authenticate beyond what they've already done.
Dead end #2: the clock doesn't accept JPEGs
With usage data in hand, the next step was pushing an 80×80 JPEG to the clock's customization-GIF slot.
The HTTP API looked straightforward:
requests.post(
"http://192.168.1.50/upload",
files={"imageFile": ("gif.jpg", jpg_bytes, "image/jpeg")},
)
Returns 200 OK. Nothing changes on screen.
Different filename — 200 OK, nothing. Different JPEG — 200 OK, nothing. A real GIF exported from their desktop converter tool — 200 OK, and it works. Diffing the bytes revealed why.
The converter's output is not a standard image format. It's a custom container.
Reverse-engineering the container
Pulling apart the converter output byte-by-byte:
┌──────────────────────────┐
│ frame0 JPEG │
├──────────────────────────┤
│ 2400-byte index block │
├──────────────────────────┤
│ frame1 JPEG │
│ frame2 JPEG │
│ ... │
│ frameN-1 JPEG │
└──────────────────────────┘
The index block is 200 slots × 12 bytes. Record layout:
u16 magic (0x01ff)
u16 id # record 0's id = total frame count; others = frame index
u32 offset # absolute byte offset into the file
u32 size # frame size in bytes
Three additional constraints the firmware doesn't document:
- Quantization tables are pinned. The hardware JPEG decoder silently rejects frames that don't use the specific luma/chroma qtables the converter produces. Pillow's defaults result in a black screen. I extracted the tables from a known-good converter output and hardcoded them.
- APP0 density matters. The JFIF APP0 segment must declare 96×96 DPI. Pillow's default (0×0) causes silent rejection.
- Minimum frame count is ~33. Single-frame and 2-frame payloads were rejected. 33 works. 33 frames × ~2KB ≈ 64KB — likely a hardcoded firmware buffer size.
The solution: push the same 80×80 frame 33 times, wrapped in the expected container. The display stays static, the firmware validation passes.
The full encoder is ~50 lines.
Running it
Not on PyPI yet — installing from source is one command:
git clone https://github.com/shavindraSN/claude-meter.git
cd claude-meter
./install.sh 192.168.1.50 # your clock's IP
The script handles the full setup: creates a venv, installs the package, writes the config, runs claude-meter check to verify auth and API connectivity, and registers the background service (launchd on macOS, systemd user unit on Linux). After it completes, the clock updates automatically.
Dedup is built in — if the rounded percentages haven't changed since the last push, the upload is skipped to avoid unnecessary traffic.
The clock has two writable slots, so claude-meter supports both:
gif80 — 80×80 in the customization-GIF corner, alongside the stock weather clock.
photo240 — full-screen 240×240, larger numbers with reset countdowns under each bar.
Switch with claude-meter configure --mode photo240.
Contribute
The project is open source under the MIT license. The core — OAuth reuse, fetch loop, renderer — is device-agnostic, and the GeekMagic-specific transport is only ~50 lines. If you have a different display (TRMNL, Inkplate, LaMetric, a Raspberry Pi PiTFT), adding support is one new file under transports/. Bug reports, display ports, and feature ideas are all welcome.
github.com/shavindraSN/claude-meter
A $20 clock that was showing weather is now showing something I actually check. If you've repurposed any IoT hardware for your own workflow, I'd love to see it — drop it in the comments.




Top comments (0)