A physical dashboard for monitoring Claude Code session usage limits, built on an ESP32-C3 SuperMini with a tiny 76×284 ST7789 display.
Polls the Anthropic OAuth usage API every 3 minutes and renders a Pac-Man animation where Pac-Man's position reflects how much of your 5-hour usage window has been consumed.
- Live usage tracking — 5-hour and 7-day utilization from
POST /v1/messagesrate-limit headers - Pac-Man progress bar — Pac-Man eats dots as your quota fills up; fewer dots = less quota left
- Color-coded alerts — yellow (OK) → orange (70%+) → red (90%+), using official arcade palette
- Buzzer alarms — one-shot alarm fires when threshold is crossed; silences after 10 s; re-arms only after utilization drops and rises again
- PWM backlight dimming — 4 hardware brightness levels (25/50/75/100%), persisted in NVS
- Auto-dim — backlight drops to 25% after 5 minutes of inactivity; any button press restores full brightness
- Two tactile buttons — test/alarm threshold (A) and brightness/mute (B)
- Live countdown — 5H and 7D reset timers tick every minute, independent of the 3-minute API poll
- Wi-Fi config portal — password-protected AP on first boot; token-only update page during normal operation
- OTA firmware updates — upload
.binvia the web dashboard athttp://claude-monitor.local/update - mDNS — reachable as
claude-monitor.localon your network - Factory reset — hold BOOT button (GPIO9) at power-on to wipe all config and re-enter setup
- Token-only reset — hold Button A (GPIO2) at power-on for 3 s to wipe just the OAuth token (keep Wi-Fi)
| Part | Notes |
|---|---|
| ESP32-C3 SuperMini | RISC-V, 160 MHz, native USB CDC |
| ST7789 display 76×284 px | SPI, 3.3V, variant P3 |
| NPN transistor (BC547 / 2N2222 / S8050) | Backlight control (PWM dimming) |
| 1 kΩ resistor | Transistor base resistor |
| 2× tact switch | Button A (GPIO2), Button B (GPIO3), INPUT_PULLUP |
| Passive buzzer | GPIO0 via NPN transistor, PWM-driven |
ESP32-C3 SuperMini ST7789 76×284
─────────────────────────────────────────
GPIO 6 (MOSI) ─────── SDA
GPIO 4 (SCLK) ─────── SCL
GPIO 5 (CS) ─────── CS
GPIO 7 (DC) ─────── DC
GPIO 8 (RST) ─────── RST
GPIO 10 ──[1kΩ]──► NPN base (backlight, PWM CH0)
NPN emitter ──► GND
NPN collector ──► BL
3.3V ─────── VCC
GND ─────── GND
Peripherals
─────────────────────────────────────────
GPIO 2 ── tact switch A ──► GND (INPUT_PULLUP)
GPIO 3 ── tact switch B ──► GND (INPUT_PULLUP)
GPIO 0 ──[1kΩ]──► NPN base (buzzer, PWM CH1)
NPN emitter ──► GND
NPN collector ──► Buzzer(+)
Buzzer(-) ──► VCC
The BL pin on this display connects to LED cathodes — it needs a path to GND to light up. The NPN transistor inverts GPIO10 HIGH → BL pulled to GND → backlight ON.
- PlatformIO (CLI or VSCode extension)
espressif32 @ 6.9.0platform (pinned — see docs/TFT_eSPI_ESP32C3_ST7789_76x284_fixes.md)
pio run -t upload --upload-port /dev/ttyACM0The port on ESP32-C3 SuperMini (native USB CDC) is
/dev/ttyACM0on Linux,COMxon Windows. The port number can change between sessions.
This display needs three manual patches to TFT_eSPI after the first pio run (or pio lib install) generates the libdeps folder. Without them the display will show nothing.
| Patch | File | What to change |
|---|---|---|
| SPI register address (IDF 5.x) | TFT_eSPI/Processors/TFT_eSPI_ESP32_C3.h |
#define SPI_PORT 2 (was SPI2_HOST) |
| CGRAM offset for 76×284 | TFT_eSPI/TFT_Drivers/ST7789_Rotation.h |
Add else if(_init_width == 76) block with colstart=18, rowstart=82 in cases 1 and 3 |
| Dummy MISO pin | TFT_eSPI/User_Setup.h |
#define TFT_MISO 3 |
Full explanation with exact line numbers and root cause analysis: docs/TFT_eSPI_ESP32C3_ST7789_76x284_fixes.md
The complete User_Setup.h contents are also in that document.
- Flash the firmware.
- The device starts a WPA2-protected Wi-Fi AP named
Claude-Monitor-XXXX. - The random 8-character password is displayed on the screen — enter it on your phone/laptop.
- Connect to the AP and open
http://192.168.4.1. - Enter your Wi-Fi SSID, password, and Claude OAuth token.
- The device saves config to NVS flash, restarts, and connects to your network.
The token lives in ~/.claude/.credentials.json on the machine where Claude Code is installed:
cat ~/.claude/.credentials.json | python3 -m json.tool
# look for "accessToken"Copy the accessToken value into the config form. Tokens expire every few hours — see Automatic token sync below to avoid manual updates.
Visit http://claude-monitor.local/ (or http://<device-ip>/) and paste the new token in the dashboard.
Run tools/sync-token.py on the computer where Claude Code is installed. It watches ~/.claude/.credentials.json and pushes the fresh token to the device instantly when the file changes.
# Optional: install watchdog for instant inotify/fsevents detection
python3 -m pip install watchdog
python3 tools/sync-token.pyIf watchdog is not installed, the script falls back to polling every 10 minutes.
Zero Python dependencies. Systemd itself watches the file and triggers a one-shot sync only when the token actually changes.
Important: Install as user-level systemd (not system-wide with
sudo), otherwise%hexpands to/rootand the unit won't find your credentials.
mkdir -p ~/.config/systemd/user/
cp tools/sync-token.path ~/.config/systemd/user/
cp tools/sync-token.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now sync-token.pathCheck status:
systemctl --user status sync-token.path
journalctl --user -u sync-token -fIf you don't use Claude Code regularly, the OAuth token expires after ~8 hours. The timer runs sync-token.py every 4 hours — but it cannot auto-refresh an expired token. You must run Claude Code interactively (e.g. claude or Kimi Code) at least once every 8 hours to refresh the token. The timer then detects the updated credentials.json and syncs it.
cp tools/sync-token.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now sync-token.timerCheck status:
systemctl --user list-timersIf you prefer the Python script running continuously (with watchdog or polling):
sudo cp tools/sync-token-polling.service /etc/systemd/system/
sudo systemctl enable --now sync-token-pollingEnvironment variables (all options):
CLAUDE_MON_HOST— device hostname/IP (default:claude-monitor.local)CLAUDE_MON_INTERVAL— polling fallback interval in seconds (default:600)
Both buttons use internal pull-ups (INPUT_PULLUP). Press = connect to GND.
| Button | Action | Result |
|---|---|---|
| A (GPIO2) | Short press | Test buzzer (plays 1500 Hz beep; does not change mute state) |
| A (GPIO2) | Long press (≥ 1.5 s) | Cycle alarm threshold: 80% → 90% → OFF → 80% |
| A (GPIO2) | Hold at boot (≥ 3 s) | Reset only the OAuth token; Wi-Fi credentials are preserved |
| B (GPIO3) | Short press | Cycle backlight: 25% → 50% → 75% → 100% |
| B (GPIO3) | Long press (≥ 1.5 s) | Toggle buzzer mute ON / OFF |
All settings are saved to NVS immediately and persist across reboots.
The passive buzzer is driven by PWM on GPIO0 via an NPN transistor. Three alarm patterns are triggered automatically when the 5-hour utilization crosses the configured threshold:
| Utilization | Pattern | Sound |
|---|---|---|
| ≥ threshold, < 90% | LOW | Waka-waka (800/600 Hz, 100 ms every 3 s) |
| ≥ 90%, < 100% | HIGH | Rapid triple beep (2000 Hz, 80 ms every 1.5 s) |
| ≥ 100% | CRITICAL | Continuous interrupted tone (2000 Hz, 200 ms every 500 ms) |
| Below threshold or OFF | NONE | Silent |
Long-press Button B to mute/unmute all alarm sounds instantly.
┌────────────────────────────────────────────────┐ ← 284 px wide
│ ▌ CLAUDE·MAN 192.168.1.5│ y 0–11 title + IP
│─────────────────────────────────────────────────│
│ · · · · · · · C> · · · · 47% │ y 12–57 Pac-Man track
│ 1h33m │ + 5H data (right)
│─────────────────────────────────────────────────│
│ ▌ [7D] 12% 5d02h AL 80% ●WiFi ●API 14:22│ y 58–71 bottom bar
│▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ y 72–75 7D progress bar
└────────────────────────────────────────────────┘
| Zone | Content |
|---|---|
| Top bar | CLAUDE·MAN title (Inky cyan · Clyde orange · Pac yellow), local IP dim right |
| Pac-Man track | Animated Pac-Man, uneaten dots to the right, 5H % and reset time far right |
| Bottom bar | [7D] badge, 7D utilization %, reset time, alarm/brightness overlay, WiFi dot, API dot, clock |
| Progress bar | 3D metallic 7D utilization bar |
Color legend (official arcade palette):
| Color | Meaning |
|---|---|
Yellow #FFFF00 |
Normal (< 70%) |
Orange #FFB852 |
Warning (70–89%) |
Red #FF0000 |
Critical (≥ 90%) |
Cyan #00FFFF |
WiFi / IP |
Pink #FFB8FF |
API status |
Blue #2121DE |
Maze wall accents |
Edit include/config.h before building:
#define API_POLL_MS 180000 // poll interval (ms) — 3 minutes
#define API_HOST "api.anthropic.com"
#define API_MODEL "claude-haiku-4-5-20251001"
#define NTP_SERVER "pool.ntp.org"
#define TIMEZONE_STRING "CET-1CEST,M3.5.0,M10.5.0/3"
// Hardware pins
#define PIN_BTN_ALARM 2
#define PIN_BTN_BRIGHT 3
#define PIN_BUZZER 0
#define PIN_TFT_BL 10Runtime settings (stored in NVS, changed via buttons):
| Setting | Default | Range |
|---|---|---|
| Backlight brightness | 100% | 25 / 50 / 75 / 100 |
| Alarm threshold | 80% | OFF / 80 / 90 |
| Buzzer mute | OFF | ON / OFF |
ai_session_limits/
├── include/
│ └── config.h — pins, display size, timing constants
├── src/
│ ├── main.cpp — state machine, button handlers, alarm logic
│ ├── display/
│ │ ├── display.cpp/h — TFT init, PWM backlight
│ │ ├── ui.cpp/h — all screen rendering functions
│ │ └── pacman.cpp/h — Pac-Man animation
│ ├── hardware/
│ │ ├── buttons.cpp/h — debounced button input, short/long press
│ │ └── buzzer.cpp/h — PWM tones, boot melody, non-blocking alarm
│ ├── network/
│ │ ├── api_client.cpp/h — HTTPS to Anthropic API, rate-limit parsing
│ │ ├── api_task.cpp/h — FreeRTOS background API polling task
│ │ ├── web_config.cpp/h — captive config portal + OTA + dashboard
│ │ └── wifi_mgr.cpp/h — STA / AP management
│ └── storage/
│ └── storage.cpp/h — NVS config persistence (Preferences)
├── docs/
│ └── TFT_eSPI_ESP32C3_ST7789_76x284_fixes.md — display bring-up guide
├── tools/
│ ├── sync-token.py — automatic token sync client (with auto-refresh)
│ ├── sync-token.path — systemd Path unit (reactive)
│ ├── sync-token.service — systemd oneshot service
│ ├── sync-token.timer — systemd Timer (periodic refresh)
│ └── sync-token-polling.service — systemd continuous service
└── platformio.ini
S_INIT ──(no config)──► S_AP_MODE (serves config portal, waits for submit + restart)
──(config OK)──► S_CONNECTING ──(connected)──► S_RUNNING
──(timeout)───► S_ERROR (30s countdown → restart)
S_RUNNING: async API poll every 3 min, Pac-Man animation, web dashboard, button handling
- The probe request (
POST /v1/messageswithmax_tokens: 1) costs ~1 token per poll. At 3-minute intervals that is ~480 probes/day — negligible against any real usage. - OAuth tokens expire. The device shows a red
TOKEN WYGASL (401)screen with the update URL when that happens. - API polling backs off to 5 minutes automatically if the server returns HTTP 429 (rate limited).
Serialoutput goes to UART0 hardware pins (GPIO20/21) on ESP32-C3 SuperMini withoutARDUINO_USB_CDC_ON_BOOT=1— it is not visible on the USB port. Do not add that flag (causes compile errors with TFT_eSPI).
MIT
