15 Commits
Author SHA1 Message Date
wenilandClaude Opus 4.8 ec8322fa21 v2: standalone Clawdmeter.exe — runs on any Win11 machine, no Python
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>
2026-06-21 09:58:26 +03:00
wenilandClaude Opus 4.8 650af4221b v2: real-time Now Playing cadence + robust long-title BLE writes
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>
2026-06-21 09:43:31 +03:00
wenilandClaude Opus 4.8 b8d4daa03f v2 Phase 5: Now Playing — Windows media session on the watch
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>
2026-06-21 00:41:15 +03:00
wenilandClaude Opus 4.8 e682b43735 daemon: autonomous OAuth token refresh (no manual /login)
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>
2026-06-20 23:42:25 +03:00
wenilandClaude Opus 4.8 b188349262 v2 Phase 4: decouple Session (local usage) from the rate-limit API
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>
2026-06-20 18:49:34 +03:00
wenilandClaude Opus 4.8 4c1b63e645 daemon: survive BLE adapter removal and auto-restart on crash
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>
2026-06-20 18:39:58 +03:00
wenilandClaude Opus 4.8 26bc85ffe6 v2 Phase 4: fix token over-count (dedup) and Opus pricing
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>
2026-06-20 15:27:56 +03:00
wenilandClaude Opus 4.8 c2020dcea2 v2 Phase 4: add "generated" (output) tokens under the total on Session
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>
2026-06-20 15:14:27 +03:00
wenilandClaude Opus 4.8 466e32fda1 v2 Phase 4: relabel Session "messages" -> "requests"
The count is assistant API turns (each tool-use round is one), not user chat
messages — "messages" misled into reading it as conversation count / all-time.
"requests" reads correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:09:25 +03:00
wenilandClaude Opus 4.8 56b49898a3 v2 Phase 4: Session screen — today's tokens and equivalent cost
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>
2026-06-20 15:03:23 +03:00
wenilandClaude Opus 4.8 abe72eca0a v2 Phase 3: on-screen soundpad sending HID F13..F18
Add a Soundpad app screen: a 2x3 grid of colored pads that each emit one of
F13..F18 over the existing BLE HID keyboard, for mapping to a host soundboard.
Key release is deferred via a one-shot LVGL timer so the UI thread never blocks.
Raise the HID report-map keycode ceiling from 101 (0x65) to 115 (0x73) in
ble.cpp so F13..F24 (0x68..0x73) are within the declared key-array range — they
were silently undeliverable before. Wire SCREEN_SOUNDPAD to the real screen
(was a stub).

NOTE: the HID report descriptor changed, so the host must re-pair to refresh its
cached map before F13..F18 are recognized.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:49:15 +03:00
wenilandClaude Opus 4.8 d5d80e81c0 v2 Phase 2: fix launcher scroll rest position + back chevron placement
The tile grid used track_cross_place=CENTER, which vertically centers the
overflowing block of rows — hiding the top row under the title and springing
back to it after an elastic pull. Switch to START so rows stack from the top:
the first row sits under the title and the rest is reached by scrolling down.
Nudge the back chevron down/right so it clears the rounded top-left corner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:45:23 +03:00
wenilandClaude Opus 4.8 5bed8cbd1f v2 Phase 2: app-launcher navigation shell
Replace the splash<->usage tap-toggle with a home + launcher model. Tapping the
Claude logo on the home (usage) screen opens a scrollable 2-column launcher
driven by an app registry — adding a screen is one registry line plus a builder,
no edits to shared navigation. A shared back chevron returns launcher->home and
app->launcher; tapping the splash returns to home.

