MCPcopy Index your code
hub / github.com/HermannBjorgvin/Clawdmeter

github.com/HermannBjorgvin/Clawdmeter @main

Chat with this repo
repository ↗ · DeepWiki ↗ · + Follow
526 symbols 1,334 edges 113 files 174 documented · 33%
What it actually does AI analysis from the code graph — generated when you open this
loading…
README

Clawdmeter

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
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.

Screens

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 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.

Hardware

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.

Prerequisites

  • Linux (tested on Ubuntu), macOS, or Windows 10/11
  • PlatformIO CLI
  • Linux: curl, bluetoothctl, busctl (BlueZ Bluetooth stack)
  • macOS: python3 (the installer sets up a venv with bleak and httpx)
  • Windows: python3 3.11+ (the installer sets up a venv with bleak, httpx, and pystray)
  • Claude Code with an active subscription

macOS installation

The macOS host pieces — Python daemon, LaunchAgent, and flash helper — were ported by Chris Davidson (@lorddavidson). Thanks Chris!

Flash the firmware

./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).

Pair the device

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).

Install the daemon

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

Linux installation

Flash the firmware

./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).

Pair the device

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.

Install the daemon

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

Windows installation

Runs natively on Windows — no WSL required. A system-tray app polls your usage and pushes it over BLE, and starts automatically at login.

Prerequisites

  • Native Windows (not WSL).
  • Python 3.11+ from python.org — check "Add python.exe to PATH" during install.
  • Claude Code installed, with claude login completed. The token is read from %USERPROFILE%\.claude\.credentials.json (falling back to %LOCALAPPDATA%\Claude\ then %APPDATA%\Claude\).
  • The repo on a native Windows path (e.g. %USERPROFILE%\Clawdmeter), not a \\wsl$ share — the installer refuses a WSL path.

Flash the firmware

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.

Pair the device

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).

Install the daemon (recommended)

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).

Run manually instead (optional)

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

Tray icon and menu

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:

  • Status header — live state + last sync time.
  • Start at login — toggle autostart on/off.
  • Quit — stops the daemon cleanly; leaves the Windows pairing intact (device keeps its last reading).

Logs and troubleshooting

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.

How it works

  1. The daemon reads your Claude Code OAuth token — from the macOS Keychain (service Claude Code-credentials) on macOS, or from ~/.claude/.credentials.json on Linux (%USERPROFILE%\.claude\.credentials.json on Windows).
  2. It makes a minimal API call to api.anthropic.com/v1/messages — one token of Haiku, basically free.
  3. The usage numbers come straight out of the response headers (anthropic-ratelimit-unified-5h-utilization and friends).
  4. The daemon connects to the ESP32 over BLE and writes a JSON payload to the GATT RX characteristic.
  5. The firmware parses it and updates the LVGL dashboard.
  6. The firmware also tracks the rate of change of session % over a 5-minute window and picks splash animations from the matching mood group.
  7. The two side buttons are independent of all of this — they send Space and Shift+Tab as BLE HID keyboard input to the paired host directly.

Physical buttons

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.

BLE protocol

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.

Recompiling fonts

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:

  1. Remove #if LVGL_VERSION_MAJOR >= 8 guards around font_dsc and the font struct
  2. Remove the .cache field from font_dsc
  3. Add .release_glyph = NULL, .kerning = 0, .static_bitmap = 0 to the font struct
  4. Add .fallback = NULL, .user_data = NULL to the font struct

Without these patches, fonts compile but render as invisible.

Converting Lucide icons

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

Core symbols most depended-on inside this repo

log
called by 34
daemon/claude_usage_daemon.py
es8311_write_reg
called by 28
firmware/src/es8311.c
log
called by 22
daemon/claude_usage_daemon_windows.py
poll_api
called by 17
daemon/claude_usage_daemon_windows.py
choose
called by 13
daemon/claude_usage_daemon.py
connect_and_run
called by 13
daemon/claude_usage_daemon_windows.py
es8311_read_reg
called by 10
firmware/src/es8311.c
hdr
called by 8
daemon/claude_usage_daemon.py

Shape

Function 484
Method 19
Class 16
Enum 7

Languages

C++49%
Python44%
C4%
TypeScript2%

Modules by API surface

daemon/tests/test_windows_reconnect.py39 symbols
daemon/claude_usage_daemon.py36 symbols
daemon/claude_usage_daemon_windows.py30 symbols
firmware/src/ble.cpp28 symbols
firmware/src/ui.cpp25 symbols
daemon/tests/test_windows_tray.py23 symbols
daemon/tests/test_macos_multidir.py21 symbols
firmware/src/es8311.c19 symbols
daemon/tests/test_windows_poll.py19 symbols
firmware/src/splash.cpp14 symbols
daemon/tests/test_windows_token.py14 symbols
daemon/tray_windows.py12 symbols

For agents

$ claude mcp add Clawdmeter \
  -- python -m otcore.mcp_server <graph>

⬇ download graph artifact