Rust firmware for the GeekMagic SmallTV PRO (ESP32-WROOM-32) that displays your Claude Code rate-limit usage on the built-in 240×240 ST7789 display.
Inspired by Clawdmeter. Instead of a BLE + Python daemon, the ESP32 talks to Anthropic directly over WiFi: you authorize it once from a browser, and it keeps itself signed in from then on. Reading usage costs no tokens.
Uses Claude Code's OAuth client and an undocumented usage endpoint — see Disclaimer.
| Part | Details |
|---|---|
| MCU | ESP32-D0WDQ6 rev1.0 (dual-core, 240 MHz, 520 KB SRAM, no PSRAM) |
| Display | ST7789, 240×240, SPI Mode 3 |
| Crystal | 40 MHz |
| Device | GeekMagic SmallTV PRO v02-22 |
| GPIO | Function | Notes |
|---|---|---|
| 18 | SPI CLK | VSPI default |
| 23 | SPI MOSI | VSPI default |
| 3 | SPI CS | also UART0 RX — see note below |
| 2 | Display DC | |
| 4 | Display RST | |
| 21 | Backlight | high = on |
Alternative pinout: some board revisions use CLK=14, MOSI=13 (HSPI). If the display stays blank, change those two lines in
src/main.rs.
GPIO3 note: GPIO3 doubles as UART0 RX (serial input from host) and SPI CS. Serial input is only possible before the display is initialized. After that, everything (including authorization) happens over WiFi.
- 5h — the rolling session window,
0–100%, with a bar and a countdown to its reset. - 7d — the weekly all-model window.
- ALLOWED / RATE LIMITED — whether any window is exhausted.
- The title bar shows the account tier (
Claude Max/Claude Pro) when the profile endpoint answers. - If the device is not authorized yet, the screen shows its IP address and asks you to open it in a browser.
cargo install espup
espup install
. $HOME/export-esp.sh # add to your shell profilecargo install espflashNo credentials needed at build time.
cargo build --release
espflash flash target/xtensa-esp32-espidf/release/clawdmeter-rs --monitor
# or:
./flash.shOn first boot the device has no WiFi config. Open espflash monitor (or any serial terminal at 115200 baud) before or immediately after powering the device. You will be prompted:
I (...) clawdmeter_rs: === No WiFi config. Enter credentials in serial monitor ===
I (...) clawdmeter_rs: SSID:
MyNetwork
I (...) clawdmeter_rs: Password:
mysecret
WiFi credentials are saved to NVS and survive reboots. To reconfigure, erase NVS:
espflash erase-flash # wipes everything; reflash firmware afterwardsOnce the device is on Wi-Fi it shows its IP address on the display (and logs it to serial). Authorization is a one-time browser round-trip — after that the device renews its own tokens and never needs attention again.
- Open
http://<device-ip>/from a phone or laptop on the same network. - Tap Authorize with Claude. This opens
claude.ai, where you sign in and approve access. - You get back a string that looks like
<code>#<state>. Copy it. - Paste it into the form on the device page and submit.
The device exchanges the code for an access + refresh token pair. Only the refresh token is written to NVS; access tokens live in RAM and are renewed automatically ~60 seconds before they expire, and again if the API answers 401. Authorization survives reboots.
The device page generates fresh PKCE secrets on every load, so use the Authorize link and the paste form from the same page load — reloading the page invalidates the previous link.
This is the only way to give the device credentials — there is no manual token upload. To re-authorize (different account, revoked access), just open the page again and repeat the four steps.
This project talks to endpoints that were built for Anthropic's own Claude Code client:
- It authenticates with Claude Code's OAuth
client_id. Anthropic offers no way to register a third-party OAuth application, so there is no first-party alternative. - It reads usage from
GET https://api.anthropic.com/api/oauth/usage, which is undocumented and carries no compatibility guarantee.
Anthropic can change, restrict, or remove either of these at any time and without notice, which would break this firmware. Check that this use fits Anthropic's terms for your account before relying on it.
| Crate | Role |
|---|---|
esp-idf-hal |
SPI, GPIO, UART, delay |
esp-idf-svc |
WiFi, HTTPS (mbedTLS), HTTP server, NVS, hardware RNG |
embedded-graphics |
2D drawing primitives, fonts |
serde / serde_json |
usage + token response parsing |
sha2 |
SHA-256 for the PKCE code challenge |
No external display driver library — ST7789 is driven directly to avoid version-compatibility issues between embedded-hal 1.0 and the mipidsi/display-interface ecosystem.
main loop (every 60s)
└── access_token() RAM tokens → refresh with the token in NVS
└── api::fetch_usage() GET /api/oauth/usage
└── limits[]: session + weekly_all (percent, resets_at)
└── ui::draw_usage() render bars + countdown to display
HTTP server (background, port 80)
├── GET / authorization page (PKCE link + paste form)
└── POST /oauth exchange code#state → tokens, store refresh in NVS
Reading usage costs no quota: /api/oauth/usage is a plain read-only lookup. Earlier versions instead fired a
max_tokens: 1 request at claude-haiku-4-5 just to harvest its anthropic-ratelimit-unified-* response
headers — that spent tokens in order to measure them, and is gone.
The response is parsed from limits[] (kind = session / weekly_all), which is where the live counters are;
the flat five_hour / seven_day objects are only used as a fallback because they come back as null on most
accounts.
| Symptom | Fix |
|---|---|
| Display blank | Try CLK=14, MOSI=13 in src/main.rs |
| Display colors wrong | Toggle INVON in display.rs::init() |
| Display rotated | Change MADCTL byte in display.rs::init() |
| TLS handshake fails | Ensure CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y in sdkconfig.defaults |
| Serial prompt not appearing | Open monitor before powering the device |
| Can't type in serial prompt | Must open monitor before display init (GPIO3 conflict) |
| Authorization page unreachable | Check device IP in serial logs; ensure same network |
state mismatch on submit |
The page was reloaded after opening the authorize link — reload once, then use that page's link and form together |
| claude.ai says "Invalid request format" | The authorize URL is malformed. It must match the reference clients exactly: 43-char state, and scope spaces encoded as + (see oauth::push_param) |
Failed: token endpoint HTTP 400 |
The code was already used or expired; start again from the Authorize link |
| Display shows "Authorize" again after working | The refresh token was rejected (revoked, or password changed) — authorize once more |