Screens wired: Usage (home) and Animations (splash) are real; Bluetooth shows
live connection state + device name + MAC; Soundpad/Session/Now Playing/Home
point at a shared "coming soon" stub until their phases land. Enable Montserrat
20/28 for the LVGL symbol glyphs used by tile icons and the back chevron (the
brand Styrene/Tiempos fonts are Latin-only). Flash 20.3% -> 20.9%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:34:02 +03:00
wenilandClaude Opus 4.8 8d82beaacf v2 Phase 1: verify FT3168 touch needs no coordinate transform
On-hardware calibration (5-point corner+center tap) confirms the FT3168 on the
2.06 panel reports directly in panel pixel space (~0..409 x ~0..501), correctly
oriented with no axis swap or mirror — a center tap reads ~(209,228) against the
410x502 panel center (205,251), a 1:1 mapping within finger error. The existing
raw passthrough is correct; record the verified result in the driver comment.
The temporary on-screen TOUCH_DEBUG overlay used to gather this has been removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:16:00 +03:00
wenilandClaude Opus 4.8 25b101d086 v2: kick off v2-dev — version constant + roadmap
Freeze the classic line on main (tagged v1.0-classic) and open the v2
development branch. Add a firmware version constant (2.0.0-dev) reported in
the boot banner so the daemon can tell classic from v2, and document the
phased v2 plan in docs/v2-roadmap.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:49:40 +03:00
24 changed files with 10441 additions and 110 deletions
+9
View File
@@ -18,3 +18,12 @@ daemon/.venv/
# Python bytecode cache # Python bytecode cache
__pycache__/ __pycache__/
*.pyc *.pyc
# Windows daemon venv
/.venv/
# PyInstaller build artifacts — the standalone exe ships via a Gitea release,
# not git (build it with build-exe.ps1 / clawdmeter.spec).
/build/
/dist/
*_dbg.spec
+22
View File
@@ -289,6 +289,28 @@ to preserve the brand font — Chinese text in those slots renders as
empty boxes. Add a `font_cjk_28.c` if full coverage is needed (~1MB empty boxes. Add a `font_cjk_28.c` if full coverage is needed (~1MB
more flash). more flash).
### Cyrillic (Now Playing titles)
`firmware/src/font_styrene_cyr_{28,20}.c` back the Now Playing screen's
title/artist, where user content (media titles) can be non-Latin. Styrene B
has no Cyrillic glyphs, so these are **composite** fonts: Latin from Styrene B,
Cyrillic + typographic punctuation ( — ' ' " " …) from Montserrat. `lv_font_conv`
merges multiple `--font`/`-r` groups into one font — a pure-Latin title stays
on-brand, only Cyrillic falls back to Montserrat.
```bash
for size in 28 20; do
lv_font_conv \
--font assets/StyreneB-Regular.otf -r 0x20-0x7E \
--font assets/Montserrat-Medium.ttf -r '0x2013,0x2014,0x2018,0x2019,0x201C,0x201D,0x2026,0x400-0x45F' \
--size $size --format lvgl --bpp 4 --no-compress \
-o firmware/src/font_styrene_cyr_${size}.c --lv-include lvgl.h
done
```
Then apply the four LVGL 9 patches above — `tools/patch_lvgl9_font.py <file...>` does
all four automatically.
## Converting Lucide icons ## Converting Lucide icons
The UI uses a small set of [Lucide](https://lucide.dev) icons (bluetooth + battery states) converted to RGB565 / RGB565A8 C arrays for LVGL. The UI uses a small set of [Lucide](https://lucide.dev) icons (bluetooth + battery states) converted to RGB565 / RGB565A8 C arrays for LVGL.
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
# build-exe.ps1 — build the standalone Clawdmeter.exe (PyInstaller).
#
# Produces dist\Clawdmeter.exe: a single self-contained tray + daemon executable
# that runs on ANY Windows 11 machine with no Python install and no pip. Build it
# on a machine that DOES have Python (e.g. your dev box), then distribute the exe
# via a Gitea release; end users just download and run it (see daemon\README-windows.md).
#
# Usage (from anywhere):
# powershell -ExecutionPolicy Bypass -File build-exe.ps1
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Log { param([string]$Msg) Write-Host "[$(Get-Date -Format HH:mm:ss)] $Msg" }
$RepoRoot = $PSScriptRoot
if (-not $RepoRoot) { $RepoRoot = (Get-Location).Path }
Set-Location $RepoRoot
$VenvDir = Join-Path $RepoRoot ".venv"
$PythonExe = Join-Path $VenvDir "Scripts\python.exe"
Log "=== Clawdmeter exe build ==="
# 1. Virtual environment
if (-not (Test-Path $PythonExe)) {
Log "Creating virtual environment at .venv ..."
& python -m venv $VenvDir
if ($LASTEXITCODE -ne 0) { throw "venv creation failed (exit $LASTEXITCODE) — is Python on PATH?" }
}
# 2. Runtime dependencies + PyInstaller (the only build-time extra)
Log "Installing runtime dependencies + PyInstaller ..."
& $PythonExe -m pip install --quiet -r (Join-Path $RepoRoot "daemon\requirements-windows.txt")
if ($LASTEXITCODE -ne 0) { throw "pip install (runtime deps) failed (exit $LASTEXITCODE)" }
& $PythonExe -m pip install --quiet pyinstaller
if ($LASTEXITCODE -ne 0) { throw "pip install pyinstaller failed (exit $LASTEXITCODE)" }
# 3. Build from the committed spec (onefile, windowed)
Log "Building dist\Clawdmeter.exe (this takes a minute) ..."
& $PythonExe -m PyInstaller --noconfirm (Join-Path $RepoRoot "clawdmeter.spec")
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed (exit $LASTEXITCODE)" }
$ExePath = Join-Path $RepoRoot "dist\Clawdmeter.exe"
if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" }
$sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1)
Log "Build complete: $ExePath ($sizeMB MB)"
Log "Distribute this exe via a Gitea release — it is intentionally not committed to git."
+76
View File
@@ -0,0 +1,76 @@
# -*- mode: python ; coding: utf-8 -*-
#
# clawdmeter.spec — build the standalone Windows daemon + tray executable.
#
# Produces a single, self-contained dist\Clawdmeter.exe that bundles its own
# Python, bleak (WinRT BLE) and the winrt media projection — so it runs on ANY
# Windows 11 machine with no Python and no pip install. The exe is the tray app
# (daemon thread + notification-area icon + login-autostart toggle); it is built
# windowed, so there is no console window and it logs to
# %LOCALAPPDATA%\Clawdmeter\daemon.log.
#
# Build (from the repo root, inside the venv) — or just run build-exe.ps1:
# .venv\Scripts\python.exe -m PyInstaller --noconfirm clawdmeter.spec
#
# winrt + bleak ship C-extension projections (.pyd) that PyInstaller's static
# analysis misses: bleak imports its WinRT backend dynamically, and the
# winrt.windows.* namespaces are split across separate distributions. collect_all
# pulls in their submodules, binaries and metadata so both BLE (bluetooth) and the
# now-playing media session work inside the frozen exe. Verified on hardware:
# connects, reads the media session, and pushes Cyrillic now-playing payloads.
from PyInstaller.utils.hooks import collect_all
datas = [('firmware/src/logo.h', 'firmware/src')] # tray icon parsed at runtime
binaries = []
hiddenimports = [
# Imported lazily inside tray_windows.main(), so name them explicitly.
'daemon.claude_usage_daemon_windows',
'daemon.autostart_windows',
'daemon.icon_assets',
# The exact winrt media modules read_now_playing() pulls in.
'winrt.windows.media',
'winrt.windows.media.control',
]
for _pkg in ('winrt', 'bleak', 'pystray', 'PIL'):
_d, _b, _h = collect_all(_pkg)
datas += _d
binaries += _b
hiddenimports += _h
a = Analysis(
['daemon\\tray_windows.py'],
pathex=['.'],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['tkinter', 'pytest'],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Clawdmeter',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False, # UPX compression raises AV false-positive rates — leave it off
upx_exclude=[],
runtime_tmpdir=None,
console=False, # windowed tray app: no console window; logs to daemon.log
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+49 -5
View File
@@ -59,7 +59,46 @@ pairing disables the keyboard buttons.
--- ---
## Setup (one time) ## Standalone executable — no Python required (recommended)
To run Clawdmeter on a machine **without Python**, use the single-file
`Clawdmeter.exe`. It bundles its own Python, the WinRT BLE stack and the
media-session reader, so nothing needs to be installed.
1. Pair the device with Windows once (see [Pair the device](#pair-the-device-one-time)).
2. Get `Clawdmeter.exe` — download it from the project's Gitea release, or build it
yourself (below).
3. Double-click `Clawdmeter.exe`. The tray icon appears and the watch starts
updating within ~10 seconds.
4. To launch it automatically at every logon, right-click the tray icon →
**Start at login**. That registers the exe itself under
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run` — no Python, no console window.
There is no console window; the exe logs to `%LOCALAPPDATA%\Clawdmeter\daemon.log`.
### Building the exe
On a machine that *does* have Python (e.g. your dev box), from the repo root:
```powershell
powershell -ExecutionPolicy Bypass -File build-exe.ps1
```
This creates `dist\Clawdmeter.exe` (~3060 MB). The PyInstaller config lives in
`clawdmeter.spec`. The exe is intentionally **not** committed to git — distribute
it through a Gitea release.
> **SmartScreen / antivirus:** unsigned PyInstaller executables are sometimes
> flagged by a generic heuristic (not a real detection). Until the exe is
> code-signed, you may need to allow it through SmartScreen ("More info → Run
> anyway"). Building from source sidesteps this entirely.
---
## Setup from source (one time)
> Use this if you prefer running from a Python checkout instead of the standalone
> exe above (e.g. on your dev box).
Open a PowerShell terminal and `cd` to the repository root. Open a PowerShell terminal and `cd` to the repository root.
@@ -118,9 +157,14 @@ python daemon\claude_usage_daemon_windows.py
payload within a few seconds of connect (warm token path). With a valid, non-expired token payload within a few seconds of connect (warm token path). With a valid, non-expired token
the device should leave its waiting screen and show session + weekly percentages within the device should leave its waiting screen and show session + weekly percentages within
about 10 seconds of launch. about 10 seconds of launch.
- The daemon then re-polls every 60 seconds while connected. If the device fires a refresh - The daemon then re-polls the Anthropic API every 60 seconds while connected. If the device
request (e.g., after a button press), an immediate re-poll occurs without waiting for the fires a refresh request (e.g., after a button press), an immediate re-poll occurs without
60-second interval. waiting for the 60-second interval.
- The **Now Playing** screen updates on a separate, faster cadence: the daemon reads the
Windows media session every 3 seconds and pushes an update the moment the track or
play/pause state changes — so the watch reflects a song change within a few seconds, not at
the next 60-second API poll. The cached usage data is re-sent with each of these updates, so
the rate-limit / token screens never blank between polls.
- If the device disconnects or goes out of range, the daemon logs `Device disconnected` and - If the device disconnects or goes out of range, the daemon logs `Device disconnected` and
re-scans automatically with exponential backoff (starting at 1 second, capped at 60 seconds). re-scans automatically with exponential backoff (starting at 1 second, capped at 60 seconds).
@@ -219,5 +263,5 @@ launched.
## What is NOT covered here ## What is NOT covered here
- PyInstaller / one-file `.exe` packaging — v2 - Code-signing the standalone `.exe` (to avoid SmartScreen/AV prompts) — future
- MAC-address cache / sleep-wake reconnect hardening — Phase 3 - MAC-address cache / sleep-wake reconnect hardening — Phase 3
+5
View File
@@ -56,6 +56,11 @@ def _command(tray_script: str | None = None) -> str:
module's own path (useful when autostart_windows.py IS module's own path (useful when autostart_windows.py IS
the entry point, but callers should pass tray_windows.py). the entry point, but callers should pass tray_windows.py).
""" """
if getattr(sys, "frozen", False):
# Running as the bundled PyInstaller exe: autostart launches the exe
# itself (sys.executable), not a pythonw + script pair. The exe is built
# windowed, so this is genuinely consoleless — no script path involved.
return f'"{sys.executable}"'
pythonw = os.path.join(sys.base_exec_prefix, "pythonw.exe") pythonw = os.path.join(sys.base_exec_prefix, "pythonw.exe")
script = os.path.abspath(tray_script if tray_script is not None else __file__) script = os.path.abspath(tray_script if tray_script is not None else __file__)
return f'"{pythonw}" "{script}"' return f'"{pythonw}" "{script}"'
+403 -34
View File
@@ -33,8 +33,13 @@ SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001"
RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002" RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002"
REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004" REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004"
POLL_INTERVAL = 60 POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds)
TICK = 5 NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The
# "music card" is refreshed ~20x faster than the API
# poll so a track / play-pause change reaches the watch
# within a few seconds instead of at the next 60s poll.
# Doubles as the inner-loop tick (was TICK=5) — the loop
# wakes this often to read media + detect a dropped link.
SCAN_TIMEOUT = 8.0 SCAN_TIMEOUT = 8.0
CONNECT_RETRIES = 3 # D-01: attempts before giving up on a device CONNECT_RETRIES = 3 # D-01: attempts before giving up on a device
CONNECT_RETRY_DELAY = 2.0 # D-01: seconds between failed connect attempts CONNECT_RETRY_DELAY = 2.0 # D-01: seconds between failed connect attempts
@@ -57,6 +62,120 @@ API_BODY = {
"messages": [{"role": "user", "content": "hi"}], "messages": [{"role": "user", "content": "hi"}],
} }
# --- OAuth self-refresh -------------------------------------------------------
# In a managed (Agent SDK) environment `claude login` is unavailable, so once the
# stored access token expires nothing renews it and the 5h/7d rate-limit % goes
# dark permanently. The credentials file ships a refreshToken, so the daemon
# renews its own access token via the standard Claude Code OAuth refresh grant
# and writes the (rotated) tokens back. Endpoint + client_id verified empirically
# against a live refresh: HTTP 200, refresh_token rotates, expires_in=28800 (8h).
OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
TOKEN_REFRESH_SKEW = 300 # refresh this many seconds BEFORE expiry (proactive)
# Anthropic list prices, USD per million tokens, keyed by a substring of the
# model id. Used to show the "equivalent API cost" of today's Claude Code usage
# — subscription users don't actually pay this; it's the ccusage-style flex.
# Verified against ccusage/LiteLLM: cost reproduces to 100%. The rates follow
# Anthropic's fixed structure (output 5x, cache-write 2x, cache-read 0.1x of the
# base input rate). Opus 4.x is $5/$25 — NOT the older $15/$75.
PRICING = {
"opus": {"in": 5.0, "out": 25.0, "cache_w": 10.0, "cache_r": 0.50},
"sonnet": {"in": 3.0, "out": 15.0, "cache_w": 6.0, "cache_r": 0.30},
"haiku": {"in": 0.75, "out": 3.75, "cache_w": 1.50, "cache_r": 0.075},
}
_DEFAULT_PRICE = PRICING["opus"]
def _price_for(model: str) -> dict:
m = (model or "").lower()
for key, price in PRICING.items():
if key in m:
return price
return _DEFAULT_PRICE
def compute_today_usage() -> dict:
"""Sum today's Claude Code token usage + equivalent API cost across all local
project transcripts (~/.claude/projects/**/*.jsonl).
Reads only files modified today (a session that touched today has mtime
today), so the scan stays cheap even with a large transcript history. Each
assistant line carries message.usage (input/output/cache token counts) and a
UTC timestamp; per-line timestamps gate to today so a session spanning
midnight is split correctly. Assistant turns are de-duplicated by
(messageId, requestId): Claude Code copies history into new transcript files
on compaction/resume, so the same API turn appears in several files and a
naive sum over-counts (it inflated the total ~2.7x). Matches ccusage.
Returns the compact BLE fields tk/tc/tn/to.
"""
base = Path.home() / ".claude" / "projects"
today = datetime.date.today()
midnight = datetime.datetime.combine(today, datetime.time.min)
total_tokens = 0
output_tokens = 0
cost = 0.0
messages = 0
seen = set()
try:
files = list(base.glob("**/*.jsonl"))
except OSError:
return {"tk": 0, "tc": 0, "tn": 0}
for f in files:
try:
if datetime.datetime.fromtimestamp(f.stat().st_mtime) < midnight:
continue
except OSError:
continue
try:
with open(f, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
msg = obj.get("message") if isinstance(obj, dict) else None
if not isinstance(msg, dict):
continue
usage = msg.get("usage")
if not isinstance(usage, dict):
continue
ts = obj.get("timestamp")
if not ts:
continue
try:
d = datetime.datetime.fromisoformat(
ts.replace("Z", "+00:00")).astimezone().date()
except ValueError:
continue
if d != today:
continue
# De-dup the same API turn copied across resumed/compacted files.
mid = obj.get("messageId") or msg.get("id")
rid = obj.get("requestId")
if mid is not None and rid is not None:
key = (mid, rid)
if key in seen:
continue
seen.add(key)
inp = usage.get("input_tokens", 0) or 0
out = usage.get("output_tokens", 0) or 0
cw = usage.get("cache_creation_input_tokens", 0) or 0
cr = usage.get("cache_read_input_tokens", 0) or 0
total_tokens += inp + out + cw + cr
output_tokens += out
messages += 1
p = _price_for(msg.get("model"))
cost += (inp * p["in"] + out * p["out"]
+ cw * p["cache_w"] + cr * p["cache_r"]) / 1_000_000
except OSError:
continue
return {"tk": int(total_tokens), "tc": int(round(cost * 100)),
"tn": messages, "to": int(output_tokens)}
def _build_file_logger() -> logging.Logger | None: def _build_file_logger() -> logging.Logger | None:
"""Create a rotating file logger for field diagnostics, or None. """Create a rotating file logger for field diagnostics, or None.
@@ -150,15 +269,16 @@ async def poll_api(token: str) -> dict | None:
except ValueError: except ValueError:
return 0 return 0
payload = { # Rate-limit utilization only. The local token usage and the "ok" flag are
# merged by the caller (connect_and_run) so the Session screen keeps updating
# even when this call fails (expired token / API down) — see decoupling there.
return {
"s": pct(hdr("anthropic-ratelimit-unified-5h-utilization")), "s": pct(hdr("anthropic-ratelimit-unified-5h-utilization")),
"sr": reset_minutes(hdr("anthropic-ratelimit-unified-5h-reset")), "sr": reset_minutes(hdr("anthropic-ratelimit-unified-5h-reset")),
"w": pct(hdr("anthropic-ratelimit-unified-7d-utilization")), "w": pct(hdr("anthropic-ratelimit-unified-7d-utilization")),
"wr": reset_minutes(hdr("anthropic-ratelimit-unified-7d-reset")), "wr": reset_minutes(hdr("anthropic-ratelimit-unified-7d-reset")),
"st": hdr("anthropic-ratelimit-unified-5h-status", "unknown"), "st": hdr("anthropic-ratelimit-unified-5h-status", "unknown"),
"ok": True,
} }
return payload
async def scan_for_device(): async def scan_for_device():
@@ -197,10 +317,21 @@ class Session:
log(f"Refresh subscription unavailable: {e}") log(f"Refresh subscription unavailable: {e}")
async def write_payload(self, payload: dict) -> bool: async def write_payload(self, payload: dict) -> bool:
data = json.dumps(payload, separators=(",", ":")).encode() # ensure_ascii=False keeps non-Latin titles as compact UTF-8 (~2 bytes/char)
# instead of \uXXXX (6 bytes), which roughly thirds the size of a Cyrillic
# "now playing" payload. ArduinoJson parses UTF-8 directly and LVGL renders
# it, so this is a pure wire-size win with no firmware change.
data = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
log(f"Sending: {data.decode()}") log(f"Sending: {data.decode()}")
try: try:
await self.client.write_gatt_char(RX_CHAR_UUID, data, response=False) # response=True (Write Request, not Write Command). WinRT performs a
# reliable long write when the payload exceeds the ATT MTU, so a long
# media title no longer fails with E_INVALIDARG the way a larger-than-MTU
# write-without-response does. The RX characteristic advertises WRITE as
# well as WRITE_NR and NimBLE reassembles the long write (512 B buffer).
# A successful write now also means the peer actually ACKed, which
# sharpens the zombie-link detection below.
await self.client.write_gatt_char(RX_CHAR_UUID, data, response=True)
return True return True
except (BleakError, OSError) as e: except (BleakError, OSError) as e:
# WinRT can raise a raw OSError/WinError (NOT wrapped as BleakError) # WinRT can raise a raw OSError/WinError (NOT wrapped as BleakError)
@@ -312,12 +443,164 @@ def _read_expiry() -> str:
return "expiry unknown" return "expiry unknown"
def _read_credentials() -> tuple[Path, dict] | None:
"""Return (path, parsed-JSON dict) for the first readable credentials file.
Same probe order as read_token() so the file we refresh is the file we read
the token from.
"""
for path in _windows_credential_candidates():
try:
return path, json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
return None
def _write_credentials_atomic(path: Path, data: dict) -> None:
"""Write credentials JSON via temp-file + os.replace (atomic same-volume).
Avoids leaving a half-written .credentials.json if the process dies mid-write
— a corrupt credentials file would break BOTH the daemon and Claude Code.
"""
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
os.replace(tmp, path)
def _token_expired(oauth: dict, skew: int = TOKEN_REFRESH_SKEW) -> bool:
"""True if the access token is within `skew` seconds of expiry (or past it).
Unknown/absent expiresAt -> False: don't refresh blindly on every cycle; the
API-401 path (force=True) drives a refresh if the token is actually rejected.
"""
exp = oauth.get("expiresAt")
if not isinstance(exp, (int, float)):
return False
return (exp / 1000.0) <= (time.time() + skew)
async def refresh_token_if_needed(force: bool = False) -> bool:
"""Renew the OAuth access token from the stored refresh token when it is
expired/near-expiry (or force=True), writing the rotated tokens back atomically.
Returns True iff a refresh succeeded and credentials were updated. Never
raises — every failure is logged and returned as False so a refresh hiccup
can never take down the poll loop.
"""
rc = _read_credentials()
if rc is None:
return False
path, data = rc
oauth = data.get("claudeAiOauth")
if not isinstance(oauth, dict):
return False
refresh = oauth.get("refreshToken")
if not isinstance(refresh, str) or not refresh.strip():
return False
if not force and not _token_expired(oauth):
return False
body = {
"grant_type": "refresh_token",
"refresh_token": refresh,
"client_id": OAUTH_CLIENT_ID,
}
try:
async with httpx.AsyncClient(timeout=20.0) as http:
resp = await http.post(
OAUTH_TOKEN_URL, json=body,
headers={"Content-Type": "application/json"},
)
except httpx.HTTPError as e:
log(f"Token refresh network error: {e}")
return False
if resp.status_code != 200:
# Error bodies are {"error": ...} — no secrets; safe to log a snippet.
log(f"Token refresh HTTP {resp.status_code}: {resp.text[:200]}")
return False
try:
tok = resp.json()
except ValueError:
log("Token refresh: non-JSON response")
return False
new_access = tok.get("access_token")
if not new_access:
log("Token refresh: response had no access_token")
return False
oauth["accessToken"] = new_access
if tok.get("refresh_token"):
oauth["refreshToken"] = tok["refresh_token"] # refresh tokens rotate
if tok.get("expires_in"):
oauth["expiresAt"] = int((time.time() + float(tok["expires_in"])) * 1000)
try:
_write_credentials_atomic(path, data)
except OSError as e:
log(f"Token refresh: could not write credentials: {e}")
return False
log(f"OAuth token refreshed; new expiry {_read_expiry()}")
return True
# --- Now Playing (Phase 5) ----------------------------------------------------
# Best-effort read of the Windows "now playing" media session (System Media
# Transport Controls) so the watch can show the current track. Pure local WinRT —
# no network, no token — so it works even when the rate-limit data is unavailable.
# Any failure degrades to {"np": 0} (nothing playing) and never disturbs the loop.
NP_MAX_LEN = 60 # truncate title/artist so the BLE payload stays comfortably small
async def read_now_playing() -> dict:
"""Return compact now-playing fields for the BLE payload::
{"np": 0|1|2, "nt": <title>, "na": <artist>}
np: 0 = nothing playing, 1 = playing, 2 = paused. nt/na are omitted when
empty. Never raises — the daemon must keep polling even if WinRT hiccups
(and the winrt media package may simply be absent on some installs).
"""
try:
from winrt.windows.media.control import (
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PB,
)
except ImportError:
return {"np": 0}
try:
mgr = await MediaManager.request_async()
sess = mgr.get_current_session()
if sess is None:
return {"np": 0}
status = sess.get_playback_info().playback_status
if status == PB.PLAYING:
np = 1
elif status == PB.PAUSED:
np = 2
else:
np = 0 # stopped / closed / changing -> treat as nothing playing
out: dict = {"np": np}
if np:
info = await sess.try_get_media_properties_async()
title = (info.title or "").strip()
artist = (info.artist or "").strip()
if title:
out["nt"] = title[:NP_MAX_LEN]
if artist:
out["na"] = artist[:NP_MAX_LEN]
return out
except Exception as e: # WinRT can surface assorted OSError/RuntimeError types
log(f"Now-playing read failed: {e!r}")
return {"np": 0}
async def _wait_first(*events: asyncio.Event, timeout: float) -> None: async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
"""Return when any of `events` is set, or after `timeout` seconds. """Return when any of `events` is set, or after `timeout` seconds.
Lets the poll loop's TICK wait wake immediately on a stop signal (clean, Lets the poll loop's tick wait wake immediately on a stop signal (clean,
responsive Quit) without losing the refresh-request wakeup — instead of responsive Quit) without losing the refresh-request wakeup — instead of
waiting only on refresh_requested and re-checking stop_event up to TICK waiting only on refresh_requested and re-checking stop_event up to a tick
later. Cancels and drains the loser tasks so they don't warn. later. Cancels and drains the loser tasks so they don't warn.
""" """
tasks = [asyncio.ensure_future(e.wait()) for e in events] tasks = [asyncio.ensure_future(e.wait()) for e in events]
@@ -381,35 +664,107 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
session = Session(client) session = Session(client)
await session.setup_refresh_subscription() await session.setup_refresh_subscription()
last_poll = 0.0 # D-03: poll immediately on first connect # Two cadences share one connection: the Anthropic usage / rate-limit poll runs
# every POLL_INTERVAL (60s) while the Windows media session is read every
# NOWPLAYING_INTERVAL (3s). The last usage payload is cached and merged into
# each now-playing write, so the firmware always gets one complete JSON object
# (its parser defaults any missing field to 0 — a partial write would blank the
# usage screens) and a track change shows within a few seconds, not 60.
last_claude_poll = 0.0 # 0 => poll Anthropic immediately on first connect
cached: dict = {} # last usage + rate-limit fields, re-sent every tick
last_np_sent = None # last now-playing fields actually written (change gate)
used_successfully = False used_successfully = False
consecutive_failures = 0 # D-03: zombie-link break counter consecutive_failures = 0 # D-03: zombie-link break counter
try: try:
while client.is_connected and not stop_event.is_set(): while client.is_connected and not stop_event.is_set():
now = time.time() now = time.time()
elapsed = now - last_poll claude_due = (session.refresh_requested.is_set()
if session.refresh_requested.is_set() or elapsed >= POLL_INTERVAL: or (now - last_claude_poll) >= POLL_INTERVAL)
auth_problem = False
if claude_due:
session.refresh_requested.clear() session.refresh_requested.clear()
token = read_token() # D-09: fresh each cycle
# Local token usage (Session screen) needs no network — compute it
# every cycle so the watch keeps updating even with no/expired token.
fresh = compute_today_usage()
# Rate-limit utilization is best-effort. A genuine 401/403 flags the
# token; a transient failure (network/DNS/5xx) leaves the tray state
# alone (SC#5: a DNS blip must not read as "token expired").
rl = None
# Self-refresh the OAuth token before using it. In a managed
# (Agent SDK) environment `claude login` is unavailable, so the
# daemon renews its own access token from the refresh token —
# otherwise the rate-limit % goes dark forever once it expires.
try:
await refresh_token_if_needed()
except Exception as e: # belt-and-braces: never crash the loop
log(f"Token refresh skipped: {e!r}")
token = read_token() # D-09: fresh each cycle (post-refresh)
if not token: if not token:
log("No token; skipping poll") auth_problem = True
if tray_state: log("No token; sending local usage only")
tray_state.set_error("token expired — run claude login")
else: else:
try: try:
payload = await poll_api(token) rl = await poll_api(token)
except AuthError: except AuthError:
# Real 401/403 — token genuinely needs a refresh. # Rejected despite the proactive refresh (e.g. expiresAt
if tray_state: # was stale/missing so we skipped it). Force one refresh
tray_state.set_error("token expired — run claude login") # and retry the poll once before flagging the token bad.
payload = None try:
if payload is not None: forced = await refresh_token_if_needed(force=True)
except Exception as e:
log(f"Forced token refresh failed: {e!r}")
forced = False
if forced and (token := read_token()):
try:
rl = await poll_api(token)
except AuthError:
auth_problem = True
else:
auth_problem = True
if rl is not None:
fresh.update(rl)
fresh["ok"] = True # rate-limit data is fresh
else:
fresh["ok"] = False # rate-limit unknown -> watch usage view goes idle
cached = fresh
last_claude_poll = now
# Now Playing (Phase 5) — best-effort Windows media session, read every
# tick (fast cadence). Local only, so it works regardless of "ok" above.
try:
np = await read_now_playing()
except Exception as e: # never let media reading break the poll loop
log(f"Now-playing skipped: {e!r}")
np = {"np": 0}
# Write when the usage data was just refreshed (this is also the ~60s
# heartbeat) or when the track / playback state changed since the last
# write — skip otherwise so the link, and the field log, stay quiet
# between changes instead of repeating an identical payload every 3s.
if claude_due or np != last_np_sent:
payload = dict(cached)
payload.update(np)
if await session.write_payload(payload): if await session.write_payload(payload):
last_poll = time.time()
used_successfully = True used_successfully = True
consecutive_failures = 0 # D-03: reset on success consecutive_failures = 0 # D-03: reset on success
last_np_sent = np
# The tray reflects the Anthropic data freshness, so only touch
# it on a usage poll — never on a now-playing-only write.
if claude_due:
if cached.get("ok"):
if tray_state: if tray_state:
tray_state.set_connected(time.time()) tray_state.set_connected(time.time())
elif auth_problem:
if tray_state:
tray_state.set_error("token expired — run claude login")
# transient rate-limit failure: leave tray state unchanged
else: else:
consecutive_failures += 1 consecutive_failures += 1
if consecutive_failures >= ZOMBIE_BREAK_LIMIT: if consecutive_failures >= ZOMBIE_BREAK_LIMIT:
@@ -418,18 +773,15 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
f" write failures); abandoning connection" f" write failures); abandoning connection"
) )
break break
# else: payload is None from a TRANSIENT failure (network/DNS,
# timeout, rate-limit, 5xx). poll_api already logged it; do NOT
# toast "token expired" — that mislabeled a boot-time DNS blip
# as an auth problem (SC#5). Leave tray state unchanged; the next
# tick retries and set_connected() recovers it.
# Wake on a refresh request OR a stop, whichever comes first. Waking # Wake on a refresh request OR a stop, whichever comes first, but no
# promptly on stop_event is what lets the finally below run # later than NOWPLAYING_INTERVAL so the media session is re-read on time.
# client.disconnect() before the process exits, so the peer gets a # Waking promptly on stop_event is what lets the finally below run
# clean GATT disconnect (returns to its waiting screen) instead of # client.disconnect() before the process exits, so the peer gets a clean
# being left frozen on stale data after Quit (SC#3 graceful shutdown). # GATT disconnect (returns to its waiting screen) instead of being left
await _wait_first(session.refresh_requested, stop_event, timeout=TICK) # frozen on stale data after Quit (SC#3 graceful shutdown).
await _wait_first(session.refresh_requested, stop_event,
timeout=NOWPLAYING_INTERVAL)
finally: finally:
# Clean GATT disconnect on the way out — this is what tells the peripheral # Clean GATT disconnect on the way out — this is what tells the peripheral
# the link is gone. WinRT can surface a raw OSError (not BleakError) here, # the link is gone. WinRT can surface a raw OSError (not BleakError) here,
@@ -490,6 +842,7 @@ async def main(tray_state=None) -> None:
search_backoff = 1 # caps at 60s — gentle, for a device that is genuinely absent/off search_backoff = 1 # caps at 60s — gentle, for a device that is genuinely absent/off
reconnect_backoff = 1 # caps at RECONNECT_BACKOFF_CAP — fast, to clear the 120s SLA after a drop reconnect_backoff = 1 # caps at RECONNECT_BACKOFF_CAP — fast, to clear the 120s SLA after a drop
while not stop_event.is_set(): while not stop_event.is_set():
try:
device = await scan_for_device() device = await scan_for_device()
if not device: if not device:
# Slow-search regime: device was not found by scan — back off gently # Slow-search regime: device was not found by scan — back off gently
@@ -518,6 +871,22 @@ async def main(tray_state=None) -> None:
# Successful session — reset reconnect counter to floor; search_backoff also reset # Successful session — reset reconnect counter to floor; search_backoff also reset
reconnect_backoff = 1 reconnect_backoff = 1
search_backoff = 1 search_backoff = 1
except asyncio.CancelledError:
raise
except Exception as e:
# The whole BLE adapter can disappear (USB dongle unplugged) — bleak/
# WinRT then raises from the scan or the connect. Never let that kill
# the loop: log, show Scanning, back off, and keep retrying so
# replugging the adapter recovers on its own, no manual restart
# (field SC: pulled the dongle -> daemon crashed and stayed down).
if tray_state:
tray_state.set_scanning()
log(f"BLE error ({type(e).__name__}: {e}); retrying in {search_backoff}s...")
try:
await asyncio.wait_for(stop_event.wait(), timeout=search_backoff)
except asyncio.TimeoutError:
pass
search_backoff = _next_backoff(search_backoff, 60)
if __name__ == "__main__": if __name__ == "__main__":
+5
View File
@@ -4,3 +4,8 @@ bleak
httpx httpx
pystray pystray
Pillow Pillow
# Now Playing (Phase 5): read the Windows "now playing" media session (SMTC).
# bleak already pulls winrt-runtime + the Bluetooth projections; these add the
# media-control projection. pip resolves them to the same winrt-runtime as bleak.
winrt-Windows.Media
winrt-Windows.Media.Control
+4 -3
View File
@@ -73,7 +73,7 @@ def test_poll_api_nominal(monkeypatch):
assert payload["s"] == 42 assert payload["s"] == 42
assert payload["w"] == 10 assert payload["w"] == 10
assert payload["st"] == "allowed" assert payload["st"] == "allowed"
assert payload["ok"] is True # "ok" is added by the caller (connect_and_run), not by poll_api itself.
# reset_minutes allows ±1 minute tolerance # reset_minutes allows ±1 minute tolerance
assert abs(payload["sr"] - 60) <= 1, f"Expected ~60, got {payload['sr']}" assert abs(payload["sr"] - 60) <= 1, f"Expected ~60, got {payload['sr']}"
assert abs(payload["wr"] - 1440) <= 1, f"Expected ~1440, got {payload['wr']}" assert abs(payload["wr"] - 1440) <= 1, f"Expected ~1440, got {payload['wr']}"
@@ -416,9 +416,10 @@ def test_wire_bytes_compact_json_shape(monkeypatch):
assert ": " not in wire_str, f"Non-compact JSON detected: {wire_str!r}" assert ": " not in wire_str, f"Non-compact JSON detected: {wire_str!r}"
assert ", " not in wire_str, f"Non-compact JSON detected: {wire_str!r}" assert ", " not in wire_str, f"Non-compact JSON detected: {wire_str!r}"
# Must start with '{' and contain all required keys # Must start with '{' and contain every key poll_api emits. ("ok" is added
# later by connect_and_run, so it is intentionally not part of poll_api output.)
assert wire_str.startswith("{") assert wire_str.startswith("{")
for key in ("s", "sr", "w", "wr", "st", "ok"): for key in ("s", "sr", "w", "wr", "st"):
assert f'"{key}"' in wire_str, f"Missing key {key!r} in wire bytes: {wire_str!r}" assert f'"{key}"' in wire_str, f"Missing key {key!r} in wire bytes: {wire_str!r}"
+30 -6
View File
@@ -27,7 +27,13 @@ import time
# imports below and the brand-logo asset load work no matter what the current # imports below and the brand-logo asset load work no matter what the current
# working directory is — critical for logon autostart, where the HKCU\Run entry # working directory is — critical for logon autostart, where the HKCU\Run entry
# starts with cwd = System32, not the repo (APP-01 / SC#1). # starts with cwd = System32, not the repo (APP-01 / SC#1).
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if getattr(sys, "frozen", False):
# PyInstaller bundle: the `daemon` package loads from the frozen archive and
# data files (the brand logo.h) live under sys._MEIPASS. Point _REPO_ROOT at
# the bundle root so the logo asset path below resolves inside the exe.
_REPO_ROOT = sys._MEIPASS # type: ignore[attr-defined]
else:
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path: if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT) sys.path.insert(0, _REPO_ROOT)
@@ -190,19 +196,33 @@ def main() -> None:
ts = TrayState() ts = TrayState()
icon = pystray.Icon("Clawdmeter", images["scanning"], "Clawdmeter") icon = pystray.Icon("Clawdmeter", images["scanning"], "Clawdmeter")
# --- background thread: asyncio loop --- # quit_evt lets the supervisor below tell a clean shutdown (Quit) apart from
# an unexpected crash: restart the loop on a crash, but NOT after Quit.
quit_evt = threading.Event()
# --- background thread: supervised asyncio loop ---
def _run_daemon() -> None: def _run_daemon() -> None:
# daemon=True thread: an unhandled exception here would vanish silently # daemon=True thread. The inner loop (daemon_main) is now resilient to the
# and freeze the tray on its last state forever (the field "frozen tray" # BLE adapter vanishing, but as a last resort we also supervise the whole
# failure mode). Surface it instead — log the traceback to the rotating # asyncio.run: any unexpected crash is logged AND the loop is restarted
# file and flip the tray to an actionable error state. # after a backoff, instead of the thread dying and freezing the tray
# forever (field SC: pulling the BT dongle killed the daemon and it never
# came back). A clean return means stop_event was set (Quit) — stop then.
backoff = 1
while not quit_evt.is_set():
try: try:
_asyncio.run(daemon_main(tray_state=ts)) _asyncio.run(daemon_main(tray_state=ts))
break # clean return == Quit requested
except Exception as e: # last-resort thread guard except Exception as e: # last-resort thread guard
import traceback import traceback
daemon_log(f"Daemon thread crashed: {e!r}") daemon_log(f"Daemon thread crashed: {e!r}")
daemon_log(traceback.format_exc()) daemon_log(traceback.format_exc())
ts.set_error(f"daemon crashed: {type(e).__name__}") ts.set_error(f"daemon crashed: {type(e).__name__}")
if quit_evt.is_set():
break
daemon_log(f"Restarting daemon loop in {backoff}s")
time.sleep(backoff)
backoff = min(backoff * 2, 30)
daemon_thread = threading.Thread(target=_run_daemon, daemon=True) daemon_thread = threading.Thread(target=_run_daemon, daemon=True)
daemon_thread.start() daemon_thread.start()
@@ -219,8 +239,12 @@ def main() -> None:
# the device sits frozen on stale data instead of returning to its waiting # the device sits frozen on stale data instead of returning to its waiting
# screen (SC#3 field report). The timeout caps the block so Quit can never # screen (SC#3 field report). The timeout caps the block so Quit can never
# hang if a WinRT disconnect wedges (rare) — we exit anyway as a fallback. # hang if a WinRT disconnect wedges (rare) — we exit anyway as a fallback.
quit_evt.set() # tell the supervisor this is a clean stop, not a crash
if ts.loop is not None and ts.stop_event is not None: if ts.loop is not None and ts.stop_event is not None:
try:
ts.loop.call_soon_threadsafe(ts.stop_event.set) ts.loop.call_soon_threadsafe(ts.stop_event.set)
except RuntimeError:
pass # loop already closed (e.g. mid-restart) — quit_evt handles it
daemon_thread.join(timeout=6.0) daemon_thread.join(timeout=6.0)
icon_ref.stop() icon_ref.stop()
+55
View File
@@ -0,0 +1,55 @@
# Clawdmeter v2 — roadmap
v2 turns Clawdmeter from a single-purpose usage indicator into a small
multi-app wrist companion for the Waveshare AMOLED-2.06 watch.
- **Classic line** — the stable 2.06 port + working Windows BLE daemon. Frozen on
`main`, tagged `v1.0-classic`. Bugfix-only.
- **v2 development** — this branch (`v2-dev`). When it's ready it merges to `main`
and is tagged `v2.0.0`.
## Architecture decisions (locked)
- **Navigation:** tap the Claude logo (top-left) → app launcher. Each feature is an
"app" screen registered in a registry (`{id, icon, label, show/hide}`), mirroring
the board-HAL pattern: a new app = one registry entry + one screen module, no
edits to shared navigation code.
- **Home Assistant:** routed **through the desktop daemon** (watch → BLE → daemon →
HA REST API). The watch stays dumb; the HA URL/token live on the PC. No WiFi
provisioning or secrets on the watch.
- **Desktop companion:** a **local web UI** (FastAPI, opened from the tray) for
config, firmware flashing, and per-app setup (soundpad labels, HA entity map,
Now Playing).
- **Soundpad:** on-screen buttons emit **F13F18** over the existing BLE HID
keyboard. Requires raising the HID report map's keycode ceiling (currently 101 /
0x65; F13F24 are 0x680x73).
## Phases
| # | Phase | Depends on | Daemon work |
|---|-------|-----------|-------------|
| 0 | Version split — freeze classic, branch `v2-dev`, version constant | — | no |
| 1 | Touch foundation — verify FT3168 coordinate accuracy, calibrate swap/mirror, logo hit-area | — | no |
| 2 | Menu / launcher — app registry, launcher screen, nav stack, back affordance | 1 | no |
| 3 | Soundpad — raise HID ceiling to F24, 6-button screen → F13F18 | 2 | no |
| 4 | Session / tokens — daemon computes today's tokens/$; extend payload + parser + screen | 2 | data |
| 5 | Now Playing — daemon reads Windows media session; new screen | 2 | media |
| 6 | Home Assistant — watch→daemon command characteristic, HA bridge, control screen | 2 | HA bridge |
| 7 | Desktop companion — local web UI: config + one-click firmware flash (esptool, bins from Gitea Releases) | — | big upgrade |
| 8 | Release v2.0 — docs, icons, Gitea release with firmware binaries | all | — |
Each UI phase is verified on hardware via the `screenshot` serial command before
moving on.
## BLE data channel (current + planned)
Custom GATT service `4c41555a-…0001`:
| Char | Dir | Now | v2 plan |
|------|-----|-----|---------|
| `…0002` RX | PC→watch | JSON usage payload | + session/tokens, now-playing fields |
| `…0003` TX | watch→PC | ack/nack | (unchanged) |
| `…0004` REQ | watch→PC | refresh request (0x01) | (unchanged) |
| `…0005` CMD | watch→PC | — | **new:** app commands (HA toggle, etc.) |
Plus the standard BLE HID keyboard (0x1812) for Space / Shift+Tab / soundpad keys.
+5
View File
@@ -157,6 +157,11 @@ build_flags =
-DLV_USE_ANIMIMG=0 -DLV_USE_ANIMIMG=0
-DLV_TICK_CUSTOM=1 -DLV_TICK_CUSTOM=1
-DLV_USE_SNAPSHOT=1 -DLV_USE_SNAPSHOT=1
; Montserrat carries the LVGL symbol glyphs (FontAwesome subset) used for the
; v2 launcher tile icons and the back chevron. The brand Styrene/Tiempos fonts
; are Latin-only and have no symbols.
-DLV_FONT_MONTSERRAT_20=1
-DLV_FONT_MONTSERRAT_28=1
lib_deps = lib_deps =
; 1.6.4+ ships Arduino_CO5300 in mainline ; 1.6.4+ ships Arduino_CO5300 in mainline
+2 -2
View File
@@ -46,10 +46,10 @@ static const uint8_t HID_REPORT_MAP[] = {
0x95, 0x06, // Report Count (6) 0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8) 0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0) 0x15, 0x00, // Logical Minimum (0)
0x25, 0x65, // Logical Maximum (101) 0x25, 0x73, // Logical Maximum (115) - raised from 101 to cover F13..F24 (0x68..0x73)
0x05, 0x07, // Usage Page (Key Codes) 0x05, 0x07, // Usage Page (Key Codes)
0x19, 0x00, // Usage Minimum (0) 0x19, 0x00, // Usage Minimum (0)
0x29, 0x65, // Usage Maximum (101) 0x29, 0x73, // Usage Maximum (115) - the v2 soundpad sends F13..F18 (0x68..0x6D)
0x81, 0x00, // Input (Data, Array) - Key array (6 keys) 0x81, 0x00, // Input (Data, Array) - Key array (6 keys)
0xC0, // End Collection 0xC0, // End Collection
}; };
@@ -10,9 +10,11 @@
// reg 0x03 / 0x04: X1 high (low nibble) + X1 low // reg 0x03 / 0x04: X1 high (low nibble) + X1 low
// reg 0x05 / 0x06: Y1 high (low nibble) + Y1 low // reg 0x05 / 0x06: Y1 high (low nibble) + Y1 low
// //
// Axis orientation: started with no swap/mirror (matches the portrait FT3168 // Axis orientation: verified on hardware (v2 Phase 1). The FT3168 reports
// on the 1.8 board). If touch coordinates come out flipped/swapped on this // directly in the panel's pixel space (~0..409 x ~0..501), correctly oriented
// panel, mirror/swap them in touch_read_into_shared_state() below. // (x grows left->right, y grows top->bottom, no axis swap). A center tap reads
// ~(209,228) vs the 410x502 panel center (205,251) — a 1:1 mapping within
// finger error. Raw values pass straight through; no mirror/swap/scale needed.
static volatile bool touch_data_ready = false; static volatile bool touch_data_ready = false;
static volatile bool touch_pressed = false; static volatile bool touch_pressed = false;
+12
View File
@@ -9,4 +9,16 @@ struct UsageData {
char status[16]; // "allowed" or "limited" char status[16]; // "allowed" or "limited"
bool ok; // data parse succeeded bool ok; // data parse succeeded
bool valid; // false until first successful parse bool valid; // false until first successful parse
// Today's Claude Code usage (Phase 4 — computed by the daemon from local
// transcripts). Tokens can exceed 2^31 on a heavy day, so use 64-bit.
long long tokens_today; // total tokens today (input+output+cache)
long long output_today; // output tokens today (what Claude generated)
int cost_cents_today; // equivalent API cost today, US cents
int messages_today; // assistant requests (API turns) today
// Now Playing (Phase 5 — daemon relays the Windows media session).
int np_state; // 0 = nothing playing, 1 = playing, 2 = paused
char np_title[64]; // current track title (UTF-8, may be empty)
char np_artist[64]; // current track artist (UTF-8, may be empty)
}; };
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -7,6 +7,7 @@
#include "data.h" #include "data.h"
#include "ui.h" #include "ui.h"
#include "ble.h" #include "ble.h"
#include "version.h"
#include "splash.h" #include "splash.h"
#include "usage_rate.h" #include "usage_rate.h"
#include "idle.h" #include "idle.h"
@@ -111,6 +112,13 @@ static bool parse_json(const char* json, UsageData* out) {
out->weekly_reset_mins = doc["wr"] | -1; out->weekly_reset_mins = doc["wr"] | -1;
strlcpy(out->status, doc["st"] | "unknown", sizeof(out->status)); strlcpy(out->status, doc["st"] | "unknown", sizeof(out->status));
out->ok = doc["ok"] | false; out->ok = doc["ok"] | false;
out->tokens_today = doc["tk"] | (long long)0;
out->output_today = doc["to"] | (long long)0;
out->cost_cents_today = doc["tc"] | 0;
out->messages_today = doc["tn"] | 0;
out->np_state = doc["np"] | 0;
strlcpy(out->np_title, doc["nt"] | "", sizeof(out->np_title));
strlcpy(out->np_artist, doc["na"] | "", sizeof(out->np_artist));
out->valid = true; out->valid = true;
return true; return true;
} }
@@ -180,7 +188,7 @@ extern "C" void board_init(void);
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
delay(300); delay(300);
Serial.println("{\"ready\":true}"); Serial.printf("{\"ready\":true,\"fw\":\"%s\"}\n", CLAWDMETER_VERSION);
board_init(); board_init();
+518 -12
View File
@@ -1,6 +1,7 @@
#include "ui.h" #include "ui.h"
#include "splash.h" #include "splash.h"
#include <lvgl.h> #include <lvgl.h>
#include <string.h>
#include "logo.h" #include "logo.h"
#include "icons.h" #include "icons.h"
#include "hal/board_caps.h" #include "hal/board_caps.h"
@@ -14,6 +15,10 @@ LV_FONT_DECLARE(font_styrene_24);
LV_FONT_DECLARE(font_styrene_20); LV_FONT_DECLARE(font_styrene_20);
LV_FONT_DECLARE(font_styrene_16); LV_FONT_DECLARE(font_styrene_16);
LV_FONT_DECLARE(font_styrene_14); LV_FONT_DECLARE(font_styrene_14);
// Composite Styrene (Latin) + Montserrat (Cyrillic) — used where user content
// (media titles) can be non-Latin; brand Styrene has no Cyrillic glyphs.
LV_FONT_DECLARE(font_styrene_cyr_28);
LV_FONT_DECLARE(font_styrene_cyr_20);
LV_FONT_DECLARE(font_mono_32); LV_FONT_DECLARE(font_mono_32);
// Layout values computed from the active board's geometry. Populated once // Layout values computed from the active board's geometry. Populated once
@@ -119,6 +124,46 @@ static lv_obj_t* battery_img;
static lv_obj_t* logo_img; static lv_obj_t* logo_img;
static lv_image_dsc_t battery_dscs[5]; // empty, low, medium, full, charging static lv_image_dsc_t battery_dscs[5]; // empty, low, medium, full, charging
// ---- v2 navigation: launcher, stub & bluetooth screens ----
static lv_obj_t* nav_back_btn; // top-left back chevron (shown off the home screen)
static lv_obj_t* menu_container; // app launcher (scrollable tile grid)
static lv_obj_t* stub_container; // generic "coming soon" screen, retitled per app
static lv_obj_t* stub_title;
static lv_obj_t* stub_icon;
static lv_obj_t* bt_container; // BLE connection info
static lv_obj_t* bt_status_lbl;
static lv_obj_t* bt_name_lbl;
static lv_obj_t* bt_mac_lbl;
static lv_obj_t* session_container; // today's tokens / cost
static lv_obj_t* sess_cost_lbl;
static lv_obj_t* sess_tokens_lbl;
static lv_obj_t* sess_gen_lbl;
static lv_obj_t* sess_msgs_lbl;
static lv_obj_t* nowplaying_container; // media now-playing (Phase 5)
static lv_obj_t* np_icon_lbl; // play / pause / music glyph
static lv_obj_t* np_title_lbl; // track title (scrolls if long)
static lv_obj_t* np_artist_lbl; // track artist
static lv_obj_t* np_status_lbl; // "Playing" / "Paused"
// App registry — the launcher renders one tile per entry, so adding a screen is
// one line here plus its builder. Order = tile order. "Animations" reuses the
// splash; the four future apps open the shared stub until their phase lands.
struct AppEntry {
screen_t screen;
const char* label;
const char* symbol; // LVGL/Montserrat symbol glyph
};
static const AppEntry APPS[] = {
{ SCREEN_USAGE, "Usage", LV_SYMBOL_CHARGE },
{ SCREEN_SPLASH, "Animations", LV_SYMBOL_IMAGE },
{ SCREEN_SOUNDPAD, "Soundpad", LV_SYMBOL_AUDIO },
{ SCREEN_SESSION, "Session", LV_SYMBOL_LIST },
{ SCREEN_NOWPLAYING, "Now Playing", LV_SYMBOL_PLAY },
{ SCREEN_HOMEASSIST, "Home", LV_SYMBOL_HOME },
{ SCREEN_BLUETOOTH, "Bluetooth", LV_SYMBOL_BLUETOOTH },
};
#define APP_COUNT (sizeof(APPS) / sizeof(APPS[0]))
// ---- Live-data freshness → which usage sub-view to show ---- // ---- Live-data freshness → which usage sub-view to show ----
// usage panels when data is flowing, an idle "Zzz" screen when the host is // usage panels when data is flowing, an idle "Zzz" screen when the host is
// connected but no usage update landed within DATA_FRESH_MS, the pairing hint // connected but no usage update landed within DATA_FRESH_MS, the pairing hint
@@ -207,8 +252,17 @@ static void format_reset_time(int mins, char* buf, size_t len) {
} }
} }
// Format a token count compactly: 94972115 -> "95.0M", 960621 -> "960.6K".
static void format_tokens(long long n, char* buf, size_t len) {
if (n >= 1000000) snprintf(buf, len, "%.1fM", (double)n / 1000000.0);
else if (n >= 1000) snprintf(buf, len, "%.1fK", (double)n / 1000.0);
else snprintf(buf, len, "%lld", n);
}
// Forward decls — callbacks defined near ui_show_screen below // Forward decls — callbacks defined near ui_show_screen below
static void global_click_cb(lv_event_t* e); static void global_click_cb(lv_event_t* e);
static void logo_click_cb(lv_event_t* e);
static void nav_back_cb(lv_event_t* e);
static lv_obj_t* make_panel(lv_obj_t* parent, int x, int y, int w, int h) { static lv_obj_t* make_panel(lv_obj_t* parent, int x, int y, int w, int h) {
lv_obj_t* panel = lv_obj_create(parent); lv_obj_t* panel = lv_obj_create(parent);
@@ -362,7 +416,6 @@ static void init_usage_screen(lv_obj_t* scr) {
lv_obj_set_style_border_width(usage_container, 0, 0); lv_obj_set_style_border_width(usage_container, 0, 0);
lv_obj_set_style_pad_all(usage_container, 0, 0); lv_obj_set_style_pad_all(usage_container, 0, 0);
lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_event_cb(usage_container, global_click_cb, LV_EVENT_CLICKED, NULL);
lbl_title = lv_label_create(usage_container); lbl_title = lv_label_create(usage_container);
lv_label_set_text(lbl_title, "Usage"); lv_label_set_text(lbl_title, "Usage");
@@ -400,6 +453,344 @@ static void init_usage_screen(lv_obj_t* scr) {
lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15); lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15);
} }
// ======== v2: launcher, stub & bluetooth screens ========
static const AppEntry* app_for_screen(screen_t s) {
for (size_t i = 0; i < APP_COUNT; i++)
if (APPS[i].screen == s) return &APPS[i];
return nullptr;
}
// Centered screen title for the navigation screens. The back chevron lives in
// the shared top-left chrome (nav_back_btn), so only the title is drawn here.
static lv_obj_t* make_screen_title(lv_obj_t* parent, const char* text) {
lv_obj_t* t = lv_label_create(parent);
lv_label_set_text(t, text);
lv_obj_set_style_text_font(t, &font_styrene_28, 0);
lv_obj_set_style_text_color(t, COL_TEXT, 0);
lv_obj_align(t, LV_ALIGN_TOP_MID, 16, L.title_y);
return t;
}
static void tile_click_cb(lv_event_t* e) {
screen_t s = (screen_t)(intptr_t)lv_event_get_user_data(e);
ui_show_screen(s);
}
static lv_obj_t* make_tile(lv_obj_t* parent, const AppEntry* app, int w, int h) {
lv_obj_t* tile = lv_obj_create(parent);
lv_obj_set_size(tile, w, h);
lv_obj_set_style_bg_color(tile, COL_PANEL, 0);
lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0);
lv_obj_set_style_radius(tile, 16, 0);
lv_obj_set_style_border_width(tile, 0, 0);
lv_obj_set_style_pad_all(tile, 6, 0);
lv_obj_set_flex_flow(tile, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(tile, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(tile, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(tile, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(tile, tile_click_cb, LV_EVENT_CLICKED, (void*)(intptr_t)app->screen);
lv_obj_t* icon = lv_label_create(tile);
lv_label_set_text(icon, app->symbol);
lv_obj_set_style_text_font(icon, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(icon, COL_ACCENT, 0);
lv_obj_t* lbl = lv_label_create(tile);
lv_label_set_text(lbl, app->label);
lv_obj_set_style_text_font(lbl, &font_styrene_16, 0);
lv_obj_set_style_text_color(lbl, COL_TEXT, 0);
lv_obj_set_style_pad_top(lbl, 6, 0);
return tile;
}
static void init_menu_screen(lv_obj_t* scr) {
menu_container = lv_obj_create(scr);
lv_obj_set_size(menu_container, L.scr_w, L.scr_h);
lv_obj_set_pos(menu_container, 0, 0);
lv_obj_set_style_bg_opa(menu_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(menu_container, 0, 0);
lv_obj_set_style_pad_all(menu_container, 0, 0);
lv_obj_clear_flag(menu_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(menu_container, "Menu");
// Scrollable 2-column tile grid — wraps and scrolls vertically as the app
// registry grows, so new screens need no layout changes here.
lv_obj_t* grid = lv_obj_create(menu_container);
lv_obj_set_size(grid, L.content_w, L.scr_h - L.content_y - L.margin);
lv_obj_set_pos(grid, L.margin, L.content_y);
lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(grid, 0, 0);
lv_obj_set_style_pad_all(grid, 0, 0);
lv_obj_set_style_pad_row(grid, 12, 0);
lv_obj_set_style_pad_column(grid, 12, 0);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
// track_cross_place = START: stack rows from the top so the first row sits
// just under the title and the rest is reached by scrolling DOWN. (CENTER
// would vertically center the overflowing block, hiding the top row under
// the title and springing back to it after an elastic pull.)
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
lv_obj_set_scroll_dir(grid, LV_DIR_VER);
lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_AUTO);
int tile_w = (L.content_w - 16) / 2; // 4px slack so two fit per row beside a scrollbar
int tile_h = 116;
for (size_t i = 0; i < APP_COUNT; i++)
make_tile(grid, &APPS[i], tile_w, tile_h);
lv_obj_add_flag(menu_container, LV_OBJ_FLAG_HIDDEN);
}
// One generic "coming soon" screen, retitled per app in stub_show_for(). The
// four future apps (Soundpad/Session/Now Playing/Home) point here until built.
static void init_stub_screen(lv_obj_t* scr) {
stub_container = lv_obj_create(scr);
lv_obj_set_size(stub_container, L.scr_w, L.scr_h);
lv_obj_set_pos(stub_container, 0, 0);
lv_obj_set_style_bg_opa(stub_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(stub_container, 0, 0);
lv_obj_set_style_pad_all(stub_container, 0, 0);
lv_obj_clear_flag(stub_container, LV_OBJ_FLAG_SCROLLABLE);
stub_title = make_screen_title(stub_container, "");
stub_icon = lv_label_create(stub_container);
lv_label_set_text(stub_icon, LV_SYMBOL_SETTINGS);
lv_obj_set_style_text_font(stub_icon, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(stub_icon, COL_DIM, 0);
lv_obj_align(stub_icon, LV_ALIGN_CENTER, 0, -20);
lv_obj_t* soon = lv_label_create(stub_container);
lv_label_set_text(soon, "coming soon");
lv_obj_set_style_text_font(soon, &font_styrene_20, 0);
lv_obj_set_style_text_color(soon, COL_DIM, 0);
lv_obj_align(soon, LV_ALIGN_CENTER, 0, 30);
lv_obj_add_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
}
static void init_bt_screen(lv_obj_t* scr) {
bt_container = lv_obj_create(scr);
lv_obj_set_size(bt_container, L.scr_w, L.scr_h);
lv_obj_set_pos(bt_container, 0, 0);
lv_obj_set_style_bg_opa(bt_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(bt_container, 0, 0);
lv_obj_set_style_pad_all(bt_container, 0, 0);
lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(bt_container, "Bluetooth");
bt_status_lbl = lv_label_create(bt_container);
lv_label_set_text(bt_status_lbl, "");
lv_obj_set_style_text_font(bt_status_lbl, &font_styrene_28, 0);
lv_obj_align(bt_status_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 20);
bt_name_lbl = lv_label_create(bt_container);
lv_label_set_text(bt_name_lbl, "");
lv_obj_set_style_text_font(bt_name_lbl, &font_styrene_20, 0);
lv_obj_set_style_text_color(bt_name_lbl, COL_DIM, 0);
lv_obj_align(bt_name_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 80);
bt_mac_lbl = lv_label_create(bt_container);
lv_label_set_text(bt_mac_lbl, "");
lv_obj_set_style_text_font(bt_mac_lbl, &font_styrene_20, 0);
lv_obj_set_style_text_color(bt_mac_lbl, COL_DIM, 0);
lv_obj_align(bt_mac_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 120);
lv_obj_add_flag(bt_container, LV_OBJ_FLAG_HIDDEN);
}
static void bt_refresh(void) {
if (!bt_container) return;
lv_label_set_text(bt_status_lbl, s_ble_connected ? "Connected" : "Waiting");
lv_obj_set_style_text_color(bt_status_lbl, s_ble_connected ? COL_GREEN : COL_AMBER, 0);
lv_label_set_text_fmt(bt_name_lbl, "%s", ble_get_device_name());
lv_label_set_text_fmt(bt_mac_lbl, "%s", ble_get_mac_address());
}
static void stub_show_for(screen_t s) {
const AppEntry* app = app_for_screen(s);
if (app) {
lv_label_set_text(stub_title, app->label);
lv_label_set_text(stub_icon, app->symbol);
}
}
// ---- Soundpad: 6 pads → HID F13..F18 (the host maps them to a soundboard) ----
// F13..F24 are HID usage IDs 0x68..0x73; the report-map keycode ceiling was
// raised to 0x73 in ble.cpp so these transmit.
struct PadDef { uint8_t keycode; const char* label; uint32_t color; };
static const PadDef PADS[6] = {
{ 0x68, "F13", 0xD85A30 }, // coral
{ 0x69, "F14", 0xBA7517 }, // amber
{ 0x6A, "F15", 0x639922 }, // green
{ 0x6B, "F16", 0x1D9E75 }, // teal
{ 0x6C, "F17", 0x534AB7 }, // purple
{ 0x6D, "F18", 0xD4537E }, // pink
};
static lv_obj_t* soundpad_container;
static void release_timer_cb(lv_timer_t* t) {
(void)t;
ble_keyboard_release();
}
static void pad_click_cb(lv_event_t* e) {
uint8_t key = (uint8_t)(intptr_t)lv_event_get_user_data(e);
ble_keyboard_press(key, 0);
// Release shortly after so the host sees a discrete keypress, without
// blocking the UI thread. The one-shot timer auto-deletes once it fires.
lv_timer_t* t = lv_timer_create(release_timer_cb, 40, NULL);
lv_timer_set_repeat_count(t, 1);
}
static lv_obj_t* make_pad(lv_obj_t* parent, const PadDef* pad, int w, int h) {
lv_obj_t* btn = lv_obj_create(parent);
lv_obj_set_size(btn, w, h);
lv_obj_set_style_bg_color(btn, lv_color_hex(pad->color), 0);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
lv_obj_set_style_radius(btn, 16, 0);
lv_obj_set_style_border_width(btn, 0, 0);
lv_obj_set_style_border_width(btn, 3, LV_STATE_PRESSED); // white ring lights on press
lv_obj_set_style_border_color(btn, lv_color_white(), LV_STATE_PRESSED);
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(btn, pad_click_cb, LV_EVENT_CLICKED, (void*)(intptr_t)pad->keycode);
lv_obj_t* lbl = lv_label_create(btn);
lv_label_set_text(lbl, pad->label);
lv_obj_set_style_text_font(lbl, &font_styrene_28, 0);
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
lv_obj_center(lbl);
return btn;
}
static void init_soundpad_screen(lv_obj_t* scr) {
soundpad_container = lv_obj_create(scr);
lv_obj_set_size(soundpad_container, L.scr_w, L.scr_h);
lv_obj_set_pos(soundpad_container, 0, 0);
lv_obj_set_style_bg_opa(soundpad_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(soundpad_container, 0, 0);
lv_obj_set_style_pad_all(soundpad_container, 0, 0);
lv_obj_clear_flag(soundpad_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(soundpad_container, "Soundpad");
lv_obj_t* grid = lv_obj_create(soundpad_container);
int avail_h = L.scr_h - L.content_y - L.margin;
lv_obj_set_size(grid, L.content_w, avail_h);
lv_obj_set_pos(grid, L.margin, L.content_y);
lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(grid, 0, 0);
lv_obj_set_style_pad_all(grid, 0, 0);
lv_obj_set_style_pad_row(grid, 12, 0);
lv_obj_set_style_pad_column(grid, 12, 0);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
int pad_w = (L.content_w - 16) / 2; // 2 columns, 4px slack
int pad_h = (avail_h - 2 * 12) / 3 - 1; // 3 rows
for (int i = 0; i < 6; i++)
make_pad(grid, &PADS[i], pad_w, pad_h);
lv_obj_add_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN);
}
// Session: today's Claude Code usage — equivalent API cost (hero), total tokens,
// and message count. Labels are refreshed from ui_update() as data arrives.
static void init_session_screen(lv_obj_t* scr) {
session_container = lv_obj_create(scr);
lv_obj_set_size(session_container, L.scr_w, L.scr_h);
lv_obj_set_pos(session_container, 0, 0);
lv_obj_set_style_bg_opa(session_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(session_container, 0, 0);
lv_obj_set_style_pad_all(session_container, 0, 0);
lv_obj_clear_flag(session_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(session_container, "Session");
sess_cost_lbl = lv_label_create(session_container);
lv_label_set_text(sess_cost_lbl, "$0.00");
lv_obj_set_style_text_font(sess_cost_lbl, &font_tiempos_56, 0);
lv_obj_set_style_text_color(sess_cost_lbl, COL_TEXT, 0);
lv_obj_align(sess_cost_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 25);
lv_obj_t* sub = lv_label_create(session_container);
lv_label_set_text(sub, "spent today");
lv_obj_set_style_text_font(sub, &font_styrene_20, 0);
lv_obj_set_style_text_color(sub, COL_DIM, 0);
lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, L.content_y + 105);
sess_tokens_lbl = lv_label_create(session_container);
lv_label_set_text(sess_tokens_lbl, "\xE2\x80\x94 tokens");
lv_obj_set_style_text_font(sess_tokens_lbl, &font_styrene_28, 0);
lv_obj_set_style_text_color(sess_tokens_lbl, COL_ACCENT, 0);
lv_obj_align(sess_tokens_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 152);
sess_gen_lbl = lv_label_create(session_container);
lv_label_set_text(sess_gen_lbl, "\xE2\x80\x94 generated");
lv_obj_set_style_text_font(sess_gen_lbl, &font_styrene_16, 0);
lv_obj_set_style_text_color(sess_gen_lbl, COL_DIM, 0);
lv_obj_align(sess_gen_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 192);
sess_msgs_lbl = lv_label_create(session_container);
lv_label_set_text(sess_msgs_lbl, "\xE2\x80\x94 requests");
lv_obj_set_style_text_font(sess_msgs_lbl, &font_styrene_20, 0);
lv_obj_set_style_text_color(sess_msgs_lbl, COL_DIM, 0);
lv_obj_align(sess_msgs_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 232);
lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
}
// Now Playing: the Windows media session relayed by the daemon. A play/pause glyph
// and status line reflect playback; the title scrolls if it overflows. Independent
// of rate-limit data, so it stays live even when the OAuth token is unavailable.
static void init_nowplaying_screen(lv_obj_t* scr) {
nowplaying_container = lv_obj_create(scr);
lv_obj_set_size(nowplaying_container, L.scr_w, L.scr_h);
lv_obj_set_pos(nowplaying_container, 0, 0);
lv_obj_set_style_bg_opa(nowplaying_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(nowplaying_container, 0, 0);
lv_obj_set_style_pad_all(nowplaying_container, 0, 0);
lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(nowplaying_container, "Now Playing");
np_icon_lbl = lv_label_create(nowplaying_container);
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
lv_obj_set_style_text_font(np_icon_lbl, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
lv_obj_align(np_icon_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 35);
np_title_lbl = lv_label_create(nowplaying_container);
lv_obj_set_width(np_title_lbl, L.content_w);
lv_label_set_long_mode(np_title_lbl, LV_LABEL_LONG_SCROLL_CIRCULAR);
lv_obj_set_style_text_align(np_title_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_font(np_title_lbl, &font_styrene_cyr_28, 0);
lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0);
lv_label_set_text(np_title_lbl, "Nothing playing");
lv_obj_align(np_title_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 120);
np_artist_lbl = lv_label_create(nowplaying_container);
lv_obj_set_width(np_artist_lbl, L.content_w);
lv_label_set_long_mode(np_artist_lbl, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_align(np_artist_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_font(np_artist_lbl, &font_styrene_cyr_20, 0);
lv_obj_set_style_text_color(np_artist_lbl, COL_DIM, 0);
lv_label_set_text(np_artist_lbl, "");
lv_obj_align(np_artist_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 170);
np_status_lbl = lv_label_create(nowplaying_container);
lv_obj_set_style_text_font(np_status_lbl, &font_styrene_16, 0);
lv_obj_set_style_text_color(np_status_lbl, COL_DIM, 0);
lv_label_set_text(np_status_lbl, "");
lv_obj_align(np_status_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 215);
lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
}
// ======== Public API ======== // ======== Public API ========
void ui_init(void) { void ui_init(void) {
@@ -419,28 +810,105 @@ void ui_init(void) {
lv_obj_add_event_cb(splash_get_root(), global_click_cb, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(splash_get_root(), global_click_cb, LV_EVENT_CLICKED, NULL);
} }
// v2 screens — created hidden, shown on navigation.
init_menu_screen(scr);
init_stub_screen(scr);
init_bt_screen(scr);
init_soundpad_screen(scr);
init_session_screen(scr);
init_nowplaying_screen(scr);
logo_img = lv_image_create(scr); logo_img = lv_image_create(scr);
lv_image_set_src(logo_img, &logo_dsc); lv_image_set_src(logo_img, &logo_dsc);
lv_obj_set_pos(logo_img, L.margin, L.title_y - 10); lv_obj_set_pos(logo_img, L.margin, L.title_y - 10);
lv_obj_add_flag(logo_img, LV_OBJ_FLAG_CLICKABLE); // tap the Claude logo → launcher
lv_obj_set_ext_click_area(logo_img, 14);
lv_obj_add_event_cb(logo_img, logo_click_cb, LV_EVENT_CLICKED, NULL);
// Back chevron — shared across the menu/app screens, hidden on home/splash.
nav_back_btn = lv_label_create(scr);
lv_label_set_text(nav_back_btn, LV_SYMBOL_LEFT);
lv_obj_set_style_text_font(nav_back_btn, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(nav_back_btn, COL_TEXT, 0);
lv_obj_set_pos(nav_back_btn, L.margin + 4, L.title_y + 10); // clear of the rounded top-left corner
lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_ext_click_area(nav_back_btn, 18);
lv_obj_add_event_cb(nav_back_btn, nav_back_cb, LV_EVENT_CLICKED, NULL);
lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
battery_img = lv_image_create(scr); battery_img = lv_image_create(scr);
lv_image_set_src(battery_img, &battery_dscs[0]); lv_image_set_src(battery_img, &battery_dscs[0]);
lv_obj_set_pos(battery_img, L.scr_w - 48 - L.margin, L.title_y); lv_obj_set_pos(battery_img, L.scr_w - 48 - L.margin, L.title_y);
} }
void ui_update(const UsageData* data) { void ui_update(const UsageData* data) {
if (!data->valid) return; if (!data->valid) return;
last_data_ms = lv_tick_get(); // a valid usage update just landed → dot goes green
char buf[48];
// Session screen — local token data, computed by the daemon from transcripts
// with no API/token needed. Always refresh it so it stays live even when the
// rate-limit data below is unavailable (data->ok == false).
if (sess_cost_lbl) {
lv_label_set_text_fmt(sess_cost_lbl, "$%d.%02d",
data->cost_cents_today / 100, data->cost_cents_today % 100);
format_tokens(data->tokens_today, buf, sizeof(buf));
lv_label_set_text_fmt(sess_tokens_lbl, "%s tokens", buf);
format_tokens(data->output_today, buf, sizeof(buf));
lv_label_set_text_fmt(sess_gen_lbl, "%s generated", buf);
lv_label_set_text_fmt(sess_msgs_lbl, "%d requests", data->messages_today);
}
// Now Playing — local media session relayed by the daemon. Like Session, it's
// independent of the rate-limit data, so refresh it before the ok==false bail.
// Only touch the labels when the track / playback state actually changed: the
// daemon pushes a payload every few seconds (fast "music card" cadence), and
// re-running lv_label_set_text() on an unchanged title restarts the circular
// scroll animation each time, so a long title would never get to scroll.
if (nowplaying_container) {
static int np_state_shown = -1;
static char np_title_shown[64] = {0};
static char np_artist_shown[64] = {0};
if (data->np_state != np_state_shown
|| strcmp(data->np_title, np_title_shown) != 0
|| strcmp(data->np_artist, np_artist_shown) != 0) {
np_state_shown = data->np_state;
strlcpy(np_title_shown, data->np_title, sizeof(np_title_shown));
strlcpy(np_artist_shown, data->np_artist, sizeof(np_artist_shown));
if (data->np_state == 0) {
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
lv_label_set_text(np_title_lbl, "Nothing playing");
lv_obj_set_style_text_color(np_title_lbl, COL_DIM, 0);
lv_label_set_text(np_artist_lbl, "");
lv_label_set_text(np_status_lbl, "");
} else {
bool playing = (data->np_state == 1);
lv_label_set_text(np_icon_lbl, playing ? LV_SYMBOL_PLAY : LV_SYMBOL_PAUSE);
lv_obj_set_style_text_color(np_icon_lbl, COL_ACCENT, 0);
lv_label_set_text(np_title_lbl, data->np_title[0] ? data->np_title : "(no title)");
lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0);
lv_label_set_text(np_artist_lbl, data->np_artist);
lv_label_set_text(np_status_lbl, playing ? "Playing" : "Paused");
}
}
}
// Rate-limit utilization (5h / 7d). The daemon sets ok=false when this data
// is unavailable (expired OAuth token, API down). Skip the bars and DON'T
// bump the freshness clock then, so the usage view falls back to its idle
// "Zzz" state instead of showing stale or zeroed percentages.
if (!data->ok) return;
last_data_ms = lv_tick_get(); // fresh limit data just landed → dot goes green
data_received = true; data_received = true;
int s_pct = (int)(data->session_pct + 0.5f); int s_pct = (int)(data->session_pct + 0.5f);
lv_label_set_text_fmt(lbl_session_pct, "%d%%", s_pct); lv_label_set_text_fmt(lbl_session_pct, "%d%%", s_pct);
lv_bar_set_value(bar_session, s_pct, LV_ANIM_ON); lv_bar_set_value(bar_session, s_pct, LV_ANIM_ON);
lv_obj_set_style_bg_color(bar_session, pct_color(data->session_pct), LV_PART_INDICATOR); lv_obj_set_style_bg_color(bar_session, pct_color(data->session_pct), LV_PART_INDICATOR);
char buf[48];
format_reset_time(data->session_reset_mins, buf, sizeof(buf)); format_reset_time(data->session_reset_mins, buf, sizeof(buf));
lv_label_set_text(lbl_session_reset, buf); lv_label_set_text(lbl_session_reset, buf);
@@ -520,29 +988,67 @@ static void apply_battery_visibility(void) {
else lv_obj_clear_flag(battery_img, LV_OBJ_FLAG_HIDDEN); else lv_obj_clear_flag(battery_img, LV_OBJ_FLAG_HIDDEN);
} }
// Tapping the splash/animations screen returns to the home (usage) screen.
static void global_click_cb(lv_event_t* e) { static void global_click_cb(lv_event_t* e) {
(void)e; (void)e;
if (current_screen == SCREEN_SPLASH) ui_show_screen(prev_non_splash_screen); if (current_screen == SCREEN_SPLASH) ui_show_screen(SCREEN_USAGE);
else ui_show_screen(SCREEN_SPLASH); }
// The Claude logo on the home screen opens the launcher.
static void logo_click_cb(lv_event_t* e) {
(void)e;
ui_show_screen(SCREEN_MENU);
}
// Back chevron: from the launcher → home; from any app screen → launcher.
static void nav_back_cb(lv_event_t* e) {
(void)e;
ui_show_screen(current_screen == SCREEN_MENU ? SCREEN_USAGE : SCREEN_MENU);
}
// Top-left chrome: the Claude logo (tap → menu) on home, the back chevron on the
// screens below it, neither on the splash.
static void update_nav_chrome(screen_t screen) {
bool show_logo = (screen == SCREEN_USAGE);
bool show_back = (screen != SCREEN_USAGE && screen != SCREEN_SPLASH);
if (logo_img) {
if (show_logo) lv_obj_clear_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
}
if (nav_back_btn) {
if (show_back) lv_obj_clear_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
}
} }
void ui_show_screen(screen_t screen) { void ui_show_screen(screen_t screen) {
lv_obj_add_flag(usage_container, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(usage_container, LV_OBJ_FLAG_HIDDEN);
if (menu_container) lv_obj_add_flag(menu_container, LV_OBJ_FLAG_HIDDEN);
if (stub_container) lv_obj_add_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
if (bt_container) lv_obj_add_flag(bt_container, LV_OBJ_FLAG_HIDDEN);
if (soundpad_container) lv_obj_add_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN);
if (session_container) lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
if (nowplaying_container) lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
splash_hide(); splash_hide();
switch (screen) { switch (screen) {
case SCREEN_SPLASH: splash_show(); break; case SCREEN_SPLASH: splash_show(); break;
case SCREEN_USAGE: lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN); break; case SCREEN_USAGE: lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_MENU: lv_obj_clear_flag(menu_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_SOUNDPAD: lv_obj_clear_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_SESSION: lv_obj_clear_flag(session_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_NOWPLAYING: lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_BLUETOOTH: bt_refresh(); lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_HOMEASSIST:
stub_show_for(screen);
lv_obj_clear_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
break;
default: break; default: break;
} }
if (logo_img) {
if (screen == SCREEN_SPLASH) lv_obj_add_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
else lv_obj_clear_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
}
if (screen != SCREEN_SPLASH) prev_non_splash_screen = screen; if (screen != SCREEN_SPLASH) prev_non_splash_screen = screen;
current_screen = screen; current_screen = screen;
update_nav_chrome(screen);
apply_battery_visibility(); apply_battery_visibility();
} }
+8 -2
View File
@@ -3,8 +3,14 @@
#include "ble.h" #include "ble.h"
enum screen_t { enum screen_t {
SCREEN_SPLASH, SCREEN_SPLASH, // brand animation (boot) + "Animations" app
SCREEN_USAGE, SCREEN_USAGE, // home: Claude usage
SCREEN_MENU, // app launcher (opened by tapping the Claude logo)
SCREEN_SOUNDPAD, // 6-key HID soundpad (Phase 3)
SCREEN_SESSION, // today's tokens / cost (Phase 4)
SCREEN_NOWPLAYING, // media now-playing (Phase 5)
SCREEN_HOMEASSIST, // Home Assistant controls (Phase 6)
SCREEN_BLUETOOTH, // BLE connection info
SCREEN_COUNT, SCREEN_COUNT,
}; };
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// Clawdmeter firmware version.
//
// The "classic" line — the stable Waveshare AMOLED-2.06 port plus the working
// Windows BLE daemon — is frozen on the `main` branch and tagged v1.0-classic.
// Active development (multi-app launcher, on-screen soundpad, Now Playing,
// Home Assistant control, richer desktop companion) lives on `v2-dev`.
// See docs/v2-roadmap.md.
#define CLAWDMETER_VERSION "2.0.0-dev"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Patch lv_font_conv output for LVGL 9.
`lv_font_conv` emits font .c files wrapped in `#if LVGL_VERSION_MAJOR` guards and
(for v8) a `.cache` field. On LVGL 9 the project wants the plain, unguarded struct
with `.release_glyph` / `.kerning` / `.static_bitmap` present (see the "LVGL 9 font
patching" note in README.md). Without this the font compiles but renders invisible.
Usage:
python tools/patch_lvgl9_font.py firmware/src/font_foo.c [more.c ...]
Idempotent: running it on an already-patched file is a no-op.
"""
import re
import sys
SUBS = [
# drop the v8-only glyph cache declaration
(r'#if LVGL_VERSION_MAJOR == 8\n/\*Store all the custom data of the font\*/\n'
r'static lv_font_fmt_txt_glyph_cache_t cache;\n#endif\n\n', ''),
# collapse the font_dsc storage-class guard
(r'#if LVGL_VERSION_MAJOR >= 8\nstatic const lv_font_fmt_txt_dsc_t font_dsc = \{\n'
r'#else\nstatic lv_font_fmt_txt_dsc_t font_dsc = \{\n#endif\n',
'static const lv_font_fmt_txt_dsc_t font_dsc = {\n'),
# drop the v8 .cache member
(r'#if LVGL_VERSION_MAJOR == 8\n \.cache = &cache\n#endif\n', ''),
# collapse the public-font const guard
(r'#if LVGL_VERSION_MAJOR >= 8\n(const lv_font_t \w+ = \{)\n'
r'#else\nlv_font_t \w+ = \{\n#endif\n', r'\1\n'),
# keep .subpx and add the three LVGL 9 fields
(r'#if !\(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0\)\n'
r' \.subpx = LV_FONT_SUBPX_NONE,\n#endif\n',
' .subpx = LV_FONT_SUBPX_NONE,\n .release_glyph = NULL,\n'
' .kerning = 0,\n .static_bitmap = 0,\n'),
# unwrap underline fields
(r'#if LV_VERSION_CHECK\(7, 4, 0\) \|\| LVGL_VERSION_MAJOR >= 8\n'
r'( \.underline_position = -1,\n \.underline_thickness = 2,\n)#endif\n', r'\1'),
# unwrap .fallback
(r'#if LV_VERSION_CHECK\(8, 2, 0\) \|\| LVGL_VERSION_MAJOR >= 9\n'
r'( \.fallback = NULL,\n)#endif\n', r'\1'),
]
def patch(path: str) -> None:
s = open(path, encoding="utf-8").read()
for pat, repl in SUBS:
s = re.sub(pat, repl, s)
open(path, "w", encoding="utf-8").write(s)
leftover = len(re.findall(r'LVGL_VERSION_MAJOR|LV_VERSION_CHECK|\.cache = &cache', s))
print(f"patched {path} leftover_guards={leftover}")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
for p in sys.argv[1:]:
patch(p)
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Capture a screenshot from the watch over USB serial (Windows).
The Unix `screenshot.sh` shells out to ffmpeg; this is a self-contained Windows
equivalent that needs only pyserial + Pillow (both in the daemon venv). The
firmware's `screenshot` serial command dumps the LVGL framebuffer as raw RGB565LE
between `SCREENSHOT_START w h size` and `SCREENSHOT_END`.
Usage:
python tools/screenshot_win.py [COM_PORT] [out.png] (defaults: COM7 shot.png)
"""
import array
import sys
import time
import serial
from PIL import Image
port_name = sys.argv[1] if len(sys.argv) > 1 else "COM7"
out = sys.argv[2] if len(sys.argv) > 2 else "shot.png"
port = serial.Serial(port_name, 115200, timeout=3)
time.sleep(0.3)
port.reset_input_buffer()
port.write(b"screenshot\n")
port.flush()
w = h = raw_size = 0
deadline = time.time() + 15
while time.time() < deadline:
line = port.readline().decode("utf-8", errors="replace").strip()
if line.startswith("SCREENSHOT_START"):
_, sw, sh, ss = line.split()
w, h, raw_size = int(sw), int(sh), int(ss)
break
if line in ("SCREENSHOT_ERR", "SCREENSHOT_UNSUPPORTED"):
sys.exit(f"device error: {line}")
if not raw_size:
sys.exit("no SCREENSHOT_START (is the device booted and on COM port?)")
data = b""
while len(data) < raw_size:
chunk = port.read(min(8192, raw_size - len(data)))
if not chunk:
sys.exit(f"timeout: got {len(data)} of {raw_size} bytes")
data += chunk
port.close()
# RGB565 little-endian -> RGB888. array 'H' is native (LE on x86) == RGB565LE.
px = array.array("H")
px.frombytes(data)
rgb = bytearray(len(px) * 3)
for i, v in enumerate(px):
r = (v >> 11) & 0x1F
g = (v >> 5) & 0x3F
b = v & 0x1F
rgb[i * 3] = (r << 3) | (r >> 2)
rgb[i * 3 + 1] = (g << 2) | (g >> 4)
rgb[i * 3 + 2] = (b << 3) | (b >> 2)
Image.frombytes("RGB", (w, h), bytes(rgb)).save(out)
print(f"saved {out} {w}x{h} ({len(data)} bytes)")