DEV Community

Shavindra Manathunga
Shavindra Manathunga

Posted on

Turning a Desk clock into a Claude Code usage meter

TL;DRgit 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.

GeekMagic SmallTV desk clock showing a full-screen 'Claude usage' card: 5h session 23% with 'resets in 2h 35m', 7d weekly 14% with 'resets Fri 8:30 AM', and cyan progress bars

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:

The GeekMagic SmallTV running its stock weather clock face, showing the time, date, city name, weather icon, and temperature/humidity readouts

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>
Enter fullscreen mode Exit fullscreen mode

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" }
}
Enter fullscreen mode Exit fullscreen mode

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": "..."
}
Enter fullscreen mode Exit fullscreen mode

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")},
)
Enter fullscreen mode Exit fullscreen mode

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       │
└──────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Three additional constraints the firmware doesn't document:

  1. 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.
  2. APP0 density matters. The JFIF APP0 segment must declare 96×96 DPI. Pillow's default (0×0) causes silent rejection.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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.

GeekMagic SmallTV in gif80 mode: stock weather clock on the left with time, date, and temperature; two small green progress bars in the bottom-right corner showing '5h 24%' and '7d 14%' Claude usage

photo240 — full-screen 240×240, larger numbers with reset countdowns under each bar.

GeekMagic SmallTV in photo240 mode: full-screen Claude usage card with large teal percentages (5h 23%, 7d 14%), cyan progress bars, reset countdowns under each bar, and the device IP at the bottom

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)