21 Commits
Author SHA1 Message Date
wenilandClaude Opus 4.8 d55dc4a268 daemon: fix control-panel server crash in the windowed frozen exe
The onefile build is windowed (no console), so sys.stderr is None and uvicorn's
default logging dictConfig fails with "Unable to configure formatter 'default'",
which took the whole settings-panel server down (the BLE side kept working, but
the Settings window couldn't load). Pass log_config=None so uvicorn skips its own
logging setup — the daemon has its own file logger and doesn't need uvicorn's.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:46:27 +03:00
wenilandClaude Opus 4.8 1c64386996 v2 host: portable desktop app (tray + FastAPI + WebView2 panel), unified config, HA control
Fold the Windows tray/daemon into one self-contained Clawdmeter.exe with a
settings UI, and wire the host side of the watch features:

- config.py: single %LOCALAPPDATA%\Clawdmeter\config.json (ha/buttons/settings),
  atomic writes, auto-migration from the old ha_config.json.
- server.py: local FastAPI (127.0.0.1:8723) — GET/PUT /api/config (token masked),
  POST /api/ha/test, GET /api/ha/entities, GET /api/status.
- web/index.html: brand-styled settings panel (Status/HA/Buttons/Settings tabs).
- panel.py: pywebview/WebView2 window, launched as its own process (pywebview and
  pystray both want the main thread); tray "Settings" opens it via --panel.
- daemon: HA command dispatch (toggle/bri/ct), dynamic button labels + index→
  action mapping, watch-battery low warning toast, and the dimmer "dim" snapshot
  (dimreq → light_snapshot of the first entity) so the watch dial seeds from HA.
- clawdmeter.spec / requirements: bundle fastapi+uvicorn+pywebview+webview backend.
- build-exe.ps1: ASCII-only (Windows PowerShell 5.1 mangles em-dashes under cp1251).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:20:44 +03:00
wenilandClaude Opus 4.8 578bc04248 v2 firmware: HA command channel, battery screen, dynamic Home buttons, tilt-dimmer
Watch-side features developed on the AMOLED-2.06 and shared across all boards:

- BLE command channel (…0005, notify watch→PC) via ble_send_command(); the
  watch stays "dumb" and the daemon maps commands to Home Assistant.
- Battery detail screen + protective low-voltage cutoff; power HAL gains
  battery_mv() / shutdown(), with battery_est.{h,cpp} for the time-left anchor.
- Home screen: dynamic 2-column button grid driven by the desktop config
  (RX "btns" labels); tap notifies {"cmd":"btn","i":N}.
- Dimmer screen: IMU tilt-joystick over the light's brightness / color temp.
  imu_hal_read_accel() added to the HAL (real on 2.06/2.16, no-op elsewhere);
  streams absolute {"cmd":"bri"|"ct","v":..}; daemon seeds the dial via "dim".
  Per-board tilt axis is calibrated with the DIM_CALIB overlay (off by default).

Multi-board fix: enable LV_FONT_MONTSERRAT_20/28 on the 2.16, 1.8 and C6 envs.
Shared ui.cpp uses these glyphs for the launcher tiles and back chevron, so
those three boards had failed to compile since the Phase-2 UI landed. All four
envs now build clean. README: correct the Windows pairing name to "Clawdmeter".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:20:04 +03:00
wenilandClaude Opus 4.8 a6d391e9d1 v2 Phase 6 (step 1): Home Assistant REST client + live self-test
daemon/ha_client.py: HAClient (brightness, color_temp, on/off, state snapshot)
over the HA REST API with smooth `transition`; token is read from a local
%LOCALAPPDATA%\Clawdmeter\ha_config.json and is never logged. Verified
end-to-end against a real Gledopto GL-C-006P tunable-white dimmer.

ha_config.example.json is the committed template; the real config (with the
token) lives outside the repo and is .gitignored as a safety net.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:33:38 +03:00
wenilandClaude Opus 4.8 39a24eebad v2: point release links at the current Gitea domain (bvrdo.online)
gitea.wenil.tech is the old (internal-DNS-only) domain; the live host is
gitea.bvrdo.online. Use a direct release-asset link so download works even
while the instance ROOT_URL still points at the internal IP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:12:08 +03:00
wenilandClaude Opus 4.8 67df140ca4 v2: link the Gitea release as the standalone .exe download
Point README-windows.md at the published v2.0-beta.1 release so users
download Clawdmeter.exe directly from Gitea instead of building it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:55:36 +03:00
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
45 changed files with 12555 additions and 135 deletions
+17
View File
@@ -18,3 +18,20 @@ 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
# Local Home Assistant config (holds a secret token - never commit)
daemon/ha_config.json
# Session scratch — BLE daemon run logs + one-off on-device QA screenshots
/m3_daemon*.log
/dimmer_qa.png
/build-exe.log
+23 -1
View File
@@ -141,7 +141,7 @@ Run `pio run -d firmware` with no env to see the available board envs.
### Pair the device ### Pair the device
The device is a bonded BLE HID keyboard, so pair it once: **Settings → Bluetooth & devices → Add device → Bluetooth**, then select "Claude Controller". Pairing is **required** — it enables the physical buttons and keeps a persistent connection (the device keeps showing your last-synced usage even after the daemon quits). To undo, use **Remove device** (this disables the buttons). The device is a bonded BLE HID keyboard, so pair it once: **Settings → Bluetooth & devices → Add device → Bluetooth**, then select "Clawdmeter". Pairing is **required** — it enables the physical buttons and keeps a persistent connection (the device keeps showing your last-synced usage even after the daemon quits). To undo, use **Remove device** (this disables the buttons).
### Install the daemon (recommended) ### Install the daemon (recommended)
@@ -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."
+95
View File
@@ -0,0 +1,95 @@
# -*- 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
('daemon/web', 'daemon/web'), # Phase 7 control-panel UI (server._web_dir)
]
binaries = []
hiddenimports = [
# Imported lazily inside tray_windows.main(), so name them explicitly.
'daemon.claude_usage_daemon_windows',
'daemon.autostart_windows',
'daemon.icon_assets',
# Phase 7 control panel (lazy string-imports inside tray_windows / server).
'daemon.config',
'daemon.server',
'daemon.panel',
'daemon.ha_client',
# The exact winrt media modules read_now_playing() pulls in.
'winrt.windows.media',
'winrt.windows.media.control',
# pywebview's Windows backend + pythonnet bridge load these dynamically.
'webview.platforms.edgechromium',
'clr',
]
# Phase 7 adds the FastAPI control panel + the pywebview WebView2 window. uvicorn
# and pywebview both import their submodules (loop/protocol pickers; the
# edgechromium backend + bundled WebView2 DLLs) dynamically, which PyInstaller's
# static analysis misses — collect_all pulls submodules, binaries and data files.
# clr_loader/pythonnet ship the .NET runtime-config JSON pywebview's WinForms host
# needs. If a package is absent the build fails loudly (better than a silent
# blank window at runtime).
for _pkg in ('winrt', 'bleak', 'pystray', 'PIL',
'fastapi', 'uvicorn', 'webview', 'clr_loader', 'pythonnet'):
_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,
)
+55 -5
View File
@@ -59,7 +59,52 @@ 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.
> **Download:** the repo is private, so **sign in to Gitea first**, then download
> **`Clawdmeter.exe`** with this direct link:
> <https://gitea.bvrdo.online/wenil/clawdmeter/releases/download/v2.0-beta.1/Clawdmeter.exe>
> — or browse [all releases](https://gitea.bvrdo.online/wenil/clawdmeter/releases) for the newest build.
1. Pair the device with Windows once (see [Pair the device](#pair-the-device-one-time)).
2. Get `Clawdmeter.exe` — download it from the
[latest Gitea release](https://gitea.bvrdo.online/wenil/clawdmeter/releases/latest), 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 +163,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 +269,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}"'
+626 -68
View File
@@ -32,9 +32,22 @@ DEVICE_ADDRESS = os.environ.get("CLAWDMETER_ADDRESS")
SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001" 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"
CMD_CHAR_UUID = "4c41555a-4465-7669-6365-000000000005" # watch → host commands (Phase 6 HA)
POLL_INTERVAL = 60 # Phase 7 M2 — dynamic watch buttons. The daemon pushes up to this many button
TICK = 5 # labels (each truncated) in the RX payload's "btns" array on the ~60s heartbeat;
# the watch renders a grid and reports only the pressed index. Bounds keep the
# merged BLE payload comfortably under the firmware's 512-byte RX buffer.
WATCH_MAX_BUTTONS = 6
WATCH_LABEL_MAX = 16
POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds)
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 +70,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 +277,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():
@@ -176,9 +304,58 @@ async def scan_for_device():
class Session: class Session:
def __init__(self, client: BleakClient) -> None: def __init__(self, client: BleakClient, tray_state=None) -> None:
self.client = client self.client = client
self.tray_state = tray_state
self.refresh_requested = asyncio.Event() self.refresh_requested = asyncio.Event()
self.dim_requested = asyncio.Event() # watch opened the Dimmer screen → push a fresh light snapshot
self.ha = None # HAClient once a valid HA config is loaded
self.ha_entities: list = [] # controllable entity_ids from the config
self._loop = None # event loop captured for thread-safe BLE dispatch
self.low_bat_pct = 15 # warn at/below this watch battery %
self._battery_warned = False
self._last_logged_bat = None
self.buttons = [] # config buttons; list index == watch button index
def reload_buttons(self) -> None:
"""Re-read the config's button list so panel edits propagate to the watch
(called each poll). The list index is the index the watch reports back."""
try:
try:
from daemon.config import load_config
except ImportError:
from config import load_config
self.buttons = load_config().get("buttons") or []
except Exception as e:
log(f"Button reload failed: {e!r}")
def button_labels(self) -> list:
"""Compact label list for the RX 'btns' payload (count + length capped so
the merged BLE write stays under the firmware's 512-byte buffer)."""
out = []
for b in self.buttons[:WATCH_MAX_BUTTONS]:
lbl = str(b.get("label") or b.get("entity") or "Button").strip()
out.append(lbl[:WATCH_LABEL_MAX])
return out
async def dim_snapshot(self) -> dict | None:
"""Compact live state of the tilt-dimmer's light for the RX 'dim' object,
so the watch dial seeds from reality. The dimmer targets the first HA
entity (same default the bri/ct commands fall back to). Returns None when
HA is off or the read fails — the watch then just shows its last value."""
if not self.ha or not self.ha_entities:
return None
snap = await self.ha.light_snapshot(self.ha_entities[0])
if not snap:
return None
out = {"on": 1 if snap["on"] else 0,
"mink": int(snap["min_kelvin"]),
"maxk": int(snap["max_kelvin"])}
if snap["brightness_pct"] is not None:
out["bri"] = int(snap["brightness_pct"])
if snap["color_temp_kelvin"] is not None:
out["ct"] = int(snap["color_temp_kelvin"])
return out
def _on_refresh(self, _char, _data: bytearray) -> None: def _on_refresh(self, _char, _data: bytearray) -> None:
log("Refresh requested by device") log("Refresh requested by device")
@@ -196,11 +373,128 @@ class Session:
except (BleakError, ValueError, OSError) as e: except (BleakError, ValueError, OSError) as e:
log(f"Refresh subscription unavailable: {e}") log(f"Refresh subscription unavailable: {e}")
async def setup_command_subscription(self) -> None:
# Watch → host channel (…0005): battery telemetry (always) plus HA commands
# (when HA is configured). We subscribe regardless of HA so the low-battery
# warning works even with Home Assistant unset. Imports are lazy so a
# missing config/dep can never break daemon startup, and so they resolve
# after `log` is defined (ha_client imports it).
self._loop = asyncio.get_running_loop() # thread-safe dispatch from the BLE callback
try:
try:
from daemon.ha_client import HAClient
from daemon.config import load_config, ha_settings
except ImportError:
from ha_client import HAClient
from config import load_config, ha_settings
cfg = load_config()
self.low_bat_pct = int(cfg.get("settings", {}).get("low_battery_pct", 15))
self.buttons = cfg.get("buttons") or []
ha = ha_settings(cfg)
if ha:
self.ha = HAClient(ha["url"], ha["token"])
self.ha_entities = ha["entities"]
except Exception as e:
log(f"Config/HA init failed ({e!r}); HA control disabled")
try:
await self.client.start_notify(CMD_CHAR_UUID, self._on_command)
log(f"Command channel ready (HA={'on' if self.ha else 'off'}, "
f"low-batt={self.low_bat_pct}%, entities={self.ha_entities})")
except (BleakError, ValueError, OSError) as e:
log(f"Command subscription unavailable: {e}")
def _on_command(self, _char, data: bytearray) -> None:
try:
payload = json.loads(bytes(data).decode("utf-8", "replace"))
except (ValueError, UnicodeDecodeError):
log(f"Watch msg: bad payload {bytes(data)!r}")
return
# Battery telemetry ({"bat":pct,"mv":..,"chg":0/1}) is handled inline (quick,
# no await). HA commands ({"cmd":...}) are dispatched onto the loop because
# bleak may deliver this callback on a non-loop thread.
if "bat" in payload:
self._handle_battery(payload)
return
# Dimmer screen opened (M3): ask the poll loop to push a fresh light
# snapshot on its next tick (~3s). asyncio.Event isn't thread-safe and
# this callback may run off-loop, so flip it via the loop.
if payload.get("cmd") == "dimreq":
loop = self._loop
if loop is not None:
loop.call_soon_threadsafe(self.dim_requested.set)
return
# Phase 7 M2: a watch button press carries only its index — map it to the
# configured action/entity/value here (the watch stays dumb).
if payload.get("cmd") == "btn":
i = payload.get("i")
if not isinstance(i, int) or not (0 <= i < len(self.buttons)):
log(f"Watch button {i}: out of range (have {len(self.buttons)})")
return
b = self.buttons[i]
payload = {"cmd": b.get("action") or "toggle", "e": b.get("entity")}
if b.get("value") is not None:
payload["v"] = b["value"]
log(f"Watch button {i} -> {payload}")
loop = self._loop
if loop is None:
return
asyncio.run_coroutine_threadsafe(self._dispatch_command(payload), loop)
def _handle_battery(self, payload: dict) -> None:
pct = payload.get("bat")
charging = bool(payload.get("chg", 0))
if not isinstance(pct, int):
return
if pct != self._last_logged_bat:
self._last_logged_bat = pct
log(f"Watch battery: {pct}%{' (charging)' if charging else ''}")
if self.tray_state is not None:
self.tray_state.battery_pct = pct
# Warn once on the way down; re-arm when charging or comfortably recovered.
if charging or pct > self.low_bat_pct + 10:
self._battery_warned = False
if not charging and pct <= self.low_bat_pct and not self._battery_warned:
self._battery_warned = True
log(f"Watch battery low: {pct}% (<= {self.low_bat_pct}%)")
if self.tray_state is not None:
self.tray_state.toasts.put(
("Clawdmeter", f"Watch battery low - {pct}%. Time to charge it."))
async def _dispatch_command(self, payload: dict) -> None:
if not self.ha:
return
cmd = payload.get("cmd")
entity = payload.get("e") or (self.ha_entities[0] if self.ha_entities else None)
if not entity:
log("HA cmd: no entity to target")
return
if cmd == "toggle":
ok = await self.ha.toggle(entity)
elif cmd == "bri": # step 3: brightness %, joystick
ok = await self.ha.set_brightness(entity, float(payload.get("v", 0)))
elif cmd == "ct": # step 3: color temperature, kelvin
ok = await self.ha.set_color_temp(entity, float(payload.get("v", 0)))
else:
log(f"HA cmd: unknown cmd {cmd!r}")
return
log(f"HA cmd {cmd} -> {'ok' if ok else 'FAIL'}")
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 +606,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]
@@ -378,58 +824,148 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
return False return False
log("Connected") log("Connected")
session = Session(client) session = Session(client, tray_state)
await session.setup_refresh_subscription() await session.setup_refresh_subscription()
await session.setup_command_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)
dim_due = session.dim_requested.is_set() # watch opened the Dimmer screen
if dim_due:
session.dim_requested.clear()
auth_problem = False
if claude_due:
session.refresh_requested.clear() session.refresh_requested.clear()
token = read_token() # D-09: fresh each cycle session.reload_buttons() # pick up desktop-panel edits to the button set
# 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)
if await session.write_payload(payload): except Exception as e:
last_poll = time.time() log(f"Forced token refresh failed: {e!r}")
used_successfully = True forced = False
consecutive_failures = 0 # D-03: reset on success 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}
# Light snapshot for the tilt-dimmer dial — fetched only when the watch
# opens the Dimmer screen (dim_due), to seed the dial from reality. No
# per-heartbeat fetch: during a session the watch is authoritative (it
# streams absolute values), and it re-requests on every re-open. Best-
# effort — a slow/dead HA just omits the field, never stalls the loop.
dim_snap = None
if dim_due:
try:
dim_snap = await session.dim_snapshot()
except Exception as e:
log(f"Dim snapshot skipped: {e!r}")
# Write when the usage data was just refreshed (this is also the ~60s
# heartbeat), when the track / playback state changed since the last
# write, or when the watch asked for a dimmer snapshot — skip otherwise
# so the link, and the field log, stay quiet between changes.
if claude_due or dim_due or np != last_np_sent:
payload = dict(cached)
payload.update(np)
if claude_due:
payload["btns"] = session.button_labels() # ~60s heartbeat only
if dim_snap is not None:
payload["dim"] = dim_snap
if await session.write_payload(payload):
used_successfully = True
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())
else: elif auth_problem:
consecutive_failures += 1 if tray_state:
if consecutive_failures >= ZOMBIE_BREAK_LIMIT: tray_state.set_error("token expired — run claude login")
log( # transient rate-limit failure: leave tray state unchanged
f"Zombie link detected ({consecutive_failures} consecutive" else:
f" write failures); abandoning connection" consecutive_failures += 1
) if consecutive_failures >= ZOMBIE_BREAK_LIMIT:
break log(
# else: payload is None from a TRANSIENT failure (network/DNS, f"Zombie link detected ({consecutive_failures} consecutive"
# timeout, rate-limit, 5xx). poll_api already logged it; do NOT f" write failures); abandoning connection"
# toast "token expired" — that mislabeled a boot-time DNS blip )
# as an auth problem (SC#5). Leave tray state unchanged; the next break
# 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,
@@ -438,6 +974,11 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
await client.disconnect() await client.disconnect()
except (BleakError, OSError): except (BleakError, OSError):
pass pass
if session.ha:
try:
await session.ha.aclose()
except Exception:
pass
log("Device disconnected" if not stop_event.is_set() else "Stopping") log("Device disconnected" if not stop_event.is_set() else "Stopping")
return used_successfully return used_successfully
@@ -490,34 +1031,51 @@ 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():
device = await scan_for_device() try:
if not device: device = await scan_for_device()
# Slow-search regime: device was not found by scan — back off gently if not device:
# Slow-search regime: device was not found by scan — back off gently
if tray_state:
tray_state.set_scanning()
log(f"Device not found, 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)
continue
ok = await connect_and_run(device, stop_event, tray_state)
if not ok:
# Fast-reconnect regime: had/attempted a link that dropped — retry quickly
if tray_state:
tray_state.set_scanning()
log(f"Connection lost, reconnecting in {reconnect_backoff}s...")
try:
await asyncio.wait_for(stop_event.wait(), timeout=reconnect_backoff)
except asyncio.TimeoutError:
pass
reconnect_backoff = _next_backoff(reconnect_backoff, RECONNECT_BACKOFF_CAP)
else:
# Successful session — reset reconnect counter to floor; search_backoff also reset
reconnect_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: if tray_state:
tray_state.set_scanning() tray_state.set_scanning()
log(f"Device not found, retrying in {search_backoff}s...") log(f"BLE error ({type(e).__name__}: {e}); retrying in {search_backoff}s...")
try: try:
await asyncio.wait_for(stop_event.wait(), timeout=search_backoff) await asyncio.wait_for(stop_event.wait(), timeout=search_backoff)
except asyncio.TimeoutError: except asyncio.TimeoutError:
pass pass
search_backoff = _next_backoff(search_backoff, 60) search_backoff = _next_backoff(search_backoff, 60)
continue
ok = await connect_and_run(device, stop_event, tray_state)
if not ok:
# Fast-reconnect regime: had/attempted a link that dropped — retry quickly
if tray_state:
tray_state.set_scanning()
log(f"Connection lost, reconnecting in {reconnect_backoff}s...")
try:
await asyncio.wait_for(stop_event.wait(), timeout=reconnect_backoff)
except asyncio.TimeoutError:
pass
reconnect_backoff = _next_backoff(reconnect_backoff, RECONNECT_BACKOFF_CAP)
else:
# Successful session — reset reconnect counter to floor; search_backoff also reset
reconnect_backoff = 1
search_backoff = 1
if __name__ == "__main__": if __name__ == "__main__":
+116
View File
@@ -0,0 +1,116 @@
"""Unified Clawdmeter configuration (Phase 7).
Single source of truth at ``%LOCALAPPDATA%\\Clawdmeter\\config.json``, shared by
the tray daemon and the FastAPI control panel. Replaces the Phase-6
``ha_config.json`` (auto-migrated on first load).
Shape::
{
"version": 1,
"ha": {"url": str, "token": str, "entities": [str, ...]},
"buttons": [{"id": str, "label": str, "icon": str,
"action": "toggle"|"bri"|"ct", "entity": str, "value": int|None}],
"settings": {"device_address": str, "autostart": bool}
}
The token is a secret: it lives only in this file (outside the repo, gitignored)
and is NEVER logged (only its length) — same discipline as ha_client.py. The
control panel masks it in API responses.
"""
import json
import os
from pathlib import Path
CONFIG_VERSION = 1
DEFAULT_PORT = 8723 # FastAPI control panel — bound to 127.0.0.1 only
VALID_ACTIONS = ("toggle", "bri", "ct")
def _dir() -> Path:
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
return base / "Clawdmeter"
def config_path() -> Path:
if override := os.environ.get("CLAWDMETER_CONFIG"):
return Path(override)
return _dir() / "config.json"
def _legacy_ha_path() -> Path:
return _dir() / "ha_config.json"
def default_config() -> dict:
return {
"version": CONFIG_VERSION,
"ha": {"url": "", "token": "", "entities": []},
"buttons": [],
"settings": {"device_address": "", "autostart": False, "low_battery_pct": 15},
}
def _migrate_legacy(cfg: dict) -> dict:
"""Import the Phase-6 ha_config.json into the unified config when the unified
HA section is still empty. Non-destructive: the old file is left in place."""
legacy = _legacy_ha_path()
if cfg["ha"]["url"] or not legacy.exists():
return cfg
try:
# utf-8-sig: PowerShell's Out-File writes a BOM that json.loads chokes on.
old = json.loads(legacy.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError):
return cfg
cfg["ha"]["url"] = (old.get("url") or "").strip().rstrip("/")
cfg["ha"]["token"] = (old.get("token") or "").strip()
cfg["ha"]["entities"] = list(old.get("entities") or [])
return cfg
def load_config() -> dict:
"""Load the unified config, filling defaults for any missing section so new
keys added in later versions always resolve. Never raises."""
cfg = default_config()
try:
loaded = json.loads(config_path().read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError):
loaded = None
if isinstance(loaded, dict):
for section in ("ha", "settings"):
if isinstance(loaded.get(section), dict):
cfg[section].update(loaded[section])
if isinstance(loaded.get("buttons"), list):
cfg["buttons"] = loaded["buttons"]
if not cfg["ha"]["url"] and not cfg["ha"]["token"]:
cfg = _migrate_legacy(cfg)
# Normalize the HA section the same way ha_client expects it.
cfg["ha"]["url"] = (cfg["ha"].get("url") or "").strip().rstrip("/")
cfg["ha"]["token"] = (cfg["ha"].get("token") or "").strip()
cfg["ha"]["entities"] = list(cfg["ha"].get("entities") or [])
return cfg
def save_config(cfg: dict) -> None:
"""Persist atomically (temp file + os.replace) so a crash mid-write can't
leave a truncated config."""
path = config_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
def ha_settings(cfg: dict | None = None) -> dict | None:
"""Return {url, token, entities} when HA is configured (url + real token),
else None — the shape the daemon needs to build an HAClient. Logging of the
token is the caller's responsibility (ha_client logs only the length)."""
if cfg is None:
cfg = load_config()
ha = cfg.get("ha", {})
url = (ha.get("url") or "").strip().rstrip("/")
token = (ha.get("token") or "").strip()
if not url or not token or token.startswith("PASTE_"):
return None
return {"url": url, "token": token, "entities": list(ha.get("entities") or [])}
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Home Assistant REST client for the Clawdmeter daemon (Phase 6).
The watch stays "dumb": it sends a short command over BLE (which entity, which
parameter, what value) and this module turns that into a Home Assistant service
call over the local network. Everything HA-specific — the base URL, the
long-lived access token, the controllable entities — lives on the PC, never on
the device.
Config lives OUTSIDE the repo at %LOCALAPPDATA%\\Clawdmeter\\ha_config.json so
the token is never committed. Copy daemon/ha_config.example.json there and fill
in `url` + `token`. The token is a secret: it is sent only as the HTTP
Authorization header and is NEVER written to any log (mirrors how the OAuth
secret is handled in the main daemon).
Live self-test against your real light (after the config is filled in), run
from the repo root with the venv python:
.venv\\Scripts\\python.exe -m daemon.ha_client
"""
import asyncio
import json
import os
from pathlib import Path
import httpx
# Reuse the daemon's logger so HA lines land in the same daemon.log. Importing
# the daemon module is safe (the tray already imports it; it has no import-time
# side effects beyond setting up the file logger). Fall back to print() when run
# in isolation before packaging.
try:
from daemon.claude_usage_daemon_windows import log
except Exception: # pragma: no cover - standalone/fallback
def log(msg: str) -> None:
print(msg, flush=True)
# GL-C-006P advertises feature 32 (transition), so every change can EASE instead
# of snapping. Keep it short: the joystick sends ~5x/sec, so a long transition
# would lag behind the wrist. 0.3s reads as "smooth" without feeling laggy.
DEFAULT_TRANSITION = 0.3
DEFAULT_MIN_KELVIN = 2000
DEFAULT_MAX_KELVIN = 6500
def _config_path() -> Path:
if override := os.environ.get("CLAWDMETER_HA_CONFIG"):
return Path(override)
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
return base / "Clawdmeter" / "ha_config.json"
def load_ha_config() -> dict | None:
"""Load {url, token, entities} from the local HA config file.
Returns None (with a helpful log) when the file is missing or still holds the
placeholder token, so the daemon runs fine with HA simply disabled.
"""
path = _config_path()
try:
# utf-8-sig: PowerShell's `Out-File -Encoding utf8` writes a BOM, which
# would otherwise make json.loads choke on the first character.
raw = path.read_text(encoding="utf-8-sig")
except OSError:
log(f"HA: no config at {path} - HA control disabled "
f"(copy daemon/ha_config.example.json there)")
return None
try:
cfg = json.loads(raw)
except json.JSONDecodeError as e:
log(f"HA: config at {path} is not valid JSON ({e}) — HA control disabled")
return None
url = (cfg.get("url") or "").strip().rstrip("/")
token = (cfg.get("token") or "").strip()
entities = cfg.get("entities") or []
if not url or not token or token.startswith("PASTE_"):
log("HA: config present but `url`/`token` not filled in - HA control disabled")
return None
# Confirm load WITHOUT ever logging the token itself.
log(f"HA: config loaded - url={url}, entities={entities}, token len={len(token)}")
return {"url": url, "token": token, "entities": list(entities)}
class HAClient:
"""Thin async wrapper over the Home Assistant REST API.
One long-lived httpx client; calls are local and quick. Methods return
True/False (or parsed state) and never raise on an HTTP/Zigbee failure — a
dead HA or a flaky mesh must not crash the daemon loop.
"""
def __init__(self, url: str, token: str, *, timeout: float = 10.0) -> None:
self._url = url.rstrip("/")
self._http = httpx.AsyncClient(
timeout=timeout,
headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json"},
)
async def aclose(self) -> None:
await self._http.aclose()
async def _service(self, domain: str, service: str, data: dict) -> bool:
url = f"{self._url}/api/services/{domain}/{service}"
try:
resp = await self._http.post(url, json=data)
except httpx.HTTPError as e:
log(f"HA: {domain}.{service} failed: {e}")
return False
if resp.status_code >= 400:
# 401 (token) and 400 (bad entity) both land here. Log status + short
# body only — never the request headers, which carry the token.
log(f"HA: {domain}.{service} HTTP {resp.status_code}: {resp.text[:160]}")
return False
return True
async def get_state(self, entity_id: str) -> dict | None:
url = f"{self._url}/api/states/{entity_id}"
try:
resp = await self._http.get(url)
except httpx.HTTPError as e:
log(f"HA: get_state({entity_id}) failed: {e}")
return None
if resp.status_code >= 400:
log(f"HA: get_state({entity_id}) HTTP {resp.status_code}")
return None
try:
return resp.json()
except ValueError:
return None
# --- light helpers --------------------------------------------------------
async def turn_off(self, entity_id: str, transition: float = DEFAULT_TRANSITION) -> bool:
return await self._service("light", "turn_off",
{"entity_id": entity_id, "transition": transition})
async def toggle(self, entity_id: str) -> bool:
return await self._service("light", "toggle", {"entity_id": entity_id})
async def set_brightness(self, entity_id: str, pct: float,
transition: float = DEFAULT_TRANSITION) -> bool:
"""Set brightness 0-100 %. 0 (or below) turns the light off."""
pct = max(0, min(100, round(pct)))
if pct <= 0:
return await self.turn_off(entity_id, transition)
return await self._service("light", "turn_on", {
"entity_id": entity_id, "brightness_pct": pct, "transition": transition})
async def set_color_temp(self, entity_id: str, kelvin: float,
min_k: int = DEFAULT_MIN_KELVIN,
max_k: int = DEFAULT_MAX_KELVIN,
transition: float = DEFAULT_TRANSITION) -> bool:
kelvin = int(max(min_k, min(max_k, round(kelvin))))
return await self._service("light", "turn_on", {
"entity_id": entity_id, "color_temp_kelvin": kelvin, "transition": transition})
async def light_snapshot(self, entity_id: str) -> dict | None:
"""Read the light's current values so the watch dial starts from the real
state instead of 0. Returns normalized fields, or None on failure."""
st = await self.get_state(entity_id)
if not st:
return None
attrs = st.get("attributes", {})
bri_255 = attrs.get("brightness") # 0-255, or None when off
bri_pct = round(bri_255 / 255 * 100) if isinstance(bri_255, (int, float)) else None
return {
"on": st.get("state") == "on",
"brightness_pct": bri_pct,
"color_temp_kelvin": attrs.get("color_temp_kelvin"),
"min_kelvin": attrs.get("min_color_temp_kelvin", DEFAULT_MIN_KELVIN),
"max_kelvin": attrs.get("max_color_temp_kelvin", DEFAULT_MAX_KELVIN),
}
async def _self_test() -> int:
"""Live smoke test: drive the first configured light so you can SEE it react,
then restore its original state."""
cfg = load_ha_config()
if not cfg:
return 1
if not cfg["entities"]:
log("HA self-test: no entities in config")
return 1
eid = cfg["entities"][0]
ha = HAClient(cfg["url"], cfg["token"])
try:
snap = await ha.light_snapshot(eid)
if snap is None:
log(f"HA self-test: could not read {eid} - check url / token / entity_id")
return 1
log(f"HA self-test: {eid} now -> {snap}")
min_k, max_k = snap["min_kelvin"], snap["max_kelvin"]
sequence = [
("brightness 30%", lambda: ha.set_brightness(eid, 30)),
("brightness 80%", lambda: ha.set_brightness(eid, 80)),
(f"warm {min_k}K", lambda: ha.set_color_temp(eid, min_k, min_k, max_k)),
(f"cool {max_k}K", lambda: ha.set_color_temp(eid, max_k, min_k, max_k)),
]
for label, action in sequence:
ok = await action()
log(f"HA self-test: {label} -> {'ok' if ok else 'FAIL'}")
await asyncio.sleep(1.3)
# Restore the original state.
if snap["on"]:
if snap["brightness_pct"]:
await ha.set_brightness(eid, snap["brightness_pct"])
if snap["color_temp_kelvin"]:
await ha.set_color_temp(eid, snap["color_temp_kelvin"], min_k, max_k)
else:
await ha.turn_off(eid)
log("HA self-test: done (original state restored)")
return 0
finally:
await ha.aclose()
if __name__ == "__main__":
raise SystemExit(asyncio.run(_self_test()))
+6
View File
@@ -0,0 +1,6 @@
{
"_comment": "Home Assistant config for Clawdmeter Phase 6. COPY this file to %LOCALAPPDATA%\\Clawdmeter\\ha_config.json and fill in url + token. The real file lives OUTSIDE the repo so the token is never committed. token = a Home Assistant Long-Lived Access Token (HA -> your profile -> Security -> Create Token).",
"url": "http://homeassistant.local:8123",
"token": "PASTE_LONG_LIVED_ACCESS_TOKEN_HERE",
"entities": ["light.dimmer_v_spalne"]
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""WebView2 settings window for Clawdmeter (Phase 7, M1).
Opens a native Windows WebView2 window (via pywebview) onto the local control
panel the tray process already serves on 127.0.0.1. Launched as a SEPARATE
PROCESS from the tray — ``Clawdmeter.exe --panel`` when frozen, ``python -m
daemon.panel`` in source — because pywebview and pystray each need to own the
main thread and cannot coexist in one process (see tray_windows._on_settings).
The port comes from CLAWDMETER_PANEL_PORT (set by the tray when it spawns us)
and falls back to the config default, so the window always points at the server
the tray actually started.
"""
from __future__ import annotations
import os
import sys
import time
import urllib.request
# Make the `daemon` package importable when run as a bare script or frozen exe,
# mirroring tray_windows.py's bootstrap (logon autostart starts us with cwd =
# System32, and the frozen exe loads the package from the bundle root).
if getattr(sys, "frozen", False):
_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:
sys.path.insert(0, _REPO_ROOT)
_VENV_SITE = os.path.join(_REPO_ROOT, ".venv", "Lib", "site-packages")
if os.path.isdir(_VENV_SITE):
import site
site.addsitedir(_VENV_SITE)
try:
from daemon import config as cfgmod
except ImportError: # running as a plain script (cwd on path)
import config as cfgmod
WINDOW_TITLE = "Clawdmeter"
WINDOW_W = 860
WINDOW_H = 720
WINDOW_MIN = (640, 560)
BRAND_BG = "#131211" # paint the chrome brand-dark so there's no white flash
def _port() -> int:
raw = os.environ.get("CLAWDMETER_PANEL_PORT")
if raw:
try:
return int(raw)
except ValueError:
pass
return cfgmod.DEFAULT_PORT
def _wait_for_server(url: str, timeout: float = 6.0) -> bool:
"""Poll the server's status endpoint until it answers or the timeout elapses.
The tray starts the HTTP server in a thread a moment before it can spawn us,
so a freshly-clicked Settings might briefly beat the socket. Polling avoids a
blank window in that race; a miss just means we open anyway and the UI's own
fetch retries.
"""
deadline = time.time() + timeout
probe = url.rstrip("/") + "/api/status"
while time.time() < deadline:
try:
with urllib.request.urlopen(probe, timeout=1.0) as r:
if r.status == 200:
return True
except Exception:
time.sleep(0.25)
return False
def run() -> None:
"""Open the control-panel window and block until the user closes it."""
import webview # imported here so the tray never pays for it unless --panel
url = f"http://127.0.0.1:{_port()}/"
_wait_for_server(url)
webview.create_window(
WINDOW_TITLE, url,
width=WINDOW_W, height=WINDOW_H,
min_size=WINDOW_MIN,
background_color=BRAND_BG,
)
# gui defaults to auto-detect; on Windows 11 that resolves to EdgeChromium
# (WebView2), which ships with the OS — the modern engine the brand CSS needs.
# start() blocks on the native GUI loop until the window closes.
webview.start()
if __name__ == "__main__":
run()
+12
View File
@@ -4,3 +4,15 @@ 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
# Phase 7 control panel: a local FastAPI server (bound to 127.0.0.1) serves the
# brand UI, and pywebview hosts it in a native Edge WebView2 window. pywebview
# pulls pythonnet (clr) on Windows for the EdgeChromium backend. httpx (above)
# is reused for the HA test/entities calls.
fastapi
uvicorn
pywebview
+171
View File
@@ -0,0 +1,171 @@
"""Local control-panel HTTP API for the Clawdmeter app (Phase 7, M1).
A FastAPI app bound to 127.0.0.1 only. Serves the brand-styled web UI (``web/``)
and a small REST API over the unified config (``config.py``). Runs in a daemon
thread alongside the tray and the BLE daemon — one process, one exe.
Security: bound to loopback; the HA token is masked in GET responses and never
logged (mirrors ha_client / config).
"""
from __future__ import annotations
import copy
import os
import sys
import threading
from pathlib import Path
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
try:
from daemon import config as cfgmod
except ImportError: # running as a plain script (cwd on path)
import config as cfgmod
MASK = "********" # what the UI sees instead of the real token; echo it back to keep it
def _web_dir() -> Path:
"""The static UI directory: next to this file in source, under the PyInstaller
bundle root when frozen (added via clawdmeter.spec datas)."""
if getattr(sys, "frozen", False):
return Path(sys._MEIPASS) / "daemon" / "web" # type: ignore[attr-defined]
return Path(__file__).parent / "web"
def _masked(cfg: dict) -> dict:
c = copy.deepcopy(cfg)
if c.get("ha", {}).get("token"):
c["ha"]["token"] = MASK
return c
app = FastAPI(title="Clawdmeter")
@app.get("/api/config")
def get_config() -> dict:
return _masked(cfgmod.load_config())
class ConfigIn(BaseModel):
ha: dict | None = None
buttons: list | None = None
settings: dict | None = None
@app.put("/api/config")
def put_config(incoming: ConfigIn) -> dict:
cfg = cfgmod.load_config()
data = incoming.model_dump(exclude_none=True)
if "ha" in data:
ha = dict(data["ha"])
# Mask echoed back unchanged => keep the stored token (UI never holds it).
if ha.get("token") == MASK:
ha["token"] = cfg["ha"]["token"]
cfg["ha"].update(ha)
if "settings" in data:
cfg["settings"].update(data["settings"])
if "buttons" in data:
cfg["buttons"] = data["buttons"]
cfgmod.save_config(cfg)
return _masked(cfg)
def _resolve_token(token: str) -> str:
return cfgmod.load_config()["ha"]["token"] if token == MASK else token
@app.post("/api/ha/test")
async def ha_test(body: dict) -> dict:
url = (body.get("url") or "").strip().rstrip("/")
token = _resolve_token((body.get("token") or "").strip())
if not url or not token:
raise HTTPException(status_code=400, detail="url and token are required")
try:
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(f"{url}/api/",
headers={"Authorization": f"Bearer {token}"})
except httpx.HTTPError as e:
return {"ok": False, "error": str(e)}
if resp.status_code == 200:
try:
msg = resp.json().get("message", "API running")
except ValueError:
msg = "API running"
return {"ok": True, "message": msg}
return {"ok": False, "error": f"HTTP {resp.status_code}"}
@app.get("/api/ha/entities")
async def ha_entities() -> dict:
ha = cfgmod.ha_settings()
if not ha:
return {"entities": [], "error": "Home Assistant not configured"}
try:
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(f"{ha['url']}/api/states",
headers={"Authorization": f"Bearer {ha['token']}"})
except httpx.HTTPError as e:
return {"entities": [], "error": str(e)}
if resp.status_code != 200:
return {"entities": [], "error": f"HTTP {resp.status_code}"}
try:
states = resp.json()
except ValueError:
return {"entities": [], "error": "bad response"}
lights = [s["entity_id"] for s in states
if isinstance(s, dict) and str(s.get("entity_id", "")).startswith("light.")]
return {"entities": sorted(lights)}
_status_provider = None # set by the tray to expose live BLE/daemon state
def set_status_provider(fn) -> None:
"""The tray injects a callable returning the live status dict (connected,
battery, state). Kept out of import-time so server.py runs standalone."""
global _status_provider
_status_provider = fn
@app.get("/api/status")
def get_status() -> dict:
if _status_provider is not None:
try:
return _status_provider()
except Exception:
pass
return {"connected": False, "battery": None, "state": "unknown"}
# Static UI mounted LAST so the /api/* routes above take precedence.
_wd = _web_dir()
if _wd.exists():
app.mount("/", StaticFiles(directory=str(_wd), html=True), name="web")
def serve_in_thread(port: int | None = None) -> threading.Thread:
"""Start uvicorn on 127.0.0.1 in a daemon thread; return the thread."""
import uvicorn
p = port or cfgmod.DEFAULT_PORT
# log_config=None disables uvicorn's default dictConfig. In the frozen,
# windowed exe there is no console, so sys.stderr is None and uvicorn's
# default logging setup dies with "Unable to configure formatter 'default'",
# taking the whole control-panel server down. We don't need uvicorn's logs
# (the daemon has its own file logger), so skip its logging config entirely.
server = uvicorn.Server(uvicorn.Config(
app, host="127.0.0.1", port=p, log_level="warning", log_config=None))
t = threading.Thread(target=server.run, daemon=True, name="clawd-http")
t.start()
return t
if __name__ == "__main__":
# Dev: run the server in the foreground with autoreload-free uvicorn.
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=cfgmod.DEFAULT_PORT, log_level="info")
+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}"
+133 -14
View File
@@ -18,6 +18,8 @@ Run: python -m pytest daemon/tests/test_windows_tray.py -x -q
""" """
import os import os
import queue
import subprocess
import sys import sys
import threading import threading
import time import time
@@ -27,7 +29,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)
@@ -61,6 +69,8 @@ class TrayState:
self.state: str = "scanning" # "connected" | "scanning" | "error" self.state: str = "scanning" # "connected" | "scanning" | "error"
self.reason: str = "" # error reason string (D-04) self.reason: str = "" # error reason string (D-04)
self.last_sync: float | None = None # time.time() of last successful write self.last_sync: float | None = None # time.time() of last successful write
self.battery_pct: int | None = None # latest watch battery %, from the …0005 channel
self.toasts: "queue.Queue" = queue.Queue() # (title, message) toasts for the tray to show
# Populated by daemon main() at startup: # Populated by daemon main() at startup:
self.loop = None # asyncio running loop (for call_soon_threadsafe) self.loop = None # asyncio running loop (for call_soon_threadsafe)
@@ -156,6 +166,34 @@ def _acquire_single_instance():
return handle return handle
# ---------------------------------------------------------------------------
# control-panel glue (Phase 7): live status feed + settings-window subprocess
# ---------------------------------------------------------------------------
def _status_dict(ts: TrayState) -> dict:
"""Live status for the control panel's GET /api/status. Shape matches what
web/index.html reads (connected / state / battery / last_sync); a pure read
of TrayState scalars, safe to call from the server's request thread."""
return {
"connected": ts.state == "connected",
"state": ts.state,
"reason": ts.reason,
"battery": ts.battery_pct,
"last_sync": ts.last_sync,
}
def _panel_argv() -> list:
"""Command that launches the settings window as a SEPARATE process. Frozen:
re-invoke this same exe with --panel. Source: run panel.py by ABSOLUTE path
not ``-m daemon.panel``, which would break under autostart (cwd = System32).
panel.py rebuilds its own sys.path from __file__, so cwd doesn't matter. Kept
a separate process because pywebview wants the main thread, which pystray owns."""
if getattr(sys, "frozen", False):
return [sys.executable, "--panel"]
return [sys.executable, os.path.join(_REPO_ROOT, "daemon", "panel.py")]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# main() — tray entry (pystray on main thread, daemon loop in bg thread) # main() — tray entry (pystray on main thread, daemon loop in bg thread)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -167,6 +205,14 @@ def main() -> None:
so the module can be imported on a GTK-less Linux dev box for unit tests so the module can be imported on a GTK-less Linux dev box for unit tests
of the pure helpers (TrayState, header_text) without pystray failing. of the pure helpers (TrayState, header_text) without pystray failing.
""" """
# --panel: we ARE the settings window, launched as a child of the tray. Open
# it and exit WITHOUT touching the single-instance mutex or the BLE daemon —
# pywebview owns this process's main thread; the tray owns the other one.
if "--panel" in sys.argv:
from daemon.panel import run as run_panel
run_panel()
return
# Single-instance guard FIRST — before icons, the daemon thread, or any BLE # Single-instance guard FIRST — before icons, the daemon thread, or any BLE
# work. If another tray already owns the session mutex (e.g. ARSO restored a # work. If another tray already owns the session mutex (e.g. ARSO restored a
# console instance and the headless autostart also fired), exit silently. # console instance and the headless autostart also fired), exit silently.
@@ -190,23 +236,56 @@ 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
try: # forever (field SC: pulling the BT dongle killed the daemon and it never
_asyncio.run(daemon_main(tray_state=ts)) # came back). A clean return means stop_event was set (Quit) — stop then.
except Exception as e: # last-resort thread guard backoff = 1
import traceback while not quit_evt.is_set():
daemon_log(f"Daemon thread crashed: {e!r}") try:
daemon_log(traceback.format_exc()) _asyncio.run(daemon_main(tray_state=ts))
ts.set_error(f"daemon crashed: {type(e).__name__}") break # clean return == Quit requested
except Exception as e: # last-resort thread guard
import traceback
daemon_log(f"Daemon thread crashed: {e!r}")
daemon_log(traceback.format_exc())
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()
# --- control-panel HTTP server (one process, one exe) ---
# Serve the brand UI + REST API on 127.0.0.1 in a daemon thread, and feed it
# live BLE/daemon status. Best-effort: a server failure must never stop the
# tray itself from coming up (the watch sync is the primary job).
panel_port = None
try:
from daemon import server as panel_server
from daemon.config import DEFAULT_PORT
panel_port = DEFAULT_PORT
panel_server.set_status_provider(lambda: _status_dict(ts))
panel_server.serve_in_thread(panel_port)
daemon_log(f"Control panel: http://127.0.0.1:{panel_port}")
except Exception as e:
daemon_log(f"Control panel unavailable: {e!r}")
# Holds the settings-window child process so we don't stack windows and can
# tear it down on Quit. Mutated by _on_settings / _on_quit below.
_panel = {"proc": None}
# --- menu --- # --- menu ---
def _on_quit(icon_ref, _item) -> None: def _on_quit(icon_ref, _item) -> None:
# NEVER call ts.stop_event.set() directly from the tray thread; # NEVER call ts.stop_event.set() directly from the tray thread;
@@ -219,9 +298,20 @@ 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:
ts.loop.call_soon_threadsafe(ts.stop_event.set) try:
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)
# Close the settings window too, if the user left it open.
proc = _panel["proc"]
if proc is not None and proc.poll() is None:
try:
proc.terminate()
except Exception:
pass
icon_ref.stop() icon_ref.stop()
def _on_toggle(_icon_ref, _item) -> None: def _on_toggle(_icon_ref, _item) -> None:
@@ -234,9 +324,30 @@ def main() -> None:
autostart.enable(tray_script=os.path.abspath(__file__)) autostart.enable(tray_script=os.path.abspath(__file__))
icon.update_menu() icon.update_menu()
def _on_settings(_icon_ref, _item) -> None:
# Open the WebView2 settings window as a child process. If one is already
# alive, leave it — re-spawning would stack duplicate windows.
proc = _panel["proc"]
if proc is not None and proc.poll() is None:
return
env = dict(os.environ)
if panel_port:
env["CLAWDMETER_PANEL_PORT"] = str(panel_port)
kwargs = {}
if sys.platform == "win32":
# No phantom console window for the child (it's a GUI of its own).
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
try:
_panel["proc"] = subprocess.Popen(_panel_argv(), env=env, **kwargs)
except Exception as e:
daemon_log(f"Could not open settings window: {e!r}")
icon.menu = Menu( icon.menu = Menu(
# Non-clickable status header; text updates via update_menu() on state change. # Non-clickable status header; text updates via update_menu() on state change.
MenuItem(lambda _item: header_text(ts), None, enabled=False), MenuItem(lambda _item: header_text(ts), None, enabled=False),
# Settings = the WebView2 control panel. default=True opens it on a plain
# left-click of the tray icon (right-click still shows the full menu).
MenuItem("Settings", _on_settings, default=True),
# Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6). # Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6).
MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()), MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()),
MenuItem("Quit", _on_quit), MenuItem("Quit", _on_quit),
@@ -266,6 +377,14 @@ def main() -> None:
prev_state["state"] = current prev_state["state"] = current
prev_state["last_sync"] = last_sync prev_state["last_sync"] = last_sync
_icon.update_menu() _icon.update_menu()
# Drain daemon-queued toasts (e.g. low watch battery) — runs every
# tick regardless of state change.
try:
while True:
_title, _msg = ts.toasts.get_nowait()
_icon.notify(_msg, _title)
except queue.Empty:
pass
time.sleep(1.0) time.sleep(1.0)
# Blocks the main thread until icon.stop() is called from _on_quit. # Blocks the main thread until icon.stop() is called from _on_quit.
+272
View File
@@ -0,0 +1,272 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Clawdmeter</title>
<style>
:root{
--bg:#131211; --panel:#1f1f1e; --panel2:#232220; --text:#faf9f5; --dim:#b0aea5;
--accent:#d97757; --accent-text:#4a1b0c; --green:#788c5d; --red:#c0392b;
--border:rgba(255,255,255,.09);
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--text);font-family:"Segoe UI",system-ui,sans-serif;font-size:14px}
.app{display:flex;height:100vh}
.nav{width:188px;flex-shrink:0;background:#161514;border-right:1px solid var(--border);padding:14px 10px;display:flex;flex-direction:column;gap:3px}
.brand{display:flex;align-items:center;gap:9px;padding:4px 10px 14px;font-weight:500}
.brand .dot{width:11px;height:11px;border-radius:50%;background:var(--accent)}
.nav button.tabbtn{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;border:none;background:transparent;color:var(--dim);font-size:14px;cursor:pointer;text-align:left;width:100%}
.nav button.tabbtn:hover{background:#1c1b1a}
.nav button.tabbtn.active{background:var(--panel2);color:var(--text)}
.nav button.tabbtn.active svg{color:var(--accent)}
.nav svg{width:18px;height:18px;flex-shrink:0}
.navstatus{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px;font-size:12px;color:var(--dim)}
.led{width:8px;height:8px;border-radius:50%;background:#555;flex-shrink:0}
.led.on{background:var(--green)}
.content{flex:1;overflow-y:auto;padding:22px 26px}
h1{font-size:18px;font-weight:500;margin:0}
.sub{font-size:13px;color:var(--dim);margin:3px 0 18px}
label{font-size:12px;color:var(--dim);display:block;margin:14px 0 6px}
input[type=text],input[type=password],input[type=number],select{width:100%;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--text);font-size:14px;outline:none}
input:focus,select:focus{border-color:var(--accent)}
button.primary{background:var(--accent);color:var(--accent-text);border:none;border-radius:8px;padding:9px 18px;font-size:14px;font-weight:500;cursor:pointer}
button.ghost{background:transparent;border:1px solid var(--border);color:var(--dim);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer}
button.ghost:hover{color:var(--text);border-color:rgba(255,255,255,.2)}
.row{display:flex;gap:10px;align-items:center}
.pill{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:4px 11px;font-size:12px;margin-top:8px}
.pill.ok{background:rgba(120,140,93,.16);color:#9bb074}
.pill.err{background:rgba(192,57,43,.16);color:#e3897f}
.chips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px}
.chip{display:inline-flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px}
.chip b{cursor:pointer;color:#7d7b74;font-weight:400}
.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:10px}
.grid{display:grid;grid-template-columns:1.4fr 1fr 90px auto;gap:10px;align-items:end}
.tab{display:none}
.tab.active{display:block}
.actions{margin-top:22px;display:flex;justify-content:flex-end;gap:10px}
.statgrid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px}
.stat{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px}
.stat .k{font-size:12px;color:var(--dim)}
.stat .v{font-size:22px;font-weight:500;margin-top:4px}
.muted{color:var(--dim);font-size:12.5px}
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--panel2);border:1px solid var(--border);color:var(--text);padding:10px 16px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s}
#toast.show{opacity:1}
</style>
</head>
<body>
<div class="app">
<nav class="nav">
<div class="brand"><span class="dot"></span>Clawdmeter</div>
<button class="tabbtn active" data-tab="status"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>Status</button>
<button class="tabbtn" data-tab="ha"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 1 4 10 4 4 0 0 0-1 3H9a4 4 0 0 0-1-3 6 6 0 0 1 4-10z"/></svg>Home Assistant</button>
<button class="tabbtn" data-tab="buttons"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>Buttons</button>
<button class="tabbtn" data-tab="settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/><circle cx="9" cy="6" r="2" fill="var(--bg)"/><circle cx="15" cy="12" r="2" fill="var(--bg)"/><circle cx="8" cy="18" r="2" fill="var(--bg)"/></svg>Settings</button>
<div class="navstatus"><span class="led" id="navled"></span><span id="navstate">Connecting…</span></div>
</nav>
<main class="content">
<!-- STATUS -->
<section class="tab active" data-tab="status">
<h1>Status</h1>
<div class="sub">Watch connection and live readings</div>
<div class="statgrid">
<div class="stat"><div class="k">Watch</div><div class="v" id="st-conn"></div></div>
<div class="stat"><div class="k">Battery</div><div class="v" id="st-batt"></div></div>
<div class="stat"><div class="k">Daemon</div><div class="v" id="st-state"></div></div>
<div class="stat"><div class="k">Last update</div><div class="v" id="st-sync"></div></div>
</div>
</section>
<!-- HOME ASSISTANT -->
<section class="tab" data-tab="ha">
<h1>Home Assistant</h1>
<div class="sub">Connection &amp; devices</div>
<label>Server URL</label>
<input type="text" id="ha-url" placeholder="https://homeassistant.local:8123" autocomplete="off">
<label>Long-lived access token</label>
<div class="row">
<input type="password" id="ha-token" placeholder="Paste token" autocomplete="off">
<button class="ghost" id="ha-test">Test</button>
</div>
<div id="ha-testresult"></div>
<label>Controlled entities</label>
<div class="chips" id="ha-chips"></div>
<div class="row" style="margin-top:10px">
<select id="ha-picker"><option value="">Load devices to add…</option></select>
<button class="ghost" id="ha-load">Load devices</button>
<button class="ghost" id="ha-add">Add</button>
</div>
<div class="actions"><button class="primary" id="ha-save">Save</button></div>
</section>
<!-- BUTTONS -->
<section class="tab" data-tab="buttons">
<h1>Buttons</h1>
<div class="sub">Actions you can fire (and, later, show on the watch)</div>
<div id="btn-list"></div>
<button class="ghost" id="btn-add">+ Add button</button>
<div class="actions"><button class="primary" id="btn-save">Save</button></div>
</section>
<!-- SETTINGS -->
<section class="tab" data-tab="settings">
<h1>Settings</h1>
<div class="sub">App behaviour</div>
<label>Low-battery warning at (%)</label>
<input type="number" id="set-lowbatt" min="1" max="100" step="1">
<label>Watch BLE address <span class="muted">(optional — for bonded, non-advertising watches)</span></label>
<input type="text" id="set-addr" placeholder="44:1B:F6:85:1E:51" autocomplete="off">
<div class="actions"><button class="primary" id="set-save">Save</button></div>
</section>
</main>
</div>
<div id="toast"></div>
<script>
const MASK = "********";
let cfg = null;
let entities = []; // current controlled entities (chips)
let lights = []; // available light.* from HA
let buttons = []; // current button defs
const $ = s => document.querySelector(s);
async function api(path, method="GET", body=null){
const opt = {method, headers:{}};
if(body){opt.headers["Content-Type"]="application/json"; opt.body=JSON.stringify(body);}
const r = await fetch(path, opt);
if(!r.ok) throw new Error("HTTP "+r.status);
return r.json();
}
function toast(msg){const t=$("#toast");t.textContent=msg;t.classList.add("show");clearTimeout(t._h);t._h=setTimeout(()=>t.classList.remove("show"),1800);}
// ---- tabs ----
document.querySelectorAll(".tabbtn").forEach(b=>b.onclick=()=>{
document.querySelectorAll(".tabbtn").forEach(x=>x.classList.toggle("active",x===b));
const id=b.dataset.tab;
document.querySelectorAll(".tab").forEach(s=>s.classList.toggle("active",s.dataset.tab===id));
});
// ---- load ----
async function loadConfig(){
cfg = await api("/api/config");
$("#ha-url").value = cfg.ha.url || "";
$("#ha-token").value = cfg.ha.token || ""; // server sends MASK when a token is set
entities = (cfg.ha.entities||[]).slice();
buttons = (cfg.buttons||[]).map(b=>Object.assign({}, b));
$("#set-lowbatt").value = cfg.settings.low_battery_pct ?? 15;
$("#set-addr").value = cfg.settings.device_address || "";
renderChips(); renderButtons();
}
// ---- HA ----
function renderChips(){
const c=$("#ha-chips"); c.innerHTML="";
if(!entities.length){c.innerHTML='<span class="muted">No devices yet — add some below.</span>';}
entities.forEach(e=>{
const s=document.createElement("span"); s.className="chip";
s.innerHTML = e+' <b title="Remove"></b>';
s.querySelector("b").onclick=()=>{entities=entities.filter(x=>x!==e);renderChips();renderButtons();};
c.appendChild(s);
});
}
$("#ha-test").onclick = async ()=>{
const box=$("#ha-testresult"); box.innerHTML='<span class="muted">Testing…</span>';
try{
const r = await api("/api/ha/test","POST",{url:$("#ha-url").value.trim(), token:$("#ha-token").value});
box.innerHTML = r.ok ? '<span class="pill ok">✓ '+(r.message||"Connected")+'</span>'
: '<span class="pill err">✕ '+(r.error||"Failed")+'</span>';
}catch(e){box.innerHTML='<span class="pill err">✕ '+e.message+'</span>';}
};
$("#ha-load").onclick = async ()=>{
// Persist url+token first so the server can query HA with them.
await saveHA(true);
const r = await api("/api/ha/entities");
lights = r.entities||[];
const sel=$("#ha-picker"); sel.innerHTML="";
if(!lights.length){sel.innerHTML='<option value="">'+(r.error||"No lights found")+'</option>';return;}
sel.appendChild(new Option("Select a device…",""));
lights.forEach(l=>sel.appendChild(new Option(l,l)));
toast(lights.length+" devices loaded");
};
$("#ha-add").onclick = ()=>{
const v=$("#ha-picker").value; if(!v) return;
if(!entities.includes(v)){entities.push(v);renderChips();renderButtons();}
};
async function saveHA(silent){
const ha={url:$("#ha-url").value.trim(), token:$("#ha-token").value, entities};
cfg = await api("/api/config","PUT",{ha});
$("#ha-token").value = cfg.ha.token; // re-mask
if(!silent) toast("Home Assistant saved");
}
$("#ha-save").onclick = ()=>saveHA(false).catch(e=>toast("Error: "+e.message));
// ---- Buttons ----
const ACTIONS=[["toggle","Toggle on/off"],["bri","Set brightness %"],["ct","Set color temp K"]];
function renderButtons(){
const list=$("#btn-list"); list.innerHTML="";
if(!buttons.length){list.insertAdjacentHTML("beforeend",'<div class="muted" style="margin-bottom:10px">No buttons yet.</div>');}
buttons.forEach((b,i)=>{
const card=document.createElement("div"); card.className="card";
const entOpts = entities.map(e=>`<option value="${e}" ${e===b.entity?"selected":""}>${e}</option>`).join("");
const actOpts = ACTIONS.map(([v,l])=>`<option value="${v}" ${v===b.action?"selected":""}>${l}</option>`).join("");
const showVal = b.action==="bri"||b.action==="ct";
card.innerHTML=`
<div class="grid">
<div><label>Label</label><input type="text" data-i="${i}" data-f="label" value="${(b.label||"").replace(/"/g,'&quot;')}"></div>
<div><label>Device</label><select data-i="${i}" data-f="entity">${entOpts||'<option value="">— add a device first —</option>'}</select></div>
<div><label>Action</label><select data-i="${i}" data-f="action">${actOpts}</select></div>
<div><button class="ghost" data-del="${i}">Remove</button></div>
</div>
<div data-val="${i}" style="${showVal?"":"display:none"};margin-top:8px">
<label>${b.action==="ct"?"Kelvin (20006500)":"Brightness %"}</label>
<input type="number" data-i="${i}" data-f="value" value="${b.value??(b.action==="ct"?3000:60)}" style="max-width:160px">
</div>`;
list.appendChild(card);
});
list.querySelectorAll("[data-f]").forEach(el=>el.oninput=()=>{
const i=+el.dataset.i, f=el.dataset.f;
buttons[i][f] = f==="value" ? +el.value : el.value;
if(f==="action"){ buttons[i].icon=el.value; renderButtons(); }
});
list.querySelectorAll("[data-del]").forEach(el=>el.onclick=()=>{buttons.splice(+el.dataset.del,1);renderButtons();});
}
$("#btn-add").onclick = ()=>{
buttons.push({id:"b"+Date.now().toString(36), label:"Light", icon:"toggle",
action:"toggle", entity:entities[0]||"", value:null});
renderButtons();
};
$("#btn-save").onclick = async ()=>{
try{ cfg = await api("/api/config","PUT",{buttons}); toast("Buttons saved"); }
catch(e){ toast("Error: "+e.message); }
};
// ---- Settings ----
$("#set-save").onclick = async ()=>{
try{
cfg = await api("/api/config","PUT",{settings:{
low_battery_pct:+$("#set-lowbatt").value, device_address:$("#set-addr").value.trim()}});
toast("Settings saved");
}catch(e){ toast("Error: "+e.message); }
};
// ---- Status poll ----
async function pollStatus(){
try{
const s = await api("/api/status");
const conn = s.connected;
$("#navled").classList.toggle("on",conn);
$("#navstate").textContent = conn?"Watch connected":(s.state==="unknown"?"Daemon off":"Scanning…");
$("#st-conn").textContent = conn?"Connected":"Disconnected";
$("#st-batt").textContent = (s.battery==null)?"—":s.battery+"%";
$("#st-state").textContent = s.state||"—";
$("#st-sync").textContent = s.last_sync? new Date(s.last_sync*1000).toLocaleTimeString():"—";
}catch(e){ $("#navstate").textContent="Panel only"; }
}
loadConfig().catch(e=>toast("Load failed: "+e.message));
pollStatus(); setInterval(pollStatus,3000);
</script>
</body>
</html>
+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.
+20
View File
@@ -42,6 +42,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. Shared ui.cpp needs these on every board.
-DLV_FONT_MONTSERRAT_20=1
-DLV_FONT_MONTSERRAT_28=1
lib_deps = lib_deps =
moononournation/GFX Library for Arduino@^1.5.6 moononournation/GFX Library for Arduino@^1.5.6
@@ -99,6 +104,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. Shared ui.cpp needs these on every board.
-DLV_FONT_MONTSERRAT_20=1
-DLV_FONT_MONTSERRAT_28=1
lib_deps = lib_deps =
; 1.6.4+ ships Arduino_SH8601 in mainline ; 1.6.4+ ships Arduino_SH8601 in mainline
@@ -157,6 +167,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
@@ -225,6 +240,11 @@ build_flags =
; 480×480) which doesn't fit in C6 internal SRAM. send_screenshot ; 480×480) which doesn't fit in C6 internal SRAM. send_screenshot
; prints SCREENSHOT_UNSUPPORTED on this board. ; prints SCREENSHOT_UNSUPPORTED on this board.
-DLV_USE_SNAPSHOT=0 -DLV_USE_SNAPSHOT=0
; 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. Shared ui.cpp needs these on every board.
-DLV_FONT_MONTSERRAT_20=1
-DLV_FONT_MONTSERRAT_28=1
lib_deps = lib_deps =
; this board uses the CO5300 (same controller as the S3 2.16); Arduino_CO5300 ; this board uses the CO5300 (same controller as the S3 2.16); Arduino_CO5300
+55
View File
@@ -0,0 +1,55 @@
#include "battery_est.h"
#include <Arduino.h>
// Anchor-based, not a ring buffer (cf. usage_rate.cpp): we remember (time, pct)
// at the moment discharge began and project from the average rate since then.
// rate = (anchor_pct - pct_now) / minutes_elapsed
// minutes_left = pct_now / rate
// A long baseline beats a short ring for a signal that only ticks every few
// minutes — the estimate just keeps tightening as the session runs. We withhold
// a number until the cell has dropped a meaningful amount over a few minutes, so
// the first figure isn't built on fuel-gauge settling noise right after boot.
#define EST_MIN_DROP_PCT 3 // need >= 3% drained since the anchor
#define EST_MIN_ELAPSED_MS 300000UL // ...over at least 5 minutes
#define EST_MAX_MINUTES (100 * 60)
static bool have_anchor = false;
static uint32_t anchor_ms = 0;
static int anchor_pct = -1;
static int last_pct = -1;
static bool last_charging = false;
void battery_est_update(int percent, bool charging) {
last_charging = charging;
last_pct = percent;
if (charging || percent < 0) {
// On USB / charging / no reading the estimate is meaningless. Drop the
// anchor so the next discharge starts from a fresh baseline.
have_anchor = false;
return;
}
// Discharging. Start an anchor, or re-anchor if the gauge ticked UP (it
// relaxes upward when load drops) so we never compute a negative rate.
if (!have_anchor || percent > anchor_pct) {
have_anchor = true;
anchor_ms = millis();
anchor_pct = percent;
}
}
int battery_est_minutes(void) {
if (last_charging || last_pct < 0) return -2;
if (!have_anchor) return -1;
int drop = anchor_pct - last_pct;
uint32_t dt = millis() - anchor_ms;
if (drop < EST_MIN_DROP_PCT || dt < EST_MIN_ELAPSED_MS) return -1;
// Minutes each 1% has been taking, extrapolated across the remaining pct.
float minutes_per_pct = (float)dt / 60000.0f / (float)drop;
float mins = (float)last_pct * minutes_per_pct;
if (mins < 0) return -1;
if (mins > EST_MAX_MINUTES) mins = EST_MAX_MINUTES;
return (int)(mins + 0.5f);
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
// Rough "time left on battery" estimator. Watches the AXP fuel-gauge percentage
// while discharging and projects minutes-to-empty from the average drain rate
// since discharge began. Coarse by design — the gauge moves in slow 1% steps —
// so the UI shows it as "~Xh Ym left", emphasis on the ~.
//
// Feed it the latest percent + charging flag whenever a fresh battery reading
// lands (the daemon-independent battery poll in main.cpp).
void battery_est_update(int percent, bool charging);
// Estimated minutes remaining, or:
// -2 charging / on USB (no estimate to give)
// -1 not enough discharge history yet ("estimating…")
int battery_est_minutes(void);
+20 -2
View File
@@ -10,6 +10,7 @@
#define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here #define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here
#define TX_CHAR_UUID "4c41555a-4465-7669-6365-000000000003" // device ack/nack notifies #define TX_CHAR_UUID "4c41555a-4465-7669-6365-000000000003" // device ack/nack notifies
#define REQ_CHAR_UUID "4c41555a-4465-7669-6365-000000000004" // device-initiated refresh request #define REQ_CHAR_UUID "4c41555a-4465-7669-6365-000000000004" // device-initiated refresh request
#define CMD_CHAR_UUID "4c41555a-4465-7669-6365-000000000005" // device → host command (Phase 6)
#define BLE_BUF_SIZE 512 #define BLE_BUF_SIZE 512
@@ -46,10 +47,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
}; };
@@ -60,6 +61,7 @@ static NimBLECharacteristic* input_kbd = nullptr;
static NimBLECharacteristic* tx_char = nullptr; static NimBLECharacteristic* tx_char = nullptr;
static NimBLECharacteristic* rx_char = nullptr; static NimBLECharacteristic* rx_char = nullptr;
static NimBLECharacteristic* req_char = nullptr; static NimBLECharacteristic* req_char = nullptr;
static NimBLECharacteristic* cmd_char = nullptr;
static ble_state_t state = BLE_STATE_INIT; static ble_state_t state = BLE_STATE_INIT;
static bool need_advertise = false; static bool need_advertise = false;
@@ -201,6 +203,14 @@ void ble_init(void) {
static ReqCallbacks reqCb; static ReqCallbacks reqCb;
req_char->setCallbacks(&reqCb); req_char->setCallbacks(&reqCb);
// Watch → host command channel (Phase 6). The daemon subscribes; the watch
// notifies a short JSON command which the daemon maps to a Home Assistant
// service call. Notify-only, same direction as the refresh char.
cmd_char = svc->createCharacteristic(
CMD_CHAR_UUID,
NIMBLE_PROPERTY::NOTIFY
);
svc->start(); svc->start();
server->start(); server->start();
start_advertising(); start_advertising();
@@ -272,6 +282,14 @@ void ble_request_refresh(void) {
} }
} }
void ble_send_command(const char* json) {
if (state == BLE_STATE_CONNECTED && cmd_char) {
cmd_char->setValue(json);
cmd_char->notify();
Serial.printf("BLE: command sent %s\n", json);
}
}
void ble_keyboard_press(uint8_t key, uint8_t modifier) { void ble_keyboard_press(uint8_t key, uint8_t modifier) {
if (state != BLE_STATE_CONNECTED || !input_kbd) return; if (state != BLE_STATE_CONNECTED || !input_kbd) return;
// HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6] // HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6]
+5
View File
@@ -21,6 +21,11 @@ void ble_send_ack(void);
void ble_send_nack(void); void ble_send_nack(void);
void ble_request_refresh(void); void ble_request_refresh(void);
// Watch → PC command channel (Phase 6). Notifies a short JSON command (e.g.
// {"cmd":"toggle"}) to the host daemon on the CMD characteristic; the daemon
// turns it into a Home Assistant service call. No-op unless connected.
void ble_send_command(const char* json);
// BLE HID keyboard // BLE HID keyboard
void ble_keyboard_press(uint8_t key, uint8_t modifier); void ble_keyboard_press(uint8_t key, uint8_t modifier);
void ble_keyboard_release(void); void ble_keyboard_release(void);
+1
View File
@@ -7,3 +7,4 @@
void imu_hal_init(void) {} void imu_hal_init(void) {}
void imu_hal_tick(void) {} void imu_hal_tick(void) {}
uint8_t imu_hal_rotation_quadrant(void) { return 0; } uint8_t imu_hal_rotation_quadrant(void) { return 0; }
bool imu_hal_read_accel(float* x, float* y, float* z) { return false; }
+2
View File
@@ -17,8 +17,10 @@ void power_hal_init(void) {}
void power_hal_tick(void) {} void power_hal_tick(void) {}
int power_hal_battery_pct(void) { return -1; } int power_hal_battery_pct(void) { return -1; }
int power_hal_battery_mv(void) { return 0; } // 0 = no battery / unsupported
bool power_hal_is_charging(void) { return false; } bool power_hal_is_charging(void) { return false; }
bool power_hal_is_vbus_in(void) { return false; } bool power_hal_is_vbus_in(void) { return false; }
void power_hal_shutdown(void) {} // no controllable PMU
bool power_hal_pwr_pressed(void) { return false; } bool power_hal_pwr_pressed(void) { return false; }
// Hold-to-pair gesture signals. Mirror the 216 (PMU PKEY long/positive IRQs) // Hold-to-pair gesture signals. Mirror the 216 (PMU PKEY long/positive IRQs)
// or the 1.8" (software hold-timing off a polled GPIO) port. Stub = no gesture. // or the 1.8" (software hold-timing off a polled GPIO) port. Stub = no gesture.
@@ -23,3 +23,7 @@ void imu_hal_tick(void) {
} }
uint8_t imu_hal_rotation_quadrant(void) { return 0; } uint8_t imu_hal_rotation_quadrant(void) { return 0; }
// Accelerometer is not configured on this board (only the bus is brought up),
// so there is no tilt data to report.
bool imu_hal_read_accel(float* x, float* y, float* z) { return false; }
@@ -23,6 +23,7 @@
static XPowersPMU pmu; static XPowersPMU pmu;
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -49,6 +50,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -62,6 +64,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; last_pwr_ms = now;
@@ -83,9 +86,12 @@ void power_hal_tick(void) {
} }
int power_hal_battery_pct(void) { return cached_pct; } int power_hal_battery_pct(void) { return cached_pct; }
int power_hal_battery_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return false;
@@ -5,21 +5,34 @@
#include <SensorQMI8658.hpp> #include <SensorQMI8658.hpp>
// AMOLED-2.06 ships with QMI8658 populated, but the panel is non-square and // AMOLED-2.06 ships with QMI8658 populated, but the panel is non-square and
// mounts in a fixed orientation, so rotation is disabled. We initialize the // mounts in a fixed orientation, so screen rotation is disabled and the quadrant
// device anyway to keep the shared I2C bus healthy, but always report 0. // is always 0. The accelerometer IS enabled, though: the tilt-dimmer screen
// reads raw g-vectors via imu_hal_read_accel() to derive wrist pitch/roll.
static SensorQMI8658 imu; static SensorQMI8658 imu;
static bool imu_ok = false;
void imu_hal_init(void) { void imu_hal_init(void) {
if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) { if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) {
Serial.println("QMI8658 init failed"); Serial.println("QMI8658 init failed");
return; return;
} }
Serial.println("QMI8658 init OK (rotation disabled on this board)"); // 2g range is plenty for static tilt sensing (gravity is 1g); 62.5Hz keeps
// the dial responsive without flooding the I2C bus. LPF on (default) damps
// hand jitter so the brightness rate doesn't twitch.
imu.configAccelerometer(SensorQMI8658::ACC_RANGE_2G, SensorQMI8658::ACC_ODR_62_5Hz);
imu.enableAccelerometer();
imu_ok = true;
Serial.println("QMI8658 init OK (accel enabled, screen rotation disabled)");
} }
void imu_hal_tick(void) { void imu_hal_tick(void) {
// No-op — rotation is disabled. // No-op — screen rotation is disabled; accel is polled on demand.
} }
uint8_t imu_hal_rotation_quadrant(void) { return 0; } uint8_t imu_hal_rotation_quadrant(void) { return 0; }
bool imu_hal_read_accel(float* x, float* y, float* z) {
if (!imu_ok || !imu.getDataReady()) return false;
return imu.getAccelerometer(*x, *y, *z);
}
@@ -26,6 +26,7 @@
static XPowersPMU pmu; static XPowersPMU pmu;
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -66,6 +67,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -79,6 +81,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; last_pwr_ms = now;
@@ -100,9 +103,12 @@ void power_hal_tick(void) {
} }
int power_hal_battery_pct(void) { return cached_pct; } int power_hal_battery_pct(void) { return cached_pct; }
int power_hal_battery_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return false;
@@ -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;
@@ -64,3 +64,8 @@ void imu_hal_tick(void) {
} }
uint8_t imu_hal_rotation_quadrant(void) { return current_rotation; } uint8_t imu_hal_rotation_quadrant(void) { return current_rotation; }
bool imu_hal_read_accel(float* x, float* y, float* z) {
if (!imu_ok || !imu.getDataReady()) return false;
return imu.getAccelerometer(*x, *y, *z);
}
@@ -16,6 +16,7 @@
static XPowersPMU pmu; static XPowersPMU pmu;
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -49,6 +50,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -62,6 +64,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; last_pwr_ms = now;
@@ -74,9 +77,12 @@ void power_hal_tick(void) {
} }
int power_hal_battery_pct(void) { return cached_pct; } int power_hal_battery_pct(void) { return cached_pct; }
int power_hal_battery_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return false;
@@ -24,3 +24,7 @@ void imu_hal_tick(void) {
} }
uint8_t imu_hal_rotation_quadrant(void) { return 0; } uint8_t imu_hal_rotation_quadrant(void) { return 0; }
// Accelerometer is not configured on this board (only the bus is brought up),
// so there is no tilt data to report.
bool imu_hal_read_accel(float* x, float* y, float* z) { return false; }
@@ -17,6 +17,7 @@ extern XPowersPMU board_pmu;
#define pmu board_pmu #define pmu board_pmu
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -50,6 +51,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -63,6 +65,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; last_pwr_ms = now;
@@ -75,9 +78,12 @@ void power_hal_tick(void) {
} }
int power_hal_battery_pct(void) { return cached_pct; } int power_hal_battery_pct(void) { return cached_pct; }
int power_hal_battery_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return 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
+6
View File
@@ -9,3 +9,9 @@
void imu_hal_init(void); void imu_hal_init(void);
void imu_hal_tick(void); void imu_hal_tick(void);
uint8_t imu_hal_rotation_quadrant(void); uint8_t imu_hal_rotation_quadrant(void);
// Raw accelerometer read in g-units (gravity = 1.0). Used by the tilt-dimmer
// screen to derive wrist pitch/roll. Returns true and fills x/y/z when a fresh
// sample is available; returns false (leaving the outputs untouched) on boards
// without an accelerometer or when no new data is ready yet.
bool imu_hal_read_accel(float* x, float* y, float* z);
+5
View File
@@ -12,9 +12,14 @@ void power_hal_init(void);
void power_hal_tick(void); void power_hal_tick(void);
int power_hal_battery_pct(void); // 0..100, or -1 if no battery (see BoardCaps.has_battery) int power_hal_battery_pct(void); // 0..100, or -1 if no battery (see BoardCaps.has_battery)
int power_hal_battery_mv(void); // battery voltage in mV, or 0 if no battery / unsupported
bool power_hal_is_charging(void); bool power_hal_is_charging(void);
bool power_hal_is_vbus_in(void); // USB cable present (true even without a battery) bool power_hal_is_vbus_in(void); // USB cable present (true even without a battery)
// Power the device fully off (PMU shutdown). Used by the low-voltage protective
// cutoff in main.cpp. No-op on boards without a controllable PMU.
void power_hal_shutdown(void);
// Edge-triggered: returns true once per PWR short-press, then clears. // Edge-triggered: returns true once per PWR short-press, then clears.
bool power_hal_pwr_pressed(void); bool power_hal_pwr_pressed(void);
+138 -14
View File
@@ -7,8 +7,10 @@
#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 "battery_est.h"
#include "idle.h" #include "idle.h"
#include "idle_cfg.h" #include "idle_cfg.h"
#include "brightness.h" #include "brightness.h"
@@ -22,6 +24,21 @@
static UsageData usage = {}; static UsageData usage = {};
// ---- Low-voltage protective cutoff ----
// Below LOW_V_CUTOFF_MV on battery power (USB absent), the firmware warns on
// screen for LOW_V_WARN_MS then powers the PMU fully off, so the cell isn't
// driven into deep over-discharge. The reading must stay below for
// LOW_V_SUSTAIN_MS first: the cell voltage sags under the BLE + AMOLED load, so
// a momentary dip must not trigger a shutdown.
//
// 3000 mV (3.0 V) is a gentle Li-ion floor — well clear of the deep
// over-discharge zone (~2.52.8 V). Mirrored as the "Auto-off below 3.0 V"
// note on the battery screen (ui.cpp). Raise toward 3300 mV to be even kinder
// to the cell, or down to 2800 to squeeze out the last drops.
#define LOW_V_CUTOFF_MV 3000
#define LOW_V_SUSTAIN_MS 6000UL
#define LOW_V_WARN_MS 5000UL
// ---- LVGL draw buffers (partial render mode) ---- // ---- LVGL draw buffers (partial render mode) ----
// PSRAM-equipped boards (S3) can comfortably hold larger strips. PSRAM-free // PSRAM-equipped boards (S3) can comfortably hold larger strips. PSRAM-free
// boards (e.g. ESP32-C6) allocate from internal SRAM, so we shrink the strip // boards (e.g. ESP32-C6) allocate from internal SRAM, so we shrink the strip
@@ -111,7 +128,44 @@ 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;
// Dynamic Home buttons (Phase 7 M2) — optional "btns" array of labels pushed
// by the daemon from the desktop config (only in the ~60s heartbeat write).
// Absent (e.g. the frequent now-playing-only writes) => leave the current
// buttons untouched; present => replace the whole set. The label strings live
// in `doc`, which is valid until this function returns, and ui_set_buttons
// copies them immediately.
JsonArray btns = doc["btns"].as<JsonArray>();
if (!btns.isNull()) {
const char* labels[UI_MAX_BUTTONS];
int n = 0;
for (JsonVariant v : btns) {
if (n >= UI_MAX_BUTTONS) break;
labels[n++] = v.as<const char*>();
}
ui_set_buttons(labels, n);
}
// Tilt-dimmer snapshot (Phase 6 step 3 / M3) — optional "dim" object carrying
// the controlled light's live state so the dial seeds from reality. Pushed on
// the ~60s heartbeat and on demand when the watch opens the Dimmer screen.
JsonObject dim = doc["dim"].as<JsonObject>();
if (!dim.isNull()) {
bool on = dim["on"] | 0;
int bri = dim["bri"] | -1; // -1 = unknown (light off)
int ct = dim["ct"] | -1;
int mink = dim["mink"] | 2000;
int maxk = dim["maxk"] | 6500;
ui_dimmer_set_snapshot(on, bri, ct, mink, maxk);
}
return true; return true;
} }
@@ -180,7 +234,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();
@@ -219,7 +273,7 @@ void setup() {
ui_init(); ui_init();
ui_update_ble_status(ble_get_state(), ble_get_device_name(), ble_get_mac_address()); ui_update_ble_status(ble_get_state(), ble_get_device_name(), ble_get_mac_address());
ui_update_battery(power_hal_battery_pct(), power_hal_is_charging()); ui_update_battery(power_hal_battery_pct(), power_hal_battery_mv(), -1, power_hal_is_charging());
ui_show_screen(SCREEN_SPLASH); ui_show_screen(SCREEN_SPLASH);
Serial.printf("Dashboard ready (%s, %dx%d), waiting for data on BLE...\n", Serial.printf("Dashboard ready (%s, %dx%d), waiting for data on BLE...\n",
@@ -299,14 +353,22 @@ void loop() {
{ {
static bool primary_was = false; static bool primary_was = false;
static bool primary_wake_swallowed = false; static bool primary_wake_swallowed = false;
static bool primary_was_dimmer = false; // press consumed by the dimmer (no HID)
bool primary_now = input_hal_is_held(INPUT_BTN_PRIMARY); bool primary_now = input_hal_is_held(INPUT_BTN_PRIMARY);
if (primary_now != primary_was) { if (primary_now != primary_was) {
if (primary_now) { if (primary_now) {
if (idle_consume_wake_press()) primary_wake_swallowed = true; if (idle_consume_wake_press()) primary_wake_swallowed = true;
else ble_keyboard_press(0x2C, 0); // HID Space, no mods else if (ui_get_current_screen() == SCREEN_DIMMER) {
ui_dimmer_arm(); // BOOT arms the tilt-dimmer (no HID here)
primary_was_dimmer = true;
} else {
ble_keyboard_press(0x2C, 0); // HID Space, no mods
primary_was_dimmer = false;
}
} else { } else {
if (primary_wake_swallowed) primary_wake_swallowed = false; if (primary_wake_swallowed) primary_wake_swallowed = false;
else ble_keyboard_release(); else if (primary_was_dimmer) primary_was_dimmer = false;
else ble_keyboard_release();
} }
primary_was = primary_now; primary_was = primary_now;
} }
@@ -329,30 +391,92 @@ void loop() {
if (power_hal_pwr_pressed()) { if (power_hal_pwr_pressed()) {
if (!idle_consume_wake_press()) { if (!idle_consume_wake_press()) {
// On splash: cycle animations. On the usage view: cycle // On splash: cycle animations. On the dimmer: switch the
// screen brightness (single non-splash view, no more screens). // controlled parameter (brightness ⇄ temp). Elsewhere: cycle
if (ui_get_current_screen() == SCREEN_SPLASH) splash_next(); // screen brightness.
else brightness_cycle(); screen_t cs = ui_get_current_screen();
if (cs == SCREEN_SPLASH) splash_next();
else if (cs == SCREEN_DIMMER) ui_dimmer_switch_param();
else brightness_cycle();
} }
} }
pair_tick(); pair_tick();
} }
// Tilt-dimmer control loop (no-op unless the Dimmer screen is armed). Keep
// the panel awake while adjusting so the idle timer can't sleep mid-tilt.
ui_dimmer_tick();
if (ui_dimmer_is_armed()) idle_note_activity();
ble_state_t bs = ble_get_state(); ble_state_t bs = ble_get_state();
if (bs != last_ble_state) { if (bs != last_ble_state) {
last_ble_state = bs; last_ble_state = bs;
ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address()); ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address());
} }
static int last_pct = -2; // ---- Battery telemetry → UI + time-left estimate ----
static bool last_charging = false; static int last_pct = -2;
static int last_mv_bucket = -2;
static bool last_charging = false;
static uint32_t last_bat_ui_ms = 0;
int pct = power_hal_battery_pct(); int pct = power_hal_battery_pct();
int mv = power_hal_battery_mv();
bool charging = power_hal_is_charging(); bool charging = power_hal_is_charging();
if (pct != last_pct || charging != last_charging) { int mv_bucket = (mv <= 0) ? -1 : (mv / 20); // 20 mV buckets — ignore sub-bucket jitter
last_pct = pct; uint32_t now_ms = millis();
last_charging = charging; bool bat_changed = (pct != last_pct) || (charging != last_charging) || (mv_bucket != last_mv_bucket);
ui_update_battery(pct, charging); bool bat_periodic = (now_ms - last_bat_ui_ms >= 2000); // keep "time left" counting down live
if (bat_changed || bat_periodic) {
last_pct = pct;
last_mv_bucket = mv_bucket;
last_charging = charging;
last_bat_ui_ms = now_ms;
battery_est_update(pct, charging);
ui_update_battery(pct, mv, battery_est_minutes(), charging);
}
// Push battery state to the host (~60s, plus on charge-state flip) so the PC
// app can warn on low charge. Reuses the watch→PC command char (…0005), so no
// new GATT characteristic / re-pair. ble_send_command no-ops if disconnected.
{
static uint32_t last_bat_tx_ms = 0;
static bool last_tx_charging = false;
if (mv > 0 && (now_ms - last_bat_tx_ms >= 60000 || charging != last_tx_charging)) {
last_bat_tx_ms = now_ms;
last_tx_charging = charging;
char bm[64];
snprintf(bm, sizeof bm, "{\"bat\":%d,\"mv\":%d,\"chg\":%d}",
pct, mv, charging ? 1 : 0);
ble_send_command(bm);
}
}
// ---- Low-voltage protective cutoff ----
// Sustained below the floor on battery power → warn, then power fully off.
{
static uint32_t low_v_since = 0;
bool batt_present = (mv > 0); // 0 == no battery / not measurable
if (batt_present && !power_hal_is_vbus_in() && mv < LOW_V_CUTOFF_MV) {
if (low_v_since == 0) low_v_since = now_ms;
if (now_ms - low_v_since >= LOW_V_SUSTAIN_MS) {
Serial.printf("Low battery %d mV < %d mV - protective shutdown\n", mv, LOW_V_CUTOFF_MV);
idle_note_activity(); // wake the panel so the warning is visible
ui_show_low_battery();
uint32_t t0 = millis();
while (millis() - t0 < LOW_V_WARN_MS) { // pump LVGL so the warning paints + fades in
idle_tick(); // drive the wake-from-sleep brightness ramp
lv_timer_handler();
if (!idle_is_asleep()) display_hal_tick();
delay(20);
}
power_hal_shutdown();
delay(3000); // let the rail collapse
low_v_since = 0; // bench-supply fallback: don't spin if still powered
}
} else {
low_v_since = 0;
}
} }
check_serial_cmd(); check_serial_cmd();
+1088 -18
View File
File diff suppressed because it is too large Load Diff
+40 -3
View File
@@ -3,8 +3,16 @@
#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_DIMMER, // tilt-to-dim light control (Phase 6 step 3 / M3)
SCREEN_BLUETOOTH, // BLE connection info
SCREEN_BATTERY, // battery detail (voltage + time left) — opened by tapping the battery icon
SCREEN_COUNT, SCREEN_COUNT,
}; };
@@ -15,4 +23,33 @@ void ui_show_screen(screen_t screen);
void ui_toggle_splash(void); void ui_toggle_splash(void);
screen_t ui_get_current_screen(void); screen_t ui_get_current_screen(void);
void ui_update_ble_status(ble_state_t state, const char* name, const char* mac); void ui_update_ble_status(ble_state_t state, const char* name, const char* mac);
void ui_update_battery(int percent, bool charging);
// minutes_left: >=0 estimated minutes remaining, -1 estimating, -2 charging.
void ui_update_battery(int percent, int voltage_mv, int minutes_left, bool charging);
// Full-screen "battery critically low" overlay shown by the protective cutoff in
// main.cpp just before power_hal_shutdown(). Drawn on the top layer so it covers
// whatever screen was active.
void ui_show_low_battery(void);
// Dynamic Home-control buttons (Phase 7 M2). The desktop config defines each
// button's action/entity; the watch stays dumb — it only renders the labels and
// reports the pressed index (the daemon maps index → action). Labels arrive in
// the RX payload's optional "btns" array. Cap kept small for the 2-column grid.
#define UI_MAX_BUTTONS 6
void ui_set_buttons(const char* const* labels, int count);
// Tilt-dimmer (Phase 6 step 3 / M3). On the Dimmer screen the watch reads its
// IMU and ramps the brightness OR color temperature of the configured light
// while "armed", sending absolute {"cmd":"bri"|"ct","v":..} to the daemon
// (which maps it to the first HA entity). The wrist acts as a joystick: tilt
// past the deadzone to change the value at a rate proportional to the tilt,
// hold neutral to stop. BOOT arms and captures the neutral pose; PWR switches
// the controlled parameter; a tap (or a few seconds of stillness) commits and
// locks. The daemon pushes the light's current state as an RX "dim" object so
// the dial starts from reality.
void ui_dimmer_set_snapshot(bool on, int bri_pct, int ct_kelvin, int min_k, int max_k);
void ui_dimmer_arm(void); // BOOT press on the Dimmer screen
void ui_dimmer_switch_param(void); // PWR press on the Dimmer screen
void ui_dimmer_tick(void); // called every loop; no-op unless armed
bool ui_dimmer_is_armed(void); // main.cpp keeps the panel awake while true
+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)")