The onefile build is windowed (no console), so sys.stderr is None and uvicorn's
default logging dictConfig fails with "Unable to configure formatter 'default'",
which took the whole settings-panel server down (the BLE side kept working, but
the Settings window couldn't load). Pass log_config=None so uvicorn skips its own
logging setup — the daemon has its own file logger and doesn't need uvicorn's.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold the Windows tray/daemon into one self-contained Clawdmeter.exe with a
settings UI, and wire the host side of the watch features:
- config.py: single %LOCALAPPDATA%\Clawdmeter\config.json (ha/buttons/settings),
atomic writes, auto-migration from the old ha_config.json.
- server.py: local FastAPI (127.0.0.1:8723) — GET/PUT /api/config (token masked),
POST /api/ha/test, GET /api/ha/entities, GET /api/status.
- web/index.html: brand-styled settings panel (Status/HA/Buttons/Settings tabs).
- panel.py: pywebview/WebView2 window, launched as its own process (pywebview and
pystray both want the main thread); tray "Settings" opens it via --panel.
- daemon: HA command dispatch (toggle/bri/ct), dynamic button labels + index→
action mapping, watch-battery low warning toast, and the dimmer "dim" snapshot
(dimreq → light_snapshot of the first entity) so the watch dial seeds from HA.
- clawdmeter.spec / requirements: bundle fastapi+uvicorn+pywebview+webview backend.
- build-exe.ps1: ASCII-only (Windows PowerShell 5.1 mangles em-dashes under cp1251).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
daemon/ha_client.py: HAClient (brightness, color_temp, on/off, state snapshot)
over the HA REST API with smooth `transition`; token is read from a local
%LOCALAPPDATA%\Clawdmeter\ha_config.json and is never logged. Verified
end-to-end against a real Gledopto GL-C-006P tunable-white dimmer.
ha_config.example.json is the committed template; the real config (with the
token) lives outside the repo and is .gitignored as a safety net.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gitea.wenil.tech is the old (internal-DNS-only) domain; the live host is
gitea.bvrdo.online. Use a direct release-asset link so download works even
while the instance ROOT_URL still points at the internal IP.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Point README-windows.md at the published v2.0-beta.1 release so users
download Clawdmeter.exe directly from Gitea instead of building it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Package the tray+daemon as a single PyInstaller onefile exe so it runs on a
fresh Windows 11 box with no Python and no pip install. Verified on hardware:
the frozen exe connects over BLE, reads the Windows media session, and pushes
now-playing (incl. Cyrillic) at the 3s / 60s cadence.
- clawdmeter.spec: onefile, windowed (no console; logs to daemon.log). collect_all
for winrt + bleak is the key bit — bleak loads its WinRT backend dynamically and
the winrt.windows.* projections are split distributions that PyInstaller's static
analysis misses, so BLE and the media session would otherwise fail at runtime.
upx off (lowers AV false-positive rate).
- build-exe.ps1: one-command build (venv + deps + pyinstaller + spec).
- tray_windows.py: resolve the brand-logo asset from sys._MEIPASS when frozen.
- autostart_windows.py: when frozen, the HKCU\Run "Start at login" value points at
the exe itself (sys.executable), not pythonw + script.
- .gitignore: ignore /build, /dist, /.venv — the exe ships via a Gitea release.
- README-windows.md: document the standalone-exe path (download / run / build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Decouple the two data sources that share the BLE link:
- Anthropic usage / rate-limit: still polled every 60s.
- Windows media session: read every 3s and pushed the moment the track or
play/pause state changes, so a song change reaches the watch in seconds
instead of at the next 60s poll. The last usage payload is cached and merged
into each now-playing write, so the firmware always gets one complete JSON
object and the usage screens never blank between polls.
Fix: long media titles (a 44-char Cyrillic title -> ~256 B once json escapes
each char to \uXXXX) overflowed the ATT MTU, so every write-without-response
failed with E_INVALIDARG and tripped the zombie-link break in a reconnect loop.
Switch the RX write to response=True (WinRT does a reliable long write; the RX
char already advertises WRITE and NimBLE reassembles into its 512 B buffer) and
serialize with ensure_ascii=False so Cyrillic goes as 2-byte UTF-8 instead of
6-byte escapes. Verified on hardware: stable link, writes succeed across the
60s heartbeat.
Firmware: only re-set the Now Playing labels when the track / state actually
changed, so the faster cadence does not restart the circular title-scroll
animation on every (often identical) payload.
requirements-windows.txt: add winrt-Windows.Media[.Control]. Phase 5 imports
them but they were never declared, so now-playing silently degraded to
"nothing playing" on a fresh machine.
tests: fix two stale poll_api assertions that expected an "ok" key poll_api has
not emitted since that flag moved to the caller (connect_and_run).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The daemon reads the Windows "now playing" media session (WinRT SMTC) and
adds np/nt/na to the BLE payload; the watch shows a play/pause glyph, a
scrolling title, artist and status. Best-effort and token-independent, so
it works even when the rate-limit data is unavailable.
Cyrillic titles: Styrene B has no Cyrillic glyphs, so the title/artist use
new composite fonts (font_styrene_cyr_{28,20}) — Latin from Styrene B,
Cyrillic + typographic punctuation from Montserrat, merged by lv_font_conv.
Latin titles stay on-brand; only Cyrillic falls back to Montserrat.
- daemon: read_now_playing() via winrt-Windows.Media.Control (lazy import,
never raises; PLAYING->1, PAUSED->2, else 0; title/artist truncated,
empty fields omitted)
- firmware: UsageData np_state/np_title/np_artist + parser; the real Now
Playing screen replaces the shared stub (SCREEN_NOWPLAYING)
- fonts: assets/Montserrat-Medium.ttf, font_styrene_cyr_{28,20}.c
- tools: patch_lvgl9_font.py (automates the 4 LVGL 9 font patches),
screenshot_win.py (Windows serial screenshot via pyserial + Pillow)
- README: document Cyrillic composite font generation
Verified on hardware (waveshare_amoled_206): idle state, a live Latin
title (scrolling), and Cyrillic (Prohozhdenie / Dunduk) all render.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In a managed Agent-SDK environment `claude login` is unavailable, so once
the stored access token expired the 5h/7d rate-limit % went dark forever.
The daemon now renews its own access token from the stored refreshToken via
the standard Claude Code OAuth refresh grant and writes the rotated tokens
back atomically (temp + os.replace, so a crash can't corrupt the file that
both the daemon and Claude Code read).
- proactive: refresh ~300s before expiry, checked each poll cycle
- reactive: on a genuine API 401, force one refresh + retry the poll once
- refresh tokens rotate on every use -> always persisted
- never raises: any refresh failure is logged and the loop continues
- secrets never logged (only lengths / expiry); error bodies are {"error":..}
Endpoint + client_id verified empirically against a live refresh
(HTTP 200, expires_in=28800).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Session screen's tokens/cost are computed locally from transcripts and need
no network, but they were gated behind a successful rate-limit API call — so an
expired OAuth token or API outage froze the whole watch.
Daemon: always compute and send local usage every cycle; rate-limit utilization
is now best-effort and merged on top when available, with ok=true only when it's
fresh (ok=false on expired token / API down). poll_api returns just the
rate-limit fields; connect_and_run owns the payload + ok flag.
Firmware: ui_update always refreshes the Session screen; the rate-limit bars and
the freshness clock update only when data->ok, so the usage view falls back to
its existing idle "Zzz" state instead of showing stale or zeroed percentages.
Verified: with the token expired (HTTP 401) the daemon still sends
tk/tc/tn/to + ok:false and the Session screen keeps updating.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pulling the USB Bluetooth dongle made BleakScanner raise from inside the main
loop; the exception propagated out of daemon_main, the bg thread died, and the
tray froze on its last state with no recovery (reported: "daemon crashed and
doesn't come back").
Two layers of hardening:
1. main() loop now wraps scan+connect in try/except — adapter-gone errors are
logged, the tray shows Scanning, and it backs off and retries, so replugging
the adapter recovers automatically. CancelledError is re-raised.
2. The tray supervises the asyncio loop: an unexpected crash is logged and the
loop is RESTARTED after a backoff, gated by a quit_evt so Quit still stops
cleanly (and call_soon_threadsafe is guarded against a closed loop).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs made the Session numbers wildly inflated (~$300/122M when the real
figures were ~$46/57M):
1. No de-duplication. Claude Code copies prior history into a new transcript
file on every compaction/resume, so the same API turn appears in several
.jsonl files. Summing all lines counted each turn multiple times — ~2.7x
inflation today (a heavily-compacted session). De-dup by (messageId,
requestId), matching ccusage.
2. Wrong Opus pricing. Used the older $15/$75 in/out rates; claude-opus-4-x is
actually $5/$25 (cache-write $10, cache-read $0.50) — 3x too high. Rates
verified against ccusage/LiteLLM, which they now reproduce to 100%.
Verified end to end against a fresh ccusage run: total tokens, output tokens and
cost all match exactly (57,256,809 tok / $45.63). Daemon-only change; no firmware
reflash needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The total is ~97% cache reads, which reads as implausibly large on its own.
Show the cache-inclusive total as the headline token number and the output
tokens ("what Claude actually generated", ~1.1M) as a small line beneath it —
impressive total, believable sub-number. Daemon sends a new `to` field; firmware
parses output_today and renders it. Re-spaced the Session layout for the 5th row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Daemon: compute_today_usage() sums today's Claude Code token usage across the
local project transcripts (~/.claude/projects/**/*.jsonl), reading only files
modified today so the scan stays cheap, and prices it with Anthropic list rates
to show the equivalent API cost (the ccusage-style flex for subscription users).
The compact fields tk (total tokens), tc (cost in cents) and tn (message count)
piggyback onto the existing 60s BLE payload.
Firmware: UsageData gains tokens_today (64-bit) / cost_cents_today /
messages_today; parse_json reads tk/tc/tn; a new Session screen shows the cost
as the hero with total tokens and message count below, refreshed from ui_update
so it's current whenever opened. Wire SCREEN_SESSION to the real screen (was a
stub). Verified end to end: daemon sends e.g. tk=105374109 tc=26838 tn=663.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New board firmware/src/boards/waveshare_amoled_206/ (CO5300 410x502,
FT3168 touch, AXP2101 PMU, QMI8658 IMU). PlatformIO env
waveshare_amoled_206. CO5300_COL_OFFSET=22, fixed orientation,
PWR button on GPIO10, FocalTech inline touch reader.
- Windows daemon: fix advertised device name (Claude Controller ->
Clawdmeter) to match firmware ble.cpp, and add CLAWDMETER_ADDRESS env
fallback so it connects by address when the bonded HID device is not
advertising (Windows keeps it HID-connected).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brand consistency: the device now advertises as "Clawdmeter". Updated the
three functional spots that must agree for discovery-by-name to work —
ble.cpp DEVICE_NAME (advertised) and both daemons' DEVICE_NAME (scan match) —
plus the README/CLAUDE.md/install-script references (and fixed the stray
"Claudemeter" typo in the README).
Note: hosts cache the GATT name, so an already-paired device keeps showing the
old name until removed/re-paired (bluetoothctl remove <mac>).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On macOS the firmware is auto-connected by the OS as a BLE HID keyboard,
and CoreBluetooth excludes already-connected peripherals from scan
results. bleak's connect-by-address path also scans internally, so the
daemon's scan loop never found the device ("Device not found" forever)
even though it was plainly visible in System Settings.
Discover the target on macOS via CoreBluetooth's
retrieveConnectedPeripheralsWithServices_ and connect to the returned
peripheral directly (no scan). The custom service UUID is matched first
(unambiguous); the generic HID service 0x1812 is only trusted on an exact
DEVICE_NAME match so it can't grab an unrelated keyboard/mouse. A
peripheral that fails to connect is skipped for one cycle so the scan
fallback stays reachable. The device's two connection slots let the OS
HID link and the daemon run simultaneously.
Linux/BlueZ path is unchanged (still scans + caches address); the
redundant per-reconnect address save was removed so caching happens only
on a fresh scan.
Adds daemon/test_macos_connect.py, a foreground smoke test for the macOS
connect path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
macOS Bluetooth quirks made the original BLE setup unusable from a Mac
host. Five focused changes to fix discovery, pairing, the keyboard
identification wizard, and concurrent connections, plus a couple of
cross-platform daemon/script niceties.
Firmware (ble.cpp):
* Advertise the standard HID Service UUID (0x1812) in the primary
packet. Without it, macOS Sequoia's Bluetooth Settings GUI
recognizes the device internally but silently hides it from the
"Nearby Devices" list. Service UUIDs >16-bit overflow the 31-byte
advertising packet, so the custom data-service UUID moved to the
scan response.
* Switch PnP ID from Apple's USB vendor (0x05AC + Magic Keyboard
PID 0x820A) to Espressif's BT SIG vendor (0x02E5). macOS validates
Apple-claimed HIDs against known device IDs and refuses to surface
a Connect button for spoofers.
* Add the LED output report (Num/Caps/Scroll Lock) to the HID
descriptor — macOS treats a keyboard descriptor without LEDs as
"incomplete" and triggers the Keyboard Setup Assistant repeatedly.
* Set HID country code to 33 (US ANSI) instead of 0 (Not Supported)
so macOS can identify the layout without asking the user.
* Bump CONFIG_BT_NIMBLE_MAX_CONNECTIONS to 2 and restart advertising
after each accept. macOS holds one connection for the HID keyboard
link; the daemon now gets its own slot for the data service in
parallel, instead of either side starving the other.
screenshot.sh: auto-pick /dev/cu.usbmodem101 on macOS vs /dev/ttyACM0
on Linux, fall back to PlatformIO's bundled Python if pyserial isn't
on the system Python (PEP 668 blocks `pip install` on Homebrew Python),
and pass the actual framebuffer dimensions to ffmpeg instead of
hardcoding 480x480.
daemon/claude_usage_daemon.py: log API HTTP status + response body on
4xx/5xx so silent token-expiry failures (the daemon was reporting
{"s":0,"w":0} payloads instead of surfacing a 401) are visible in the
daemon log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cherry-picks the macOS-specific pieces from PR #5: Python daemon using
bleak (CoreBluetooth backend), LaunchAgent template, flash-mac.sh, and
install-mac.sh. Token is read from the macOS Keychain ("Claude
Code-credentials" service); Linux behavior is unchanged. README split
into "macOS installation" and "Linux installation" sections with
parallel Flash / Pair / Install subsections.
Co-Authored-By: Chris Davidson <36679917+lorddavidson@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Cache invalidation: drop ~/.config/claude-usage-monitor/ble-address and remove
the dead MAC from bluez on connect failure, so swapping ESP boards no longer
pins the daemon to a stale factory MAC
- Scan robustness: pick first matching candidate (head -1), sanity-check cached
content for valid MAC format, handle multiple Claude Controllers in bluez
- 5s tick + 60s poll: inner loop wakes every 5s for fast disconnect detection
while keeping Anthropic API cadence at one minute
- Refresh-request channel: new GATT characteristic ...0004 NOTIFY, firmware
fires once in onSubscribe when has_received_data is false; daemon subscribes
via setsid'd dbus-monitor pipeline (process group cleanup avoids the
bash-wait-on-job hang we hit on disconnect)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move all communication from USB to BLE. The device now advertises as
"Claude Controller" and acts as both a BLE HID keyboard (for touch
gestures) and a GATT data server (for usage updates from the daemon).
- Add NimBLE-Arduino BLE module with custom GATT service + HID keyboard
- Add third screen (Bluetooth) with connection status, MAC, reset button
- Rewrite daemon in bash using bluetoothctl/busctl for BLE GATT writes
- Add screenshot capture via LVGL snapshot over serial
- Remove TinyUSB mode — normal pio upload works again
- Update README with BLE architecture, screenshots, gesture docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Physical desk monitor that displays Claude Code usage limits (5-hour session
and 7-day weekly utilization) on an ESP32-S3 touchscreen via USB serial.
Firmware: LVGL 9 dashboard with LovyanGFX on SC01 Plus, Anthropic brand
colors and custom fonts (Tiempos, Styrene B, DejaVu Sans Mono), Claude
spinner animation with rotating status words.
Daemon: Pure bash script that reads the Claude Code OAuth token, makes a
minimal Haiku API call, extracts usage from rate-limit response headers,
and sends JSON to the ESP32 over serial. Uses inotifywait for instant
USB plug/unplug detection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>