A small ESP32 dashboard I made for my desk to keep an eye on Claude Code usage.
It runs on a Waveshare ESP32-S3-Touch-AMOLED-2.16 as well as a few other alternative boards and pairs over Bluetooth, the splash screen plays pixel-art Clawd animations that get busier when your usage rate climbs. The two side buttons send Space and Shift+Tab over BLE HID for Claude Code's voice mode and mode-toggle shortcuts.
| Usage meter | Clawd animation screen |
|---|---|
![]() |
![]() |
The Clawd animations come from claudepix, @amaanbuilds's library of pixel-art Clawd sprites, check it out, it's lovely.
The device boots into the splash. Tap the screen anywhere to switch to the Usage view; tap again to flip back to the splash.
| Splash | Usage |
|---|---|
![]() |
![]() |
| Splash; touch-toggle anytime | Session and weekly utilization |
While the splash is up, the middle (PWR) button cycles animations. Hold the power button for 3 seconds, then release, to put the device into pairing mode — this clears the saved Bluetooth bond and re-advertises. The firmware also auto-rotates animations every 20 s within the current usage-rate group, so a long stretch on the splash isn't just one Clawd on loop.
Boards supported out of the box:
Please check if a pull request exists for your alternative hardware port before opening a new one, providing QA feedback and testing on the same hardware is more valuable than duplicate pull requests.
Porting to another board: the firmware is a thin HAL with per-board folders under firmware/src/boards/. Drop in a new folder and a new PlatformIO env — main.cpp, ui.cpp, and splash.cpp never need to change. See docs/porting/adding-a-board.md for the walk-through and docs/porting/hal-contract.md for the interfaces a port must implement.
curl, bluetoothctl, busctl (BlueZ Bluetooth stack)python3 (the installer sets up a venv with bleak and httpx)python3 3.11+ (the installer sets up a venv with bleak, httpx, and pystray)The macOS host pieces — Python daemon, LaunchAgent, and flash helper — were ported by Chris Davidson (@lorddavidson). Thanks Chris!
./flash-mac.sh waveshare_amoled_216 # auto-detects /dev/cu.usbmodem*
./flash-mac.sh waveshare_amoled_18 /dev/cu.usbmodem1101 # or pass an explicit USB serial port
The board env name is required. Run ./flash-mac.sh with no args to see the available envs (scraped from firmware/platformio.ini).
After flashing, open System Settings → Bluetooth and click Connect next to "Clawdmeter". The daemon only ever connects to the peripheral this Mac is paired/connected to — it never scans for a nearby device — so once it's connected here the daemon picks it up on its next poll (~60 s).
The daemon reads your Claude OAuth token from the macOS Keychain (service Claude Code-credentials), polls usage every 60 s, and pushes it to the display over BLE.
./install-mac.sh
The installer creates a Python venv in daemon/.venv/, installs bleak and httpx, renders a LaunchAgent into ~/Library/LaunchAgents/com.user.claude-usage-daemon.plist, and loads it. The first run is launched interactively so macOS prompts for Bluetooth permission.
Useful commands:
launchctl list | grep claude-usage # check it's running
tail -F ~/Library/Logs/claude-usage-daemon.out.log # live logs
launchctl unload ~/Library/LaunchAgents/com.user.claude-usage-daemon.plist # stop
launchctl load -w ~/Library/LaunchAgents/com.user.claude-usage-daemon.plist # start
./flash.sh waveshare_amoled_216 # defaults to /dev/ttyACM0
./flash.sh waveshare_amoled_18 /dev/ttyACM1 # or pass an explicit USB serial port
The board env name is required. Run ./flash.sh with no args to see the available envs (scraped from firmware/platformio.ini).
After flashing, the device advertises as "Clawdmeter". Pair it once:
# Scan for the device
bluetoothctl scan le
# When "Clawdmeter" appears, pair and trust it
bluetoothctl pair F4:12:FA:C0:8F:E5 # use your device's MAC
bluetoothctl trust F4:12:FA:C0:8F:E5
To re-pair later, hold the power button for 3 seconds then release — the device clears its saved bond and re-advertises.
The daemon polls your Claude usage every 60 seconds and sends it to the display over BLE.
./install.sh
systemctl --user start claude-usage-daemon
Check status: systemctl --user status claude-usage-daemon
View logs: journalctl --user -u claude-usage-daemon -f
Runs natively on Windows — no WSL required. A system-tray app polls your usage and pushes it over BLE, and starts automatically at login.
claude login completed. The token is read from %USERPROFILE%\.claude\.credentials.json (falling back to %LOCALAPPDATA%\Claude\ then %APPDATA%\Claude\).%USERPROFILE%\Clawdmeter), not a \\wsl$ share — the installer refuses a WSL path.pio run -d firmware -e waveshare_amoled_216 -t upload --upload-port COM5 # use your device's COM port
Run pio run -d firmware with no env to see the available board envs.
The device is a bonded BLE HID keyboard, so pair it once: Settings → Bluetooth & devices → Add device → Bluetooth, then select "Clawdmeter". Pairing is required — it enables the physical buttons and keeps a persistent connection (the device keeps showing your last-synced usage even after the daemon quits). To undo, use Remove device (this disables the buttons).
From the repo root in PowerShell:
powershell -ExecutionPolicy Bypass -File install-windows.ps1
This creates a venv, installs bleak/httpx/pystray/Pillow from the in-repo requirements (no internet downloads), registers a per-user login-autostart entry (HKCU\…\Run, no admin needed), and launches the tray app headlessly (no console window).
python -m venv .venv
.venv\Scripts\Activate.ps1 # if blocked: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, then retry
pip install -r daemon\requirements-windows.txt
python daemon\claude_usage_daemon_windows.py # runs in the foreground; Ctrl+C to stop
The icon's corner bubble shows state — green Connected, amber Scanning, red Error — and hovering shows the status (Connected · last update HH:MM). A notification fires once when it enters Error (e.g. an expired token). Right-click for the menu:
Get-Content $env:LOCALAPPDATA\Clawdmeter\daemon.log -Tail 30 # view logs
reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v Clawdmeter /f # remove autostart
| Symptom | Fix |
|---|---|
Device not found |
Power on the device; make sure it's in range and paired. |
token expired toast / API HTTP 401 |
Re-run claude login, then restart the daemon. |
Connection failed |
Toggle Windows Bluetooth off/on in Settings. |
Warning: running under Linux/WSL |
Run from a native PowerShell window, not a WSL shell. |
Claude Code-credentials) on macOS, or from ~/.claude/.credentials.json on Linux (%USERPROFILE%\.claude\.credentials.json on Windows).api.anthropic.com/v1/messages — one token of Haiku, basically free.anthropic-ratelimit-unified-5h-utilization and friends).The board has three side buttons. Left and right send HID keys; the middle (PWR) button cycles splash animations and, held for 3 seconds, triggers pairing mode.
| Button | GPIO | Function |
|---|---|---|
| Left | GPIO 0 | Hold to send Space (Claude Code voice-mode push-to-talk) |
| Middle (PWR) | AXP2101 PKEY | On splash: cycle animations. Hold 3s + release: pairing mode |
| Right | GPIO 18 | Press to send Shift+Tab (Claude Code mode toggle) |
Space and Shift+Tab go out as standard BLE HID keyboard reports, so they trigger in whatever window has focus on the paired host — not just Claude Code.
The device advertises a custom GATT service alongside the standard HID keyboard service:
| UUID | |
|---|---|
| Data Service | 4c41555a-4465-7669-6365-000000000001 |
| RX Characteristic (write) | 4c41555a-4465-7669-6365-000000000002 |
| TX Characteristic (notify) | 4c41555a-4465-7669-6365-000000000003 |
| HID Service | 00001812-0000-1000-8000-00805f9b34fb |
JSON payload format (written to RX):
{ "s": 45, "sr": 120, "w": 28, "wr": 7200, "st": "allowed", "ok": true }
Fields: s = session %, sr = session reset (minutes), w = weekly %, wr = weekly reset (minutes), st = status, ok = success flag.
The firmware/src/font_*.c files are pre-compiled LVGL bitmap fonts.
npm install -g lv_font_conv
Generate each one (one at a time — lv_font_conv doesn't like loop-driven invocations) with --no-compress (required for LVGL 9):
# Tiempos Text (titles, 56px)
lv_font_conv --font assets/TiemposText-400-Regular.otf -r 0x20-0x7E \
--size 56 --format lvgl --bpp 4 --no-compress \
-o firmware/src/font_tiempos_56.c --lv-include "lvgl.h"
# Styrene B (large numbers 48, panel labels 28, small text 24, minimal 20)
for size in 48 28 24 20; do
lv_font_conv --font assets/StyreneB-Regular.otf -r 0x20-0x7E \
--size $size --format lvgl --bpp 4 --no-compress \
-o firmware/src/font_styrene_${size}.c --lv-include "lvgl.h"
done
# DejaVu Sans Mono (32px, with spinner Unicode chars)
lv_font_conv --font assets/DejaVuSansMono.ttf \
-r 0x20-0x7E,0xB7,0x2026,0x2722,0x2733,0x2736,0x273B,0x273D \
--size 32 --format lvgl --bpp 4 --no-compress \
-o firmware/src/font_mono_32.c --lv-include "lvgl.h"
Important: lv_font_conv v1.5.3 outputs LVGL 8 format. Each generated file must be patched for LVGL 9 compatibility:
#if LVGL_VERSION_MAJOR >= 8 guards around font_dsc and the font struct.cache field from font_dsc.release_glyph = NULL, .kerning = 0, .static_bitmap = 0 to the font struct.fallback = NULL, .user_data = NULL to the font structWithout these patches, fonts compile but render as invisible.
The UI uses a small set of Lucide icons (bluetooth + battery states) converted to RGB565 / RGB565A8 C arrays for LVGL.
node tools/png_to_lvgl.js assets/icon_bluetooth_48.png icon_bluetooth_data ICON_BLUETOOTH_WIDTH ICON_BLUETOOTH_HEIGHT
Default tint is white (0xFFFFFF); Lucide PNGs ship as black-on-transparent and would render invisible against the dark UI without it. Pass --no-tint for pre-coloured artwork like the logo. Battery icons use RGB565A8 (alpha plane) so they blend cleanly over the splash; the rest are baked RGB565 over the panel colour. Paste the converter output into `firmware/src/icons
$ claude mcp add Clawdmeter \
-- python -m otcore.mcp_server <graph>