Author SHA1 Message Date
wenilandClaude Opus 4.8 d1cc4670b1 v3 M2+: Codex live usage endpoint (fresh) with rollout fallback
Adopt the live ChatGPT usage API (found via github.com/rygel/AIUsageTracker, MIT)
so Codex % is fresh every poll instead of only as fresh as the last local
rollout write:

    GET https://chatgpt.com/backend-api/wham/usage
    Authorization: Bearer <access_token from $CODEX_HOME/auth.json tokens>
    ChatGPT-Account-Id: <account_id>
    -> rate_limit.primary_window / secondary_window {used_percent, reset_at}, plan_type

poll() now tries the live endpoint first and falls back to the rollout snapshot
when the token is missing/expired/offline (we read the current OAuth token but
don't refresh it). Live-first fixes the staleness of the rollout-only approach:
verified live s=1% (5h, resets 300m), w=0% (7d, resets 10080m), st="Plus" — a full
fresh window vs the old stale past-reset heuristic.

+4 tests (live preferred, limit_reached->limited, 401->rollout fallback, no-auth
->rollout). 126 passed, 2 Linux-only failures (baseline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 09:02:12 +03:00
wenilandClaude Opus 4.8 4eeb6e242b v3 M3: finalize z.ai against the real usage endpoint (monitor/quota/limit)
Probing a live GLM Coding Plan key showed the Anthropic-compat /v1/messages
response carries NO rate-limit headers. z.ai instead exposes a dedicated status
endpoint (found via github.com/rygel/AIUsageTracker):

    GET https://api.z.ai/api/monitor/usage/quota/limit
    Authorization: <raw key>   (no "Bearer")   Accept-Language: en-US,en
    -> data.limits[] of TOKENS_LIMIT windows {percentage 0-100, nextResetTime ms, unit,number}
       + data.level (plan tier)

ZaiProvider now GETs that: the shortest TOKENS_LIMIT window -> the 5h bar (s/sr),
the longest -> the weekly bar (w/wr); the monthly TIME_LIMIT (web-tool quota) is
ignored; level -> status ("Lite"/"Pro"/…), any window at 100% -> "limited". It's
a status GET, so it does NOT spend the prompt-metered plan — no self-throttle
needed, polls on the normal cadence. Monitor URL derives from the configured
base host.

Verified live: s=1% (5h, resets ~4.9h), w=2% (weekly, resets ~6.5d), st="Lite".
test_zai rewritten for the JSON envelope (9 tests). probe_zai.py repointed to the
monitor endpoint. 122 passed, 2 Linux-only failures (baseline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 08:53:32 +03:00
wenilandClaude Opus 4.8 ac0742d92c v3 M3: z.ai (GLM) provider — Anthropic-compatible poll from a pasted API key
ZaiProvider replaces the zai stub. It reaches z.ai's Anthropic-compatible
endpoint (default https://api.z.ai/api/anthropic) exactly like Claude Code with
ANTHROPIC_BASE_URL pointed at z.ai: a /v1/messages POST with a Bearer key. The
key + base URL come from the config the control panel's z.ai field writes
(providers.zai.base_url/.api_key) — no OAuth, no local files.

Two z.ai-specific cares: (1) static API key, no self-refresh; (2) the GLM Coding
Plan meters by prompts, so a 60s poll would drain quota — the provider
self-throttles to one network call per poll_interval_s (default 15 min) and
serves a cached status in between. Rate-limit parsing assumes z.ai proxies
Anthropic's unified headers (5h->s/sr, 7d->w/wr); if a real response lacks them
it still reports connected and logs the limit-ish headers it did return, so the
mapping can be finalized against a live key.

tools/probe_zai.py dumps a real z.ai response's headers (key via env, never
printed) to pin the exact header names. Graceful without a key: selecting z.ai
shows the blue theme + "no key", never crashes the loop. Registry: all three
providers now real; unknown ids fall back to StubProvider. +7 z.ai tests (mocked
transport: header mapping, auth, throttle, fallback); 120 passed, 2 Linux-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 07:27:34 +03:00
wenilandClaude Opus 4.8 b63a6a62ae v3 M2: OpenAI Codex provider — real rate-limit % from local Codex rollouts
OpenAICodexProvider replaces the openai stub. It reads the rate-limit snapshot
the Codex CLI already records locally (no second auth): each turn Codex writes a
token_count event into $CODEX_HOME/sessions/**/rollout-*.jsonl whose
payload.rate_limits mirrors Claude's model — primary (5h) + secondary (weekly),
each with used_percent + resets_at + plan_type. The provider surfaces the
freshest such snapshot; a window whose reset time has passed is reported as a
fresh 0% (local read, so current in-session and self-heals between sessions).

primary -> s/sr, secondary -> w/wr, plan_type -> status ("Plus"/…),
rate_limit_reached_type -> "limited". No per-token cost (subscription), so the
two rate-limit bars are the metric, per the locked v3 decision.

Verified against the real ~/.codex: full daemon payload with openai active =
{pv:openai, pnm:"OpenAI Codex", ac:0x10a37f, st:"Plus", ok:true, weekly reset}.
registry test updated (openai now real, zai still stub); +7 provider tests.
113 passed, 2 Linux-only failures (baseline). spec: hiddenimport added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 07:14:15 +03:00
wenilandClaude Opus 4.8 a975eb434b v3 M1c (firmware): Provider screen — active source + tap-to-switch
New SCREEN_PROVIDER (Menu → "Provider"): shows the active provider's brand name
in the accent colour (recolors live on a theme switch via the shared accent
style) and its "i / c" position among the enabled providers. A Switch button
notifies {"cmd":"provnext"}; the daemon owns the enabled set/order and cycles to
the next one, so the watch stays dumb. When only one provider is enabled the
button is replaced by a hint.

main.cpp parses the optional pnm/pi/pc payload fields into ui_set_provider_badge.
Builds green on all four boards (206/216/18/216_c6); verified on 2.06 hardware
(OpenAI green theme + "2 / 3" + Switch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 06:53:55 +03:00
wenilandClaude Opus 4.8 1b14c363c2 v3 M1c (host): provider selector — control panel + live-switch plumbing
Panel: new Providers tab (data-driven from GET /api/providers — labels/accents
from the daemon registry, enable/active/creds from config). z.ai base_url + key,
api_key masked and preserved on save like the HA token.

Daemon: request_provider_switch() lets the panel push an active-provider change
into the live BLE session (same _set_active_provider path as the watch) so it
takes effect now, not on the next 60s poll; _active_session exposes the session
to the panel thread. On-watch cycle groundwork: provnext -> _cycle_provider
(daemon owns the enabled set/order), and pnm/pi/pc pushed in the payload so the
watch's Provider screen can show the name + "1/3".

server.py: ConfigIn gains providers/active_provider/display_order; secrets masked
in _masked; active_provider change notifies the injected _command_sink.
tray wires set_command_sink(request_provider_switch).

Tests: test_server_providers (masking + sink), test_provider_switch (cycle +
offline persist). 106 passed, 2 Linux-only failures (baseline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 06:41:19 +03:00
wenil 83bd2bed43 v3 M1b: runtime per-provider theming (firmware) + accent push (daemon)
The watch recolors to the active provider's brand, live, and it's cheap to add
more providers later.

Firmware:
- theme.h: expose THEME_ACCENT_HEX; ui.cpp: COL_ACCENT becomes a runtime
  lv_color_t backed by a shared style (s_accent_style). Static accent widgets
  (launcher tile icons, usage status line, session tokens) attach the shared
  style, so ui_set_theme() recolors them instantly via lv_obj_report_style_change
  — no widget tracking, no recreation.
- ui_set_theme(pv, accent_rgb): swaps the accent + a small logo table keyed by
  provider id (fallback to the Claude mark until a provider ships a logo). Change-
  guarded, so the ~3s payload cadence doesn't churn.
- main.cpp: parse "pv" (id) + "ac" (0xRRGGBB) from the payload and apply.
- Adding a provider needs zero firmware color edits (accent is data-driven) and
  at most one logo-table row.

Daemon: push "ac" = active provider's brand accent alongside "pv".

Test fix: test_providers used asyncio.run(), which closed the suite's shared
event loop and broke every later get_event_loop() test (Py3.13) — mirror the
repo's _run() helper instead.

Verified on the 2.06: forcing an OpenAI-green theme recolored the live launcher
icons (report_style_change path). 216 builds clean; daemon suite 94 passed.
2026-07-10 00:40:24 +03:00
wenil 6e3f8e9084 v3 M1a: provider framework — config v2 + Provider abstraction (daemon)
Introduce the multi-provider seam on the host, behaviour-identical for Anthropic.

- config v2: `providers` (per-provider enable + creds), `active_provider`,
  `display_order`; v1 configs migrate transparently (anthropic enabled+active).
  New accessors: active_provider_id / provider_conf / enabled_providers.
- daemon/providers/: Provider ABC + normalized ProviderStatus (maps to the same
  s/sr/w/wr/st/tk/to/tc/tn BLE fields), AnthropicProvider (wraps the existing
  OAuth+poll+local-usage logic), StubProvider for openai/zai until M2/M3, and a
  get_provider() registry.
- poll loop: polls the config-selected active provider each cycle and tags the
  payload with `pv` (the brand-theme id the watch will wear). Watch->PC
  `{"cmd":"prov","id":".."}` persists the active provider and repolls at once.
- clawdmeter.spec: bundle daemon.providers for the frozen exe.
- test: 7 new provider/config tests; fix a stale start_notify count assertion
  (the M2 command channel means both REQ and CMD subscriptions are attempted).

Verified live: config loads as v2, active provider resolves to AnthropicProvider,
a real poll returns ok=True (s=53%, w=5%); full suite 87 passed (+7 new).
2026-07-10 00:20:37 +03:00
wenilandClaude Opus 4.8 e99c27babd v3: roadmap — multi-provider (Anthropic / OpenAI Codex / z.ai) with per-provider theming
Kicks off v3-alpha on branch v3-dev. Locked decisions: rate-limit % via CLI
subscriptions, provider switch on both PC and watch, brand-colored themes
(clay / OpenAI green / z.ai blue). Architecture: daemon Provider abstraction,
`pv` payload field, firmware runtime palette + shared LVGL styles for live
recolor. Milestones M1 (framework+Anthropic+theming) → M2 (OpenAI) → M3 (z.ai).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:01:55 +03:00
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
59 changed files with 14306 additions and 145 deletions
+17
View File
@@ -18,3 +18,20 @@ daemon/.venv/
# Python bytecode cache
__pycache__/
*.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
/*_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
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)
@@ -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
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
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."
+101
View File
@@ -0,0 +1,101 @@
# -*- 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',
# v3 provider package (registry + providers are lazy-imported in the loop).
'daemon.providers',
'daemon.providers.base',
'daemon.providers.anthropic',
'daemon.providers.openai_codex',
'daemon.providers.zai',
# 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.
@@ -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
the device should leave its waiting screen and show session + weekly percentages within
about 10 seconds of launch.
- The daemon then re-polls every 60 seconds while connected. If the device fires a refresh
request (e.g., after a button press), an immediate re-poll occurs without waiting for the
60-second interval.
- The daemon then re-polls the Anthropic API every 60 seconds while connected. If the device
fires a refresh request (e.g., after a button press), an immediate re-poll occurs without
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
re-scans automatically with exponential backoff (starting at 1 second, capped at 60 seconds).
@@ -219,5 +269,5 @@ launched.
## 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
+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
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")
script = os.path.abspath(tray_script if tray_script is not None else __file__)
return f'"{pythonw}" "{script}"'
+725 -72
View File
@@ -32,9 +32,22 @@ DEVICE_ADDRESS = os.environ.get("CLAWDMETER_ADDRESS")
SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001"
RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002"
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
TICK = 5
# Phase 7 M2 — dynamic watch buttons. The daemon pushes up to this many button
# 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
CONNECT_RETRIES = 3 # D-01: attempts before giving up on a device
CONNECT_RETRY_DELAY = 2.0 # D-01: seconds between failed connect attempts
@@ -57,6 +70,120 @@ API_BODY = {
"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:
"""Create a rotating file logger for field diagnostics, or None.
@@ -150,15 +277,16 @@ async def poll_api(token: str) -> dict | None:
except ValueError:
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")),
"sr": reset_minutes(hdr("anthropic-ratelimit-unified-5h-reset")),
"w": pct(hdr("anthropic-ratelimit-unified-7d-utilization")),
"wr": reset_minutes(hdr("anthropic-ratelimit-unified-7d-reset")),
"st": hdr("anthropic-ratelimit-unified-5h-status", "unknown"),
"ok": True,
}
return payload
async def scan_for_device():
@@ -176,9 +304,58 @@ async def scan_for_device():
class Session:
def __init__(self, client: BleakClient) -> None:
def __init__(self, client: BleakClient, tray_state=None) -> None:
self.client = client
self.tray_state = tray_state
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:
log("Refresh requested by device")
@@ -196,11 +373,179 @@ class Session:
except (BleakError, ValueError, OSError) as 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
# v3: watch switched the displayed provider ({"cmd":"prov","id":".."}).
# Persist it to config and poll it immediately, on the loop thread.
if payload.get("cmd") == "prov":
pid = payload.get("id")
loop = self._loop
if isinstance(pid, str) and loop is not None:
loop.call_soon_threadsafe(self._set_active_provider, pid)
return
# v3: watch's Provider screen "switch" tap ({"cmd":"provnext"}) — the watch
# doesn't know the enabled set/order, so the daemon cycles to the next one.
if payload.get("cmd") == "provnext":
loop = self._loop
if loop is not None:
loop.call_soon_threadsafe(self._cycle_provider)
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 _set_active_provider(self, pid: str) -> None:
"""Persist the watch-selected provider to config and poll it now. Runs on
the loop thread (via call_soon_threadsafe), so touching refresh_requested
is safe."""
try:
try:
from daemon.config import load_config, save_config, PROVIDER_IDS
except ImportError:
from config import load_config, save_config, PROVIDER_IDS
if pid not in PROVIDER_IDS:
log(f"Provider switch: unknown id {pid!r}")
return
cfg = load_config()
if cfg.get("active_provider") != pid:
cfg["active_provider"] = pid
save_config(cfg)
log(f"Active provider -> {pid}")
self.refresh_requested.set() # poll the new provider on the next tick
except Exception as e:
log(f"Provider switch failed: {e!r}")
def _cycle_provider(self) -> None:
"""Advance to the next enabled provider (on-watch 'switch' tap). Runs on
the loop thread. With a single enabled provider this is a no-op poll."""
try:
try:
from daemon.config import active_provider_id
except ImportError:
from config import active_provider_id
ids = _enabled_ids()
cur = active_provider_id()
i = ids.index(cur) if cur in ids else -1
self._set_active_provider(ids[(i + 1) % len(ids)])
except Exception as e:
log(f"Provider cycle failed: {e!r}")
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:
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()}")
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
except (BleakError, OSError) as e:
# WinRT can raise a raw OSError/WinError (NOT wrapped as BleakError)
@@ -312,12 +657,164 @@ def _read_expiry() -> str:
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:
"""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
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.
"""
tasks = [asyncio.ensure_future(e.wait()) for e in events]
@@ -329,6 +826,75 @@ async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
await asyncio.gather(*tasks, return_exceptions=True)
def _active_provider():
"""(id, Provider) for the currently configured active provider. Lazy imports
mirror the rest of the daemon so this resolves under `-m`, the frozen exe,
and pytest alike. Falls back to Anthropic if the config can't be read."""
try:
try:
from daemon.providers import get_provider
from daemon.config import active_provider_id
except ImportError:
from providers import get_provider
from config import active_provider_id
pid = active_provider_id()
except Exception as e: # never let provider selection break the loop
log(f"Provider select failed ({e!r}); using anthropic")
try:
from daemon.providers import get_provider
except ImportError:
from providers import get_provider
pid = "anthropic"
return pid, get_provider(pid)
def _enabled_ids() -> list:
"""Enabled provider ids in on-watch cycle order (never empty — the watch
always has at least Anthropic to cycle/show). Used for both the on-watch
'switch' cycle and the pi/pc badge sent to the Provider screen."""
try:
try:
from daemon.config import enabled_providers
except ImportError:
from config import enabled_providers
return enabled_providers() or ["anthropic"]
except Exception:
return ["anthropic"]
# The live Session, exposed module-wide so the control-panel HTTP thread can push
# an active-provider switch into the BLE loop (set on connect, cleared on exit).
_active_session = None
def request_provider_switch(pid: str) -> None:
"""Control-panel hook: switch the watch's displayed provider immediately,
reusing the exact on-watch path (persist config + refresh-poll + BLE push).
Called from the panel's HTTP thread, so dispatch onto the BLE loop. If no
watch is connected, persist directly so the next connect uses it. Never raises."""
if not isinstance(pid, str):
return
sess = _active_session
loop = getattr(sess, "_loop", None) if sess is not None else None
if sess is not None and loop is not None:
loop.call_soon_threadsafe(sess._set_active_provider, pid)
return
# No live session/loop yet: persist directly. (The panel PUT already saved the
# config, so this is usually a no-op — but it keeps the hook correct on its own.)
try:
try:
from daemon.config import load_config, save_config, PROVIDER_IDS
except ImportError:
from config import load_config, save_config, PROVIDER_IDS
if pid in PROVIDER_IDS:
cfg = load_config()
if cfg.get("active_provider") != pid:
cfg["active_provider"] = pid
save_config(cfg)
except Exception as e:
log(f"Provider switch (offline) failed: {e!r}")
async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool:
"""Connect to device and poll until disconnected or stopped.
@@ -378,59 +944,124 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
return False
log("Connected")
session = Session(client)
session = Session(client, tray_state)
global _active_session
_active_session = session # let the control panel reach this session for live switches
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
consecutive_failures = 0 # D-03: zombie-link break counter
try:
while client.is_connected and not stop_event.is_set():
now = time.time()
elapsed = now - last_poll
if session.refresh_requested.is_set() or elapsed >= POLL_INTERVAL:
claude_due = (session.refresh_requested.is_set()
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()
token = read_token() # D-09: fresh each cycle
if not token:
log("No token; skipping poll")
if tray_state:
tray_state.set_error("token expired — run claude login")
else:
try:
payload = await poll_api(token)
except AuthError:
# Real 401/403 — token genuinely needs a refresh.
if tray_state:
tray_state.set_error("token expired — run claude login")
payload = None
if payload is not None:
if await session.write_payload(payload):
last_poll = time.time()
used_successfully = True
consecutive_failures = 0 # D-03: reset on success
session.reload_buttons() # pick up desktop-panel edits to the button set
# v3: poll whichever provider is active (config-driven, so an
# on-watch switch takes effect on the next cycle). The provider
# normalizes its own auth + rate-limit + local usage into a
# ProviderStatus and never raises — a genuine auth failure sets
# auth_problem, a transient blip just leaves ok=False (SC#5).
# Today only Anthropic is real; OpenAI/z.ai are stubs until M2/M3.
active_pv, provider = _active_provider()
status = await provider.poll()
auth_problem = status.auth_problem
cached = status.to_payload()
cached["pv"] = active_pv # which brand theme the watch wears
cached["pnm"] = str(provider.label)[:16] # name for the Provider screen
try: # brand accent (0xRRGGBB) for the theme
cached["ac"] = int(provider.accent, 16)
except (ValueError, TypeError):
pass
# 1-based position + count among enabled providers, so the watch's
# Provider screen shows "1/3" and hides the switch hint when alone.
_ids = _enabled_ids()
cached["pc"] = len(_ids)
cached["pi"] = (_ids.index(active_pv) + 1) if active_pv in _ids else 1
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:
tray_state.set_connected(time.time())
else:
consecutive_failures += 1
if consecutive_failures >= ZOMBIE_BREAK_LIMIT:
log(
f"Zombie link detected ({consecutive_failures} consecutive"
f" write failures); abandoning connection"
)
break
# else: payload is None from a TRANSIENT failure (network/DNS,
# timeout, rate-limit, 5xx). poll_api already logged it; do NOT
# toast "token expired" — that mislabeled a boot-time DNS blip
# as an auth problem (SC#5). Leave tray state unchanged; the next
# tick retries and set_connected() recovers it.
elif auth_problem:
if tray_state:
tray_state.set_error("token expired — run claude login")
# transient rate-limit failure: leave tray state unchanged
else:
consecutive_failures += 1
if consecutive_failures >= ZOMBIE_BREAK_LIMIT:
log(
f"Zombie link detected ({consecutive_failures} consecutive"
f" write failures); abandoning connection"
)
break
# Wake on a refresh request OR a stop, whichever comes first. Waking
# promptly on stop_event is what lets the finally below run
# client.disconnect() before the process exits, so the peer gets a
# clean GATT disconnect (returns to its waiting screen) instead of
# being left frozen on stale data after Quit (SC#3 graceful shutdown).
await _wait_first(session.refresh_requested, stop_event, timeout=TICK)
# Wake on a refresh request OR a stop, whichever comes first, but no
# later than NOWPLAYING_INTERVAL so the media session is re-read on time.
# Waking promptly on stop_event is what lets the finally below run
# client.disconnect() before the process exits, so the peer gets a clean
# GATT disconnect (returns to its waiting screen) instead of being left
# frozen on stale data after Quit (SC#3 graceful shutdown).
await _wait_first(session.refresh_requested, stop_event,
timeout=NOWPLAYING_INTERVAL)
finally:
_active_session = None # no live session for the panel to push into
# 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,
# so swallow both; the link tears down regardless once we exit.
@@ -438,6 +1069,11 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
await client.disconnect()
except (BleakError, OSError):
pass
if session.ha:
try:
await session.ha.aclose()
except Exception:
pass
log("Device disconnected" if not stop_event.is_set() else "Stopping")
return used_successfully
@@ -490,34 +1126,51 @@ async def main(tray_state=None) -> None:
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
while not stop_event.is_set():
device = await scan_for_device()
if not device:
# Slow-search regime: device was not found by scan — back off gently
try:
device = await scan_for_device()
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:
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:
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
if __name__ == "__main__":
+184
View File
@@ -0,0 +1,184 @@
"""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 (v2)::
{
"version": 2,
"ha": {"url": str, "token": str, "entities": [str, ...]},
"buttons": [{"id": str, "label": str, "icon": str,
"action": "toggle"|"bri"|"ct", "entity": str, "value": int|None}],
"providers": { # v3: multi-provider usage
"anthropic": {"enabled": bool},
"openai": {"enabled": bool},
"zai": {"enabled": bool, "base_url": str, "api_key": str},
},
"active_provider": "anthropic", # which provider the watch displays
"display_order": ["anthropic", "openai", "zai"], # on-watch cycle order
"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. Provider API keys (e.g. z.ai) are
secrets too and follow the same rule.
"""
import json
import os
from pathlib import Path
CONFIG_VERSION = 2
DEFAULT_PORT = 8723 # FastAPI control panel — bound to 127.0.0.1 only
VALID_ACTIONS = ("toggle", "bri", "ct")
# v3 providers. Order here is the default on-watch cycle order. Anthropic needs
# no extra config (it reads Claude Code's OAuth creds); z.ai needs a base URL +
# API key; OpenAI reads the local Codex install. Keep this list and the per-
# provider default shapes in sync with default_config().
PROVIDER_IDS = ("anthropic", "openai", "zai")
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_providers() -> dict:
"""Per-provider config defaults. Anthropic is enabled+active out of the box so
a v1 config (no providers section) keeps behaving exactly as before."""
return {
"anthropic": {"enabled": True},
"openai": {"enabled": False},
"zai": {"enabled": False, "base_url": "", "api_key": ""},
}
def default_config() -> dict:
return {
"version": CONFIG_VERSION,
"ha": {"url": "", "token": "", "entities": []},
"buttons": [],
"providers": default_providers(),
"active_provider": "anthropic",
"display_order": list(PROVIDER_IDS),
"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"]
# v3 providers — deep-merge per provider so new keys added in later
# versions always resolve, and a v1 config (no providers) keeps the
# defaults (anthropic enabled + active) → behaviour unchanged.
if isinstance(loaded.get("providers"), dict):
for pid, pconf in loaded["providers"].items():
if pid in cfg["providers"] and isinstance(pconf, dict):
cfg["providers"][pid].update(pconf)
if isinstance(loaded.get("active_provider"), str):
cfg["active_provider"] = loaded["active_provider"]
if isinstance(loaded.get("display_order"), list):
cfg["display_order"] = [p for p in loaded["display_order"] if p in PROVIDER_IDS]
# active provider must be a known id; fall back to anthropic otherwise.
if cfg["active_provider"] not in PROVIDER_IDS:
cfg["active_provider"] = "anthropic"
# display order must cover every provider (append any missing at the end).
for pid in PROVIDER_IDS:
if pid not in cfg["display_order"]:
cfg["display_order"].append(pid)
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 [])}
# --- v3 provider accessors -------------------------------------------------
def active_provider_id(cfg: dict | None = None) -> str:
"""The provider id the watch should display right now (always a valid id)."""
if cfg is None:
cfg = load_config()
pid = cfg.get("active_provider", "anthropic")
return pid if pid in PROVIDER_IDS else "anthropic"
def provider_conf(cfg: dict, pid: str) -> dict:
"""Per-provider config dict (empty dict if the id is unknown)."""
return (cfg.get("providers") or {}).get(pid, {})
def enabled_providers(cfg: dict | None = None) -> list:
"""Enabled provider ids in the configured on-watch cycle order."""
if cfg is None:
cfg = load_config()
order = cfg.get("display_order") or list(PROVIDER_IDS)
return [p for p in order if p in PROVIDER_IDS and provider_conf(cfg, p).get("enabled")]
+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()
+30
View File
@@ -0,0 +1,30 @@
"""Provider registry (v3).
get_provider(id) returns a Provider instance for the daemon to poll. Anthropic
is real; OpenAI and z.ai return a StubProvider until M2/M3 land, so selecting
them never crashes the loop.
"""
from __future__ import annotations
from .base import Provider, ProviderStatus, StubProvider
from .anthropic import AnthropicProvider
from .openai_codex import OpenAICodexProvider
from .zai import ZaiProvider
def get_provider(pid: str) -> Provider:
if pid == "anthropic":
return AnthropicProvider()
if pid == "openai":
return OpenAICodexProvider()
if pid == "zai":
return ZaiProvider()
# Unknown id (a config from a newer version, say) — a safe placeholder.
return StubProvider(pid, label=pid, accent="d97757")
__all__ = [
"Provider", "ProviderStatus", "StubProvider",
"AnthropicProvider", "OpenAICodexProvider", "ZaiProvider", "get_provider",
]
+79
View File
@@ -0,0 +1,79 @@
"""Anthropic (Claude Code) provider.
Wraps the daemon's existing OAuth + rate-limit poll + local-usage logic behind
the Provider interface. Behaviour is identical to pre-v3 Clawdmeter — this only
moves the Claude-specific work behind a seam so OpenAI/z.ai can slot in beside
it. The heavy lifting (compute_today_usage, poll_api, token refresh) still lives
in claude_usage_daemon_windows.py; this just orchestrates it into a
ProviderStatus.
"""
from __future__ import annotations
from .base import Provider, ProviderStatus
class AnthropicProvider(Provider):
id = "anthropic"
label = "Claude Code"
accent = "d97757" # brand terra-cotta
async def poll(self) -> ProviderStatus:
# Lazy import: the daemon module imports this package, so importing it
# back at module load would be circular. By poll() time it's resolved.
from daemon.claude_usage_daemon_windows import (
compute_today_usage, refresh_token_if_needed, read_token, poll_api,
AuthError, log,
)
fresh = compute_today_usage() # local token/cost — no network, always available
rl = None
auth_problem = False
# Proactively refresh the OAuth token; a DNS/network blip here must not
# crash the loop (SC#5) — it just means we poll with the current token.
try:
await refresh_token_if_needed()
except Exception as e: # belt-and-braces
log(f"Token refresh skipped: {e!r}")
token = read_token()
if not token:
auth_problem = True
log("No token; sending local usage only")
else:
try:
rl = await poll_api(token)
except AuthError:
# Rejected despite the proactive refresh — force one refresh and
# retry the poll once before flagging the token as bad.
try:
forced = await refresh_token_if_needed(force=True)
except Exception as e:
log(f"Forced token refresh failed: {e!r}")
forced = False
if forced and (token := read_token()):
try:
rl = await poll_api(token)
except AuthError:
auth_problem = True
else:
auth_problem = True
status = ProviderStatus(
tokens_today=int(fresh.get("tk", 0) or 0),
output_today=int(fresh.get("to", 0) or 0),
cost_cents_today=int(fresh.get("tc", 0) or 0),
messages_today=int(fresh.get("tn", 0) or 0),
auth_problem=auth_problem,
)
if rl is not None:
status.s = rl.get("s", 0.0)
status.sr = rl.get("sr", -1)
status.w = rl.get("w", 0.0)
status.wr = rl.get("wr", -1)
status.st = rl.get("st", "unknown")
status.ok = True # rate-limit data is fresh
else:
status.ok = False # unknown → the watch usage view goes idle, not 0%
return status
+77
View File
@@ -0,0 +1,77 @@
"""Provider abstraction (v3).
A ``Provider`` polls one AI coding service (Claude Code, OpenAI Codex, z.ai GLM
Coding Plan, ...) and returns a normalized ``ProviderStatus`` the daemon writes
to the watch. Each provider owns its own auth + usage source; the daemon loop
and the firmware stay provider-agnostic — the BLE payload just carries a ``pv``
id that selects which brand theme the watch wears.
The metric model mirrors Claude's: a short (5h) and long (7d/weekly) rate-limit
utilization percentage plus reset timers, with today's local token/cost as a
bonus when the provider can compute it.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class ProviderStatus:
# Rate-limit utilization (0-100) + reset minutes — the two bars on the watch.
s: float = 0.0 # short window (5h) utilization %
sr: int = -1 # short window reset, minutes (-1 = unknown)
w: float = 0.0 # long window (7d) utilization %
wr: int = -1 # long window reset, minutes
st: str = "unknown" # status string ("allowed"/"limited"/...)
ok: bool = False # rate-limit data is fresh/valid this cycle
# Today's local usage (optional — providers that can read it fill these in).
tokens_today: int = 0
output_today: int = 0
cost_cents_today: int = 0
messages_today: int = 0
# True when the provider is configured but auth failed (expired token etc.).
# Distinct from a transient network blip, which leaves ok=False without this.
auth_problem: bool = False
def to_payload(self) -> dict:
"""Compact BLE field names the firmware parser already understands."""
return {
"s": self.s, "sr": self.sr, "w": self.w, "wr": self.wr,
"st": self.st, "ok": self.ok,
"tk": self.tokens_today, "to": self.output_today,
"tc": self.cost_cents_today, "tn": self.messages_today,
}
class Provider(ABC):
"""One usage source. Subclasses set the class attributes and implement poll()."""
id: str = "" # payload `pv` id: anthropic / openai / zai
label: str = "" # human label ("Claude Code")
accent: str = "d97757" # brand accent hex — firmware theme hint
@abstractmethod
async def poll(self) -> ProviderStatus:
"""Return the current status. MUST NOT raise on a network/auth failure —
return a status with ok=False (and auth_problem=True on a genuine auth
error) so the daemon loop and the watch degrade gracefully."""
raise NotImplementedError
async def aclose(self) -> None:
"""Release any held resources (override when the provider owns a client)."""
return None
class StubProvider(Provider):
"""Placeholder for a provider id that isn't implemented yet (OpenAI/z.ai
before M2/M3). Reports nothing rather than crashing when selected."""
def __init__(self, pid: str, label: str = "", accent: str = "d97757") -> None:
self.id = pid
self.label = label or pid
self.accent = accent
async def poll(self) -> ProviderStatus:
return ProviderStatus(ok=False, st="not configured")
+206
View File
@@ -0,0 +1,206 @@
"""OpenAI Codex provider (v3 M2).
Primary source is the live ChatGPT usage endpoint (fresh every poll)::
GET https://chatgpt.com/backend-api/wham/usage
Authorization: Bearer <access_token from $CODEX_HOME/auth.json tokens>
ChatGPT-Account-Id: <account_id> # when present
-> {"plan_type": "...", "rate_limit": {"primary_window": {...}, "secondary_window": {...}}}
with each window carrying ``used_percent`` + ``reset_at`` — Claude's short (5h) /
weekly model. (Endpoint + auth discovered from github.com/rygel/AIUsageTracker,
MIT.) The access token is Codex's own OAuth token; we read the current one and do
NOT refresh it, so when it has expired (Codex not run in a while) we fall back to
the local session-rollout snapshot Codex writes every turn
(``$CODEX_HOME/sessions/**/rollout-*.jsonl`` -> ``payload.rate_limits``). The
rollout read needs no token but goes stale between sessions; a window whose
``resets_at`` has passed is reported as a fresh 0%. Live-first + rollout-fallback
gives fresh data when possible and last-known otherwise.
"""
from __future__ import annotations
import asyncio
import json
import os
import time
from pathlib import Path
import httpx
from .base import Provider, ProviderStatus
USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
# Newest-first cap: the active session's rollout has the latest reading, so we
# rarely look past the first file — but scan a few in case the newest is a
# just-opened session with no turns (hence no rate_limits) yet.
_MAX_ROLLOUTS_SCANNED = 12
def _codex_home() -> Path:
return Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
class OpenAICodexProvider(Provider):
id = "openai"
label = "OpenAI Codex"
accent = "10a37f" # brand green
def __init__(self, codex_home: str | os.PathLike | None = None,
transport: httpx.BaseTransport | None = None) -> None:
self._home = Path(codex_home) if codex_home else _codex_home()
self._transport = transport # tests inject an httpx.MockTransport
async def poll(self) -> ProviderStatus:
from daemon.claude_usage_daemon_windows import log
# 1) live endpoint (fresh) — None if no token / expired / offline.
live = await self._live_usage(log)
if live is not None:
return live
# 2) fall back to the local rollout snapshot (no token needed).
try:
snap = await asyncio.to_thread(self._latest_rate_limits)
except Exception as e: # never break the loop (SC#5)
log(f"Codex poll error: {e!r}")
return ProviderStatus(ok=False, st="error")
if snap is None:
# Configured but nothing to read (Codex never run / logged out).
return ProviderStatus(ok=False, st="no data")
return self._to_status(snap)
# -- live usage endpoint ---------------------------------------------------
def _read_auth(self) -> dict | None:
"""access_token (+ account_id) from Codex's auth.json, or None."""
try:
data = json.loads((self._home / "auth.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
toks = data.get("tokens") or {}
at = toks.get("access_token")
return {"access_token": at, "account_id": toks.get("account_id")} if at else None
async def _live_usage(self, log) -> ProviderStatus | None:
creds = await asyncio.to_thread(self._read_auth)
if not creds:
return None
headers = {"Authorization": f"Bearer {creds['access_token']}",
"Content-Type": "application/json"}
if creds.get("account_id"):
headers["ChatGPT-Account-Id"] = creds["account_id"]
client_kw = {"timeout": 20.0}
if self._transport is not None:
client_kw["transport"] = self._transport
try:
async with httpx.AsyncClient(**client_kw) as http:
resp = await http.get(USAGE_URL, headers=headers)
except httpx.HTTPError as e:
log(f"Codex live usage failed ({e}); using local snapshot")
return None
if resp.status_code >= 400:
# 401/403 => token expired (Codex refreshes it when it runs); other
# 4xx/5xx are transient. Either way, fall back to the rollout read.
log(f"Codex usage HTTP {resp.status_code}; using local snapshot")
return None
try:
root = resp.json()
except ValueError:
return None
rl = root.get("rate_limit")
return self._status_from_live(root, rl) if isinstance(rl, dict) else None
@staticmethod
def _status_from_live(root: dict, rl: dict) -> ProviderStatus:
now = time.time()
def window(w: dict | None) -> tuple[float, int]:
if not isinstance(w, dict):
return 0.0, -1
used = float(w.get("used_percent") or 0.0)
ra = w.get("reset_at")
if isinstance(ra, (int, float)):
m = (ra - now) / 60.0
return used, (int(round(m)) if m > 0 else -1)
ras = w.get("reset_after_seconds")
if isinstance(ras, (int, float)) and ras > 0:
return used, int(ras // 60)
return used, -1
s, sr = window(rl.get("primary_window"))
w, wr = window(rl.get("secondary_window"))
reached = rl.get("limit_reached") or root.get("rate_limit_reached_type")
plan = root.get("plan_type")
st = "limited" if reached else (str(plan).capitalize() if plan else "allowed")
return ProviderStatus(s=s, sr=sr, w=w, wr=wr, st=st, ok=True)
# -- local rollout reading -------------------------------------------------
def _latest_rate_limits(self) -> dict | None:
"""The freshest non-empty ``rate_limits`` object from the most recently
written session rollout. Pure/sync — run via asyncio.to_thread."""
sessions = self._home / "sessions"
if not sessions.is_dir():
return None
try:
files = sorted(sessions.rglob("rollout-*.jsonl"),
key=lambda p: p.stat().st_mtime, reverse=True)
except OSError:
return None
for path in files[:_MAX_ROLLOUTS_SCANNED]:
found = None
try:
with path.open(encoding="utf-8", errors="replace") as fh:
for line in fh:
if '"rate_limits"' not in line:
continue
try:
obj = json.loads(line)
except ValueError:
continue
# token_count events carry rate_limits as a sibling of
# info under payload; tolerate the info-nested shape too.
payload = obj.get("payload") or {}
rl = payload.get("rate_limits")
if not isinstance(rl, dict):
rl = (payload.get("info") or {}).get("rate_limits")
if isinstance(rl, dict) and (rl.get("primary") or rl.get("secondary")):
found = rl # keep the LAST one in the file
except OSError:
continue
if found is not None:
return found # newest file that has a reading wins
return None
# -- mapping ---------------------------------------------------------------
@staticmethod
def _window(w: dict | None) -> tuple[float, int]:
"""(used_percent 0-100, minutes-to-reset) for one window. A reset time
already in the past means the window rolled over since the snapshot, so
report it as a fresh 0% with an unknown reset."""
if not isinstance(w, dict):
return 0.0, -1
used = float(w.get("used_percent") or 0.0)
resets_at = w.get("resets_at")
if isinstance(resets_at, (int, float)):
delta = resets_at - time.time()
if delta <= 0:
return 0.0, -1
return used, int(delta // 60)
return used, -1
def _to_status(self, rl: dict) -> ProviderStatus:
s, sr = self._window(rl.get("primary"))
w, wr = self._window(rl.get("secondary"))
reached = rl.get("rate_limit_reached_type")
plan = rl.get("plan_type")
if reached:
st = "limited"
elif plan:
st = str(plan).capitalize() # "Plus" / "Pro" / "Team" / ...
else:
st = "allowed"
# Subscription plan → no per-token cost; the two rate-limit bars are the
# metric (matches the locked v3 decision). tokens/cost left at 0.
return ProviderStatus(s=s, sr=sr, w=w, wr=wr, st=st, ok=True)
+143
View File
@@ -0,0 +1,143 @@
"""z.ai (GLM Coding Plan) provider (v3 M3).
z.ai exposes a dedicated usage endpoint that returns the Coding Plan's rate-limit
windows directly — no need to send a (quota-consuming) chat request:
GET https://api.z.ai/api/monitor/usage/quota/limit
Authorization: <api_key> # raw key, NOT "Bearer …"
Accept-Language: en-US,en
{"code":200,"success":true,"data":{"level":"lite","limits":[
{"type":"TIME_LIMIT", "unit":5,"number":1, ...}, # monthly web-tool quota (ignored)
{"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":1,"nextResetTime":<ms>}, # 5h window
{"type":"TOKENS_LIMIT","unit":6,"number":1,"percentage":2,"nextResetTime":<ms>}]}} # weekly window
We surface the two TOKENS_LIMIT windows as Claude's short (5h) + weekly bars: of
the token windows, the shortest is the 5h bar and the longest the weekly bar.
``percentage`` is already 0-100; ``nextResetTime`` is epoch ms. The key + base URL
come from the config the control panel's z.ai field writes. This is a status GET,
so — unlike a chat call — it doesn't spend the prompt-metered plan, and can poll
on the normal cadence.
Discovered from github.com/rygel/AIUsageTracker (ZaiProvider.cs).
"""
from __future__ import annotations
import time
from urllib.parse import urlsplit
import httpx
from .base import Provider, ProviderStatus
DEFAULT_HOST = "https://api.z.ai"
_MONITOR_PATH = "/api/monitor/usage/quota/limit"
# z.ai window "unit" enum → seconds, used only to rank the token windows by length
# (shortest = the 5h bar, longest = the weekly bar). Best-effort; unknown → hours.
_UNIT_SECONDS = {1: 1, 2: 60, 3: 3600, 4: 86400, 5: 2_592_000, 6: 604_800}
def _monitor_url(base_url: str) -> str:
"""The usage endpoint on the same host as the configured (Anthropic-compat)
base URL, or the public z.ai host when unset."""
parts = urlsplit(base_url or "")
if parts.scheme and parts.netloc:
return f"{parts.scheme}://{parts.netloc}{_MONITOR_PATH}"
return DEFAULT_HOST + _MONITOR_PATH
class ZaiProvider(Provider):
id = "zai"
label = "z.ai GLM"
accent = "3859ff" # brand blue
def __init__(self, transport: httpx.BaseTransport | None = None) -> None:
self._transport = transport # tests inject an httpx.MockTransport
async def poll(self) -> ProviderStatus:
from daemon.claude_usage_daemon_windows import log
try:
from daemon.config import load_config, provider_conf
except ImportError:
from config import load_config, provider_conf
conf = provider_conf(load_config(), "zai")
key = (conf.get("api_key") or "").strip()
base = (conf.get("base_url") or "").strip()
if not key:
return ProviderStatus(ok=False, st="no key", auth_problem=True)
url = _monitor_url(base)
headers = {"Authorization": key, "Accept-Language": "en-US,en"}
client_kw = {"timeout": 20.0}
if self._transport is not None:
client_kw["transport"] = self._transport
try:
async with httpx.AsyncClient(**client_kw) as http:
resp = await http.get(url, headers=headers)
except httpx.HTTPError as e:
log(f"z.ai call failed: {e}") # transient — retry next tick
return ProviderStatus(ok=False, st="offline")
if resp.status_code in (401, 403):
log(f"z.ai auth rejected: HTTP {resp.status_code}")
return ProviderStatus(ok=False, st="bad key", auth_problem=True)
if resp.status_code >= 400:
log(f"z.ai HTTP {resp.status_code}: {resp.text[:150]}")
return ProviderStatus(ok=False, st=f"http {resp.status_code}")
try:
body = resp.json()
except ValueError:
log("z.ai: non-JSON usage response")
return ProviderStatus(ok=False, st="error")
if not body.get("success") or not isinstance(body.get("data"), dict):
# e.g. {"code":401,...,"success":false} → treat as an auth/config problem
code = body.get("code")
log(f"z.ai usage error: code={code} msg={body.get('msg')!r}")
return ProviderStatus(ok=False, st="bad key",
auth_problem=code in (401, 403))
return self._to_status(body["data"])
@staticmethod
def _to_status(data: dict) -> ProviderStatus:
limits = data.get("limits") or []
# Only the token windows are the coding rate limit; TIME_LIMIT is the
# separate monthly web-tool quota.
toks = [l for l in limits if isinstance(l, dict) and l.get("type") == "TOKENS_LIMIT"]
def win_seconds(l: dict) -> int:
return _UNIT_SECONDS.get(l.get("unit"), 3600) * int(l.get("number") or 1)
def pct(l: dict | None) -> float:
if not l:
return 0.0
try:
return round(float(l.get("percentage") or 0.0), 1)
except (TypeError, ValueError):
return 0.0
def reset_min(l: dict | None) -> int:
ts = l.get("nextResetTime") if l else None
if not isinstance(ts, (int, float)):
return -1
if ts > 1e10: # epoch ms → s
ts = ts / 1000.0
m = (ts - time.time()) / 60.0
return int(round(m)) if m > 0 else -1
short = min(toks, key=win_seconds) if toks else None
weekly = max(toks, key=win_seconds) if len(toks) > 1 else None
level = str(data.get("level") or "").strip()
st = level.capitalize() if level else "allowed"
if pct(short) >= 100 or pct(weekly) >= 100:
st = "limited"
if not toks: # authenticated but no token windows reported
return ProviderStatus(ok=True, st=st or "allowed")
return ProviderStatus(
s=pct(short), sr=reset_min(short),
w=pct(weekly), wr=reset_min(weekly),
st=st, ok=True,
)
+12
View File
@@ -4,3 +4,15 @@ bleak
httpx
pystray
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
+239
View File
@@ -0,0 +1,239 @@
"""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
# Provider secrets (e.g. z.ai api_key) get the same treatment as the HA token:
# never leave this process. The UI echoes the mask back to keep the stored one.
for pconf in (c.get("providers") or {}).values():
if isinstance(pconf, dict) and pconf.get("api_key"):
pconf["api_key"] = 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
providers: dict | None = None # v3: per-provider {enabled, base_url, api_key}
active_provider: str | None = None # which provider the watch displays
display_order: list | None = None # on-watch cycle order
@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"]
# v3 providers — merge per known id; a masked api_key echoed back keeps the
# stored secret (same discipline as the HA token).
if isinstance(data.get("providers"), dict):
for pid, pconf in data["providers"].items():
if pid not in cfg["providers"] or not isinstance(pconf, dict):
continue
pconf = dict(pconf)
if pconf.get("api_key") == MASK:
pconf["api_key"] = cfg["providers"][pid].get("api_key", "")
cfg["providers"][pid].update(pconf)
if isinstance(data.get("display_order"), list):
cfg["display_order"] = data["display_order"]
switched = None
if isinstance(data.get("active_provider"), str):
if data["active_provider"] != cfg.get("active_provider"):
switched = data["active_provider"]
cfg["active_provider"] = data["active_provider"]
cfgmod.save_config(cfg)
# Push the switch to a connected watch now (same path as the on-watch button)
# instead of waiting for the ~60s poll to notice the config change. Best-effort.
if switched is not None and _command_sink is not None:
try:
_command_sink(switched)
except Exception:
pass
return _masked(cfg)
@app.get("/api/providers")
def get_providers() -> dict:
"""Provider list for the panel's Providers tab: brand label + accent come from
the daemon registry, enabled/creds state from config. Data-driven so adding a
provider (a daemon class + a config default) needs zero web-UI edits. Secrets
are never sent — only whether a key is stored (has_key)."""
cfg = cfgmod.load_config()
try:
from daemon.providers import get_provider
except ImportError:
from providers import get_provider
out = []
for pid in cfgmod.PROVIDER_IDS:
p = get_provider(pid)
pconf = cfg["providers"].get(pid, {})
out.append({
"id": pid,
"label": p.label,
"accent": p.accent,
"enabled": bool(pconf.get("enabled")),
"needs_key": "api_key" in pconf, # z.ai-style base_url + key creds
"base_url": pconf.get("base_url", ""),
"has_key": bool(pconf.get("api_key")),
})
return {"providers": out, "active": cfg["active_provider"], "order": cfg["display_order"]}
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
_command_sink = None # set by the tray: called with a provider id to live-switch the watch
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
def set_command_sink(fn) -> None:
"""The tray injects a callable(provider_id) that pushes an active-provider
switch to the live BLE session. Optional — a panel edit still persists to
config (and the daemon picks it up on its next poll) if this is unset."""
global _command_sink
_command_sink = 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")
+180
View File
@@ -0,0 +1,180 @@
"""v3 M2 — OpenAI Codex provider: read rate-limit % from the Codex session rollouts.
Codex writes a token_count event per turn whose payload.rate_limits mirrors
Claude's model (primary=5h, secondary=weekly). The provider surfaces the freshest
such snapshot; a window whose reset time has passed is reported as a fresh 0%.
"""
import asyncio
import json
import os
import time
import httpx
from daemon.providers.openai_codex import OpenAICodexProvider
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
def _write_rollout(home, name, rate_limits_list, mtime=None):
"""Write a rollout JSONL with one token_count event per rate_limits dict."""
d = home / "sessions" / "2026" / "07" / "10"
d.mkdir(parents=True, exist_ok=True)
p = d / f"rollout-{name}.jsonl"
lines = []
for i, rl in enumerate(rate_limits_list):
lines.append(json.dumps({
"timestamp": f"2026-07-10T00:00:0{i}Z",
"type": "event_msg",
"payload": {"type": "token_count",
"info": {"total_token_usage": {"total_tokens": 1}},
"rate_limits": rl},
}))
p.write_text("\n".join(lines), encoding="utf-8")
if mtime is not None:
os.utime(p, (mtime, mtime))
return p
def _rl(pri_used, pri_reset, sec_used, sec_reset, plan="plus", reached=None):
return {"limit_id": "codex",
"primary": {"used_percent": pri_used, "window_minutes": 300, "resets_at": pri_reset},
"secondary": {"used_percent": sec_used, "window_minutes": 10080, "resets_at": sec_reset},
"plan_type": plan, "rate_limit_reached_type": reached}
def test_maps_windows_with_future_reset(tmp_path):
now = time.time()
_write_rollout(tmp_path, "a", [_rl(42.0, now + 3600, 10.0, now + 7200)])
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
assert st.ok is True
assert st.s == 42.0 and 58 <= st.sr <= 60
assert st.w == 10.0 and 118 <= st.wr <= 120
assert st.st == "Plus"
def test_past_reset_is_fresh_zero(tmp_path):
now = time.time()
# primary window already elapsed => 0% fresh, unknown reset; secondary still open
_write_rollout(tmp_path, "a", [_rl(99.0, now - 100, 55.0, now + 600)])
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
assert st.s == 0.0 and st.sr == -1
assert st.w == 55.0 and 8 <= st.wr <= 10
def test_rate_limit_reached_marks_limited(tmp_path):
now = time.time()
_write_rollout(tmp_path, "a", [_rl(100.0, now + 60, 80.0, now + 600, reached="primary")])
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
assert st.st == "limited" and st.ok is True
def test_last_snapshot_in_file_wins(tmp_path):
now = time.time()
_write_rollout(tmp_path, "a", [_rl(10.0, now + 600, 1.0, now + 600),
_rl(73.0, now + 600, 2.0, now + 600)])
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
assert st.s == 73.0 # the later event, not the first
def test_newest_file_wins(tmp_path):
now = time.time()
_write_rollout(tmp_path, "old", [_rl(11.0, now + 600, 0.0, now + 600)], mtime=now - 500)
_write_rollout(tmp_path, "new", [_rl(88.0, now + 600, 0.0, now + 600)], mtime=now)
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
assert st.s == 88.0
def test_skips_file_without_rate_limits(tmp_path):
now = time.time()
# newest file has a token_count with null rate_limits; older file has the data
_write_rollout(tmp_path, "older", [_rl(64.0, now + 600, 0.0, now + 600)], mtime=now - 500)
p = _write_rollout(tmp_path, "newer", [], mtime=now)
p.write_text(json.dumps({
"timestamp": "2026-07-10T01:00:00Z", "type": "event_msg",
"payload": {"type": "token_count", "info": {}, "rate_limits": None},
}), encoding="utf-8")
os.utime(p, (now, now))
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
assert st.ok is True and st.s == 64.0
def test_no_sessions_dir_reports_no_data(tmp_path):
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
assert st.ok is False and st.st == "no data"
# -- live ChatGPT usage endpoint (preferred over rollouts) --------------------
def _write_auth(home, access_token="tok", account_id="acct"):
home.mkdir(parents=True, exist_ok=True)
(home / "auth.json").write_text(
json.dumps({"tokens": {"access_token": access_token, "account_id": account_id}}),
encoding="utf-8")
def _live_resp(pri_pct, pri_reset, sec_pct, sec_reset, plan="plus", limit_reached=False):
return httpx.Response(200, json={
"plan_type": plan,
"rate_limit": {"allowed": True, "limit_reached": limit_reached,
"primary_window": {"used_percent": pri_pct, "reset_at": pri_reset},
"secondary_window": {"used_percent": sec_pct, "reset_at": sec_reset}},
})
def test_live_usage_preferred_over_rollout(tmp_path):
_write_auth(tmp_path)
# a rollout is also present, but the live endpoint must win
now = time.time()
_write_rollout(tmp_path, "a", [_rl(99.0, now + 600, 99.0, now + 600)])
seen = {}
def handler(req: httpx.Request) -> httpx.Response:
seen["auth"] = req.headers.get("authorization")
seen["acct"] = req.headers.get("chatgpt-account-id")
seen["url"] = str(req.url)
return _live_resp(5, now + 3600, 20, now + 7200)
p = OpenAICodexProvider(codex_home=str(tmp_path), transport=httpx.MockTransport(handler))
st = _run(p.poll())
assert st.ok is True and st.st == "Plus"
assert st.s == 5.0 and 58 <= st.sr <= 60
assert st.w == 20.0 and 118 <= st.wr <= 120
assert seen["auth"] == "Bearer tok" and seen["acct"] == "acct"
assert seen["url"] == "https://chatgpt.com/backend-api/wham/usage"
def test_live_limit_reached_is_limited(tmp_path):
_write_auth(tmp_path)
now = time.time()
def handler(req):
return _live_resp(100, now + 60, 50, now + 600, limit_reached=True)
p = OpenAICodexProvider(codex_home=str(tmp_path), transport=httpx.MockTransport(handler))
st = _run(p.poll())
assert st.st == "limited" and st.ok is True
def test_falls_back_to_rollout_on_token_expiry(tmp_path):
_write_auth(tmp_path)
now = time.time()
_write_rollout(tmp_path, "a", [_rl(64.0, now + 600, 0.0, now + 600)])
def handler(req):
return httpx.Response(401, json={"detail": "unauthorized"})
p = OpenAICodexProvider(codex_home=str(tmp_path), transport=httpx.MockTransport(handler))
st = _run(p.poll())
assert st.ok is True and st.s == 64.0 # from the rollout snapshot
def test_no_auth_uses_rollout_without_network(tmp_path):
now = time.time()
_write_rollout(tmp_path, "a", [_rl(30.0, now + 600, 0.0, now + 600)])
# no auth.json => live path skipped entirely; no transport needed
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
assert st.ok is True and st.s == 30.0
+82
View File
@@ -0,0 +1,82 @@
"""v3 M1c — provider switching (on-watch cycle + control-panel offline hook).
The daemon owns the enabled set/order, so the watch's "switch" tap just asks it
to advance (provnext -> _cycle_provider) and the panel's active_provider edit is
mirrored into the live loop (request_provider_switch). Both funnel through the
same _set_active_provider path exercised here without a real BLE loop.
"""
import json
from unittest.mock import MagicMock
from daemon import config
def _cfg(tmp_path, monkeypatch, data: dict):
p = tmp_path / "config.json"
p.write_text(json.dumps(data), encoding="utf-8")
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
return p
def test_enabled_ids_defaults_to_anthropic(tmp_path, monkeypatch):
from daemon import claude_usage_daemon_windows as d
_cfg(tmp_path, monkeypatch, {"version": 2})
assert d._enabled_ids() == ["anthropic"]
def test_cycle_advances_and_wraps(tmp_path, monkeypatch):
from daemon import claude_usage_daemon_windows as d
_cfg(tmp_path, monkeypatch, {
"version": 2,
"display_order": ["anthropic", "openai", "zai"],
"providers": {"anthropic": {"enabled": True},
"openai": {"enabled": True},
"zai": {"enabled": True}},
"active_provider": "anthropic"})
sess = d.Session(MagicMock())
sess._cycle_provider()
assert config.active_provider_id() == "openai"
sess._cycle_provider()
assert config.active_provider_id() == "zai"
sess._cycle_provider() # wraps back to the start
assert config.active_provider_id() == "anthropic"
def test_cycle_skips_disabled(tmp_path, monkeypatch):
from daemon import claude_usage_daemon_windows as d
_cfg(tmp_path, monkeypatch, {
"version": 2,
"display_order": ["anthropic", "openai", "zai"],
"providers": {"anthropic": {"enabled": True},
"openai": {"enabled": False},
"zai": {"enabled": True}},
"active_provider": "anthropic"})
sess = d.Session(MagicMock())
sess._cycle_provider()
assert config.active_provider_id() == "zai" # openai skipped (disabled)
def test_cycle_single_enabled_is_noop(tmp_path, monkeypatch):
from daemon import claude_usage_daemon_windows as d
_cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"})
sess = d.Session(MagicMock())
sess._cycle_provider()
assert config.active_provider_id() == "anthropic"
def test_request_switch_offline_persists(tmp_path, monkeypatch):
from daemon import claude_usage_daemon_windows as d
_cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"})
monkeypatch.setattr(d, "_active_session", None)
d.request_provider_switch("zai")
assert config.active_provider_id() == "zai"
def test_request_switch_ignores_bad_id(tmp_path, monkeypatch):
from daemon import claude_usage_daemon_windows as d
_cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"})
monkeypatch.setattr(d, "_active_session", None)
d.request_provider_switch("bogus")
assert config.active_provider_id() == "anthropic"
+93
View File
@@ -0,0 +1,93 @@
"""v3 provider layer + config v2 tests.
Covers the framework M1 lands: config v1->v2 migration keeps Anthropic behaviour,
the provider registry hands out the right implementations, and ProviderStatus
maps to the compact BLE fields the firmware parser expects.
"""
import asyncio
import json
from daemon import config
from daemon.providers import (
get_provider, AnthropicProvider, OpenAICodexProvider, ZaiProvider,
StubProvider, ProviderStatus)
def _run(coro):
# Reuse the suite's shared event loop (never closed) like the other test
# modules — asyncio.run() would close it and set the current loop to None,
# breaking every later test that calls get_event_loop() (Python 3.13).
return asyncio.get_event_loop().run_until_complete(coro)
def _write(tmp_path, monkeypatch, data: dict):
"""Point the config module at an isolated temp config (and LOCALAPPDATA, so
the legacy-ha_config migration can't reach the real machine's file)."""
p = tmp_path / "config.json"
p.write_text(json.dumps(data), encoding="utf-8")
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
return p
def test_v1_config_migrates_to_provider_defaults(tmp_path, monkeypatch):
# A v1 config has no providers/active_provider; load_config fills them so
# behaviour is unchanged — Anthropic enabled and active.
_write(tmp_path, monkeypatch, {
"version": 1, "ha": {"url": "", "token": "", "entities": []},
"buttons": [], "settings": {}})
cfg = config.load_config()
assert cfg["active_provider"] == "anthropic"
assert cfg["providers"]["anthropic"]["enabled"] is True
assert config.enabled_providers(cfg) == ["anthropic"]
def test_unknown_active_provider_falls_back(tmp_path, monkeypatch):
_write(tmp_path, monkeypatch, {"version": 2, "active_provider": "bogus", "providers": {}})
cfg = config.load_config()
assert cfg["active_provider"] == "anthropic"
assert config.active_provider_id(cfg) == "anthropic"
def test_display_order_covers_all_and_filters_unknown(tmp_path, monkeypatch):
_write(tmp_path, monkeypatch, {"version": 2, "display_order": ["zai", "bogus", "openai"]})
cfg = config.load_config()
# "bogus" dropped; the missing "anthropic" appended at the end.
assert cfg["display_order"] == ["zai", "openai", "anthropic"]
def test_enabled_providers_respects_order_and_flag(tmp_path, monkeypatch):
_write(tmp_path, monkeypatch, {
"version": 2, "display_order": ["zai", "openai", "anthropic"],
"providers": {"anthropic": {"enabled": True},
"openai": {"enabled": True},
"zai": {"enabled": False}}})
cfg = config.load_config()
assert config.enabled_providers(cfg) == ["openai", "anthropic"]
def test_registry_types():
a = get_provider("anthropic")
assert isinstance(a, AnthropicProvider) and a.id == "anthropic"
o = get_provider("openai")
assert isinstance(o, OpenAICodexProvider) and o.id == "openai" and o.accent == "10a37f"
z = get_provider("zai")
assert isinstance(z, ZaiProvider) and z.id == "zai" and z.accent == "3859ff"
stub = get_provider("mystery") # unknown id → safe placeholder, never crashes
assert isinstance(stub, StubProvider) and stub.id == "mystery"
def test_zai_poll_without_key_is_safe(tmp_path, monkeypatch):
# No api_key configured => "no key", no network call.
_write(tmp_path, monkeypatch, {"version": 2, "providers": {"zai": {"enabled": True}}})
st = _run(get_provider("zai").poll())
assert isinstance(st, ProviderStatus)
assert st.ok is False and st.st == "no key"
def test_provider_status_payload_keys():
st = ProviderStatus(s=50, w=10, ok=True, tokens_today=5, cost_cents_today=3)
p = st.to_payload()
assert set(p) == {"s", "sr", "w", "wr", "st", "ok", "tk", "to", "tc", "tn"}
assert p["s"] == 50 and p["tk"] == 5 and p["tc"] == 3 and p["ok"] is True
+73
View File
@@ -0,0 +1,73 @@
"""v3 M1c — control-panel provider API: secret masking + live-switch sink.
Provider api_keys (z.ai) get the same never-leave-the-process treatment as the
HA token, and flipping active_provider notifies the injected command sink so a
connected watch switches without waiting for the ~60s poll.
"""
import json
import pytest
from fastapi.testclient import TestClient
from daemon import config, server
@pytest.fixture
def client(tmp_path, monkeypatch):
cfg = {
"version": 2,
"providers": {
"anthropic": {"enabled": True},
"openai": {"enabled": False},
"zai": {"enabled": False, "base_url": "https://api.z.ai", "api_key": "secret-key"},
},
"active_provider": "anthropic",
"display_order": ["anthropic", "openai", "zai"],
}
p = tmp_path / "config.json"
p.write_text(json.dumps(cfg), encoding="utf-8")
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
monkeypatch.setattr(server, "_command_sink", None)
return TestClient(server.app)
def test_zai_api_key_masked_in_get(client):
r = client.get("/api/config").json()
assert r["providers"]["zai"]["api_key"] == server.MASK
assert r["providers"]["zai"]["base_url"] == "https://api.z.ai" # non-secret shown
def test_masked_api_key_preserved_on_put(client):
# Echo the mask back unchanged => keep the stored secret; other fields apply.
client.put("/api/config",
json={"providers": {"zai": {"api_key": server.MASK, "enabled": True}}})
saved = config.load_config()["providers"]["zai"]
assert saved["api_key"] == "secret-key"
assert saved["enabled"] is True
def test_real_api_key_stored_on_put(client):
client.put("/api/config", json={"providers": {"zai": {"api_key": "new-key"}}})
assert config.load_config()["providers"]["zai"]["api_key"] == "new-key"
def test_active_provider_switch_calls_sink(client, monkeypatch):
calls = []
monkeypatch.setattr(server, "_command_sink", lambda pid: calls.append(pid))
client.put("/api/config", json={"active_provider": "openai"})
assert calls == ["openai"]
assert config.active_provider_id() == "openai"
def test_no_switch_when_active_unchanged(client, monkeypatch):
calls = []
monkeypatch.setattr(server, "_command_sink", lambda pid: calls.append(pid))
client.put("/api/config", json={"active_provider": "anthropic"}) # already active
assert calls == []
def test_unknown_provider_id_ignored_on_put(client):
client.put("/api/config", json={"providers": {"bogus": {"enabled": True}}})
assert "bogus" not in config.load_config()["providers"]
+4 -3
View File
@@ -73,7 +73,7 @@ def test_poll_api_nominal(monkeypatch):
assert payload["s"] == 42
assert payload["w"] == 10
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
assert abs(payload["sr"] - 60) <= 1, f"Expected ~60, got {payload['sr']}"
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}"
# 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("{")
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}"
+4 -2
View File
@@ -599,8 +599,10 @@ def test_start_notify_oserror_does_not_crash_connect_and_run():
# Must NOT raise OSError — graceful degradation into the poll loop.
result = _run(connect_and_run(device, stop_event))
# start_notify was actually attempted (and raised), but was swallowed.
assert mock_client.start_notify.call_count == 1
# start_notify was actually attempted (and raised), but was swallowed — for
# BOTH subscriptions the daemon sets up: the REQ refresh channel and the CMD
# command channel (battery + HA). Both degrade gracefully.
assert mock_client.start_notify.call_count == 2
# Function returned normally instead of propagating the OSError.
assert result is False
# The link was cleaned up via the finally block.
+160
View File
@@ -0,0 +1,160 @@
"""v3 M3 — z.ai (GLM) provider: usage from the monitor/quota endpoint.
Uses httpx.MockTransport so no real z.ai call is made. Covers the token-window
mapping (5h -> s, weekly -> w), raw-key auth, the ignored monthly TIME_LIMIT,
and auth/error handling.
"""
import asyncio
import json
import time
import httpx
from daemon.providers.zai import ZaiProvider, DEFAULT_HOST, _MONITOR_PATH
def _run(coro):
# test_zai sorts last; an earlier module's asyncio.run() can leave the shared
# loop closed, so fall back to a fresh loop rather than raising (Python 3.13).
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
raise RuntimeError
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
def _config(tmp_path, monkeypatch, zai: dict):
p = tmp_path / "config.json"
p.write_text(json.dumps({"version": 2, "providers": {"zai": zai}}), encoding="utf-8")
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
def _tok(pct, reset_ms, unit=3, number=5):
return {"type": "TOKENS_LIMIT", "unit": unit, "number": number,
"percentage": pct, "nextResetTime": reset_ms}
def _time_limit(pct=0):
return {"type": "TIME_LIMIT", "unit": 5, "number": 1, "usage": 100,
"currentValue": 0, "remaining": 100, "percentage": pct,
"nextResetTime": 9999999999999, "usageDetails": []}
def _ok_resp(limits, level="lite"):
return httpx.Response(200, json={"code": 200, "msg": "Operation successful",
"success": True, "data": {"level": level, "limits": limits}})
def _provider_returning(resp_or_fn):
def handler(request):
return resp_or_fn(request) if callable(resp_or_fn) else resp_or_fn
return ZaiProvider(transport=httpx.MockTransport(handler))
def test_maps_token_windows(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk-zai",
"base_url": "https://api.z.ai/api/anthropic"})
now_ms = time.time() * 1000
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["auth"] = request.headers.get("authorization")
seen["lang"] = request.headers.get("accept-language")
seen["method"] = request.method
return _ok_resp([
_time_limit(), # ignored monthly web-tool quota
_tok(1, now_ms + 3600_000, unit=3, number=5), # 5h window -> short
_tok(2, now_ms + 6 * 86400_000, unit=6, number=1), # weekly window -> weekly
])
st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
assert st.ok is True
assert st.s == 1.0 and 58 <= st.sr <= 60
assert st.w == 2.0 and 8000 <= st.wr <= 8700 # ~6 days in minutes
assert st.st == "Lite"
# raw key auth (no "Bearer"), monitor endpoint on the configured host, GET
assert seen["method"] == "GET"
assert seen["url"] == f"{DEFAULT_HOST}{_MONITOR_PATH}"
assert seen["auth"] == "sk-zai"
assert seen["lang"] == "en-US,en"
def test_no_key(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": ""})
calls = {"n": 0}
def handler(request):
calls["n"] += 1
return _ok_resp([])
st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
assert st.ok is False and st.st == "no key" and st.auth_problem is True
assert calls["n"] == 0
def test_http_401_is_bad_key(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "bad"})
p = _provider_returning(httpx.Response(401, json={"error": "unauthorized"}))
st = _run(p.poll())
assert st.ok is False and st.st == "bad key" and st.auth_problem is True
def test_body_success_false_is_bad_key(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "bad"})
p = _provider_returning(httpx.Response(200, json={"code": 401, "msg": "auth", "success": False, "data": None}))
st = _run(p.poll())
assert st.ok is False and st.st == "bad key" and st.auth_problem is True
def test_single_token_window(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
now_ms = time.time() * 1000
p = _provider_returning(_ok_resp([_tok(37, now_ms + 3600_000)]))
st = _run(p.poll())
assert st.s == 37.0 and st.w == 0.0 and st.wr == -1 and st.ok is True
def test_hundred_percent_is_limited(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
now_ms = time.time() * 1000
p = _provider_returning(_ok_resp([
_tok(100, now_ms + 60_000, unit=3, number=5),
_tok(40, now_ms + 6 * 86400_000, unit=6, number=1)]))
st = _run(p.poll())
assert st.st == "limited" and st.s == 100.0
def test_only_time_limit_reports_connected(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
p = _provider_returning(_ok_resp([_time_limit(5)], level="pro"))
st = _run(p.poll())
assert st.ok is True and st.st == "Pro" and st.s == 0.0
def test_transient_error_reported(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
def handler(request):
raise httpx.ConnectError("boom")
st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
assert st.ok is False and st.st == "offline"
def test_custom_base_host(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk",
"base_url": "https://zzz.example/api/anthropic"})
seen = {}
def handler(request):
seen["url"] = str(request.url)
return _ok_resp([])
_run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
assert seen["url"] == f"https://zzz.example{_MONITOR_PATH}" # monitor path on the same host
+136 -15
View File
@@ -18,6 +18,8 @@ Run: python -m pytest daemon/tests/test_windows_tray.py -x -q
"""
import os
import queue
import subprocess
import sys
import threading
import time
@@ -27,7 +29,13 @@ import time
# 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
# 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:
sys.path.insert(0, _REPO_ROOT)
@@ -61,6 +69,8 @@ class TrayState:
self.state: str = "scanning" # "connected" | "scanning" | "error"
self.reason: str = "" # error reason string (D-04)
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:
self.loop = None # asyncio running loop (for call_soon_threadsafe)
@@ -156,6 +166,34 @@ def _acquire_single_instance():
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)
# ---------------------------------------------------------------------------
@@ -167,6 +205,14 @@ def main() -> None:
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.
"""
# --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
# work. If another tray already owns the session mutex (e.g. ARSO restored a
# console instance and the headless autostart also fired), exit silently.
@@ -180,7 +226,8 @@ def main() -> None:
from pystray import Menu, MenuItem
import daemon.autostart_windows as autostart
from daemon.claude_usage_daemon_windows import main as daemon_main, log as daemon_log
from daemon.claude_usage_daemon_windows import (
main as daemon_main, log as daemon_log, request_provider_switch)
from daemon.icon_assets import load_logo_rgba, build_state_icons
# Build per-state icons once at startup; swap icon.icon per tick (never recomposite).
@@ -190,23 +237,57 @@ def main() -> None:
ts = TrayState()
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:
# daemon=True thread: an unhandled exception here would vanish silently
# and freeze the tray on its last state forever (the field "frozen tray"
# failure mode). Surface it instead — log the traceback to the rotating
# file and flip the tray to an actionable error state.
try:
_asyncio.run(daemon_main(tray_state=ts))
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__}")
# daemon=True thread. The inner loop (daemon_main) is now resilient to the
# BLE adapter vanishing, but as a last resort we also supervise the whole
# asyncio.run: any unexpected crash is logged AND the loop is restarted
# after a backoff, instead of the thread dying and freezing the tray
# forever (field SC: pulling the BT dongle killed the daemon and it never
# came back). A clean return means stop_event was set (Quit) — stop then.
backoff = 1
while not quit_evt.is_set():
try:
_asyncio.run(daemon_main(tray_state=ts))
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.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.set_command_sink(request_provider_switch)
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 ---
def _on_quit(icon_ref, _item) -> None:
# NEVER call ts.stop_event.set() directly from the tray thread;
@@ -219,9 +300,20 @@ def main() -> None:
# 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
# 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:
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)
# 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()
def _on_toggle(_icon_ref, _item) -> None:
@@ -234,9 +326,30 @@ def main() -> None:
autostart.enable(tray_script=os.path.abspath(__file__))
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(
# Non-clickable status header; text updates via update_menu() on state change.
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).
MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()),
MenuItem("Quit", _on_quit),
@@ -266,6 +379,14 @@ def main() -> None:
prev_state["state"] = current
prev_state["last_sync"] = last_sync
_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)
# Blocks the main thread until icon.stop() is called from _on_quit.
+344
View File
@@ -0,0 +1,344 @@
<!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}
.swatch{width:13px;height:13px;border-radius:50%;flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.12)}
label.inline{display:inline-flex;align-items:center;gap:7px;margin:0;color:var(--text);font-size:13px;cursor:pointer}
label.inline input{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
label.inline.off{color:var(--dim);cursor:default}
#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="providers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>Providers</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>
<!-- PROVIDERS -->
<section class="tab" data-tab="providers">
<h1>Providers</h1>
<div class="sub">Which usage source the watch shows — and its brand colour</div>
<div id="prov-list"></div>
<div class="actions"><button class="primary" id="prov-save">Save</button></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));
if(id==="providers") loadProviders().catch(e=>toast("Load failed: "+e.message));
});
// ---- 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); }
};
// ---- Providers ----
let providers = []; // [{id,label,accent,enabled,needs_key,base_url,has_key,...}]
let activeProv = "anthropic";
async function loadProviders(){
const r = await api("/api/providers");
providers = r.providers; activeProv = r.active;
// Seed the editable secret from has_key: a stored key shows (and re-saves) as the
// mask, so leaving it untouched preserves it; typing over it sends the new key.
providers.forEach(p=>{ if(p.needs_key) p.api_key = p.has_key ? MASK : ""; });
renderProviders();
}
function esc(s){return (s||"").replace(/"/g,"&quot;");}
function renderProviders(){
const list=$("#prov-list"); list.innerHTML="";
providers.forEach(p=>{
const card=document.createElement("div"); card.className="card";
const keyBlock = p.needs_key ? `
<div data-keys="${p.id}" style="${p.enabled?"":"display:none"};margin-top:12px">
<label>Base URL</label>
<input type="text" data-pf="base_url" data-pid="${p.id}" value="${esc(p.base_url)}" placeholder="https://api.z.ai/api/anthropic" autocomplete="off">
<label>API key</label>
<input type="password" data-pf="api_key" data-pid="${p.id}" value="${esc(p.api_key)}" placeholder="Paste key" autocomplete="off">
</div>` : "";
const actCls = p.enabled?"inline":"inline off";
card.innerHTML=`
<div class="row" style="justify-content:space-between">
<div class="row" style="gap:10px"><span class="swatch" style="background:#${p.accent}"></span><b style="font-weight:500">${p.label}</b></div>
<div class="row" style="gap:18px">
<label class="inline"><input type="checkbox" data-pen="${p.id}" ${p.enabled?"checked":""}> Enabled</label>
<label class="${actCls}"><input type="radio" name="activeprov" data-pact="${p.id}" ${activeProv===p.id?"checked":""} ${p.enabled?"":"disabled"}> Show on watch</label>
</div>
</div>${keyBlock}`;
list.appendChild(card);
});
list.querySelectorAll("[data-pen]").forEach(el=>el.onchange=()=>{
const p=providers.find(x=>x.id===el.dataset.pen); p.enabled=el.checked;
if(!el.checked && activeProv===p.id){ // disabling the shown one → hand off
const alt=providers.find(x=>x.enabled); activeProv = alt?alt.id:"anthropic";
}
if(el.checked && !providers.some(x=>x.enabled&&x.id===activeProv)) activeProv=p.id;
renderProviders();
});
list.querySelectorAll("[data-pact]").forEach(el=>el.onchange=()=>{activeProv=el.dataset.pact;});
list.querySelectorAll("[data-pf]").forEach(el=>el.oninput=()=>{
providers.find(x=>x.id===el.dataset.pid)[el.dataset.pf]=el.value;
});
}
$("#prov-save").onclick = async ()=>{
const payload={providers:{}, active_provider:activeProv};
providers.forEach(p=>{
const pc={enabled:!!p.enabled};
if(p.needs_key){ pc.base_url=(p.base_url||"").trim(); pc.api_key=p.api_key||""; }
payload.providers[p.id]=pc;
});
try{ await api("/api/config","PUT",payload); toast("Providers saved"); await loadProviders(); }
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.
+89
View File
@@ -0,0 +1,89 @@
# Clawdmeter v3 — multi-provider (alpha)
Extend the watch beyond Claude Code to show usage for several AI coding
providers, let you pick which one the display tracks, and recolor the whole UI
to the active provider's brand.
Branch: **`v3-dev`** (off v2 `d55dc4a`). v2 stays on `v2-dev`.
## Providers (initial set)
| id | provider | used via | brand accent |
| ----------- | --------------------- | -------------------------------- | ------------ |
| `anthropic` | Claude Code | Anthropic OAuth (today's daemon) | `#d97757` clay |
| `openai` | OpenAI Codex | Codex CLI subscription (`~/.codex`) | `#10a37f` green |
| `zai` | z.ai GLM Coding Plan | Anthropic-compatible endpoint | blue (TBD) |
## Locked decisions (2026-07-10)
- **Metric = rate-limit %** — the same two-bar 5h / weekly model as Claude, read
from each provider's CLI subscription. Fall back to spend / token counts for a
provider only if it genuinely doesn't expose limits.
- **Switch on both PC and watch** — pick the active provider in the desktop
panel *and* on the watch (cycle / selector); the theme recolors live.
- **Brand palette** (starting point, tuned on-device): Anthropic clay `#d97757`,
OpenAI `#10a37f`, z.ai blue (exact hex TBD).
## Architecture
### Daemon — provider abstraction
- New `daemon/providers/` package:
- `base.py``Provider` ABC: `id`, `label`, `accent`, `async poll() -> ProviderStatus`.
- `anthropic.py` — today's logic moved here (`poll_api`, `compute_today_usage`,
OAuth refresh), behaviour-identical.
- `openai_codex.py`, `zai.py` — added in M2 / M3.
- `ProviderStatus` = normalized `{s, sr, w, wr, st, ok, tokens_today?, cost_cents_today?}`.
- **Config v2** (`config.py`): add `providers` (per-provider enable + creds),
`active_provider`, `display_order`. Migrate v1→v2 by wrapping the existing
setup as `anthropic`.
- Poll loop drives the **active** provider; for on-watch cycling it may poll all
enabled providers and cache, so switching is instant.
### Payload
- Add **`pv`** (provider id) so the watch knows which theme to wear; usage fields
stay normalized (`s/sr/w/wr/st/tk/tc/...`).
- On-watch switch: watch → PC command **`{"cmd":"prov","id":"..."}`** sets
`active_provider` (same channel as the dimmer / button commands, no new GATT).
### Firmware — runtime theming (the meaty part)
- Convert `theme.h` compile-time `#define`s into a runtime **`Palette`**
(`lv_color_t accent/green/amber/panel/bar_bg/...`) plus shared `lv_style_t`
objects for the accent / panel / text roles.
- `ui_set_theme(pv)` swaps the palette + updates the shared styles + invalidates
**no widget recreation** (LVGL styles propagate to attached widgets).
- Per-provider **logo** on the usage screen (swap the image source).
- On-watch provider switch: a small selector (button on the usage screen, or a
Providers entry in the launcher) → sends `{"cmd":"prov",...}`.
### Panel (desktop)
- Provider selector (which one displays) + per-provider credential / enable
fields. Reuses the existing FastAPI + WebView2 settings panel.
## Milestones
- **M1 — framework + Anthropic refactor + runtime theming.** Provider ABC +
`AnthropicProvider` (identical behaviour) + config v2 + `pv` in the payload.
Firmware runtime palette + shared styles + `ui_set_theme` + per-provider logo
slot + on-watch switch plumbing. Panel provider selector (only Anthropic active
yet). *Deliverable: behaviour unchanged, but themeable and switch-ready.* This
is the big refactor and de-risks M2/M3.
- **M2 — OpenAI (Codex) provider.** Read Codex rate-limit / usage. Source TBD —
Codex is installed at `~/.codex` (`auth.json`, `config.toml`, `state_*.sqlite`,
`logs_*.sqlite`, `sessions/`); investigate whether limits come from the state
DB or ChatGPT backend headers. Green theme + OpenAI logo. Fall back to
spend/tokens if the % model isn't available.
- **M3 — z.ai (GLM Coding Plan) provider.** `ANTHROPIC_BASE_URL` is already set on
the dev machine → z.ai is likely used through its Anthropic-compatible endpoint,
so reuse the Anthropic poll pointed at z.ai's base URL + z.ai key, reading the
same `anthropic-ratelimit-*` headers. Blue theme + z.ai logo.
- **M4 — polish.** On-watch switch UX, per-provider splash decision, panel niceties.
## Open items / assets
- Exact brand hexes (OpenAI `#10a37f` proposed; z.ai blue TBD — finalize on device).
- **Logos** — need official OpenAI + z.ai marks as RGB565 (user provides / points
to source, per the "don't hand-author brand art" preference). Claude logo already
in `logo.h`.
- Splash — currently Claude pixel-art (claudepix); per-provider splash is an open
choice (keep Claude, go neutral, or per-provider).
- Naming — keep **Clawdmeter**; multi-provider under the same identity.
+20
View File
@@ -42,6 +42,11 @@ build_flags =
-DLV_USE_ANIMIMG=0
-DLV_TICK_CUSTOM=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 =
moononournation/GFX Library for Arduino@^1.5.6
@@ -99,6 +104,11 @@ build_flags =
-DLV_USE_ANIMIMG=0
-DLV_TICK_CUSTOM=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 =
; 1.6.4+ ships Arduino_SH8601 in mainline
@@ -157,6 +167,11 @@ build_flags =
-DLV_USE_ANIMIMG=0
-DLV_TICK_CUSTOM=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 =
; 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
; prints SCREENSHOT_UNSUPPORTED on this board.
-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 =
; 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 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 CMD_CHAR_UUID "4c41555a-4465-7669-6365-000000000005" // device → host command (Phase 6)
#define BLE_BUF_SIZE 512
@@ -46,10 +47,10 @@ static const uint8_t HID_REPORT_MAP[] = {
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
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)
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)
0xC0, // End Collection
};
@@ -60,6 +61,7 @@ static NimBLECharacteristic* input_kbd = nullptr;
static NimBLECharacteristic* tx_char = nullptr;
static NimBLECharacteristic* rx_char = nullptr;
static NimBLECharacteristic* req_char = nullptr;
static NimBLECharacteristic* cmd_char = nullptr;
static ble_state_t state = BLE_STATE_INIT;
static bool need_advertise = false;
@@ -201,6 +203,14 @@ void ble_init(void) {
static ReqCallbacks 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();
server->start();
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) {
if (state != BLE_STATE_CONNECTED || !input_kbd) return;
// 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_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
void ble_keyboard_press(uint8_t key, uint8_t modifier);
void ble_keyboard_release(void);
+1
View File
@@ -7,3 +7,4 @@
void imu_hal_init(void) {}
void imu_hal_tick(void) {}
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) {}
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_vbus_in(void) { return false; }
void power_hal_shutdown(void) {} // no controllable PMU
bool power_hal_pwr_pressed(void) { return false; }
// 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.
@@ -23,3 +23,7 @@ void imu_hal_tick(void) {
}
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 int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false;
static bool cached_vbus = false;
static bool pwr_pressed_flag = false;
@@ -49,6 +50,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
}
void power_hal_tick(void) {
@@ -62,6 +64,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now;
cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
}
if (now - last_pwr_ms >= PWR_POLL_MS) {
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_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false;
@@ -5,21 +5,34 @@
#include <SensorQMI8658.hpp>
// 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
// device anyway to keep the shared I2C bus healthy, but always report 0.
// mounts in a fixed orientation, so screen rotation is disabled and the quadrant
// 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 bool imu_ok = false;
void imu_hal_init(void) {
if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) {
Serial.println("QMI8658 init failed");
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) {
// 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; }
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 int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false;
static bool cached_vbus = false;
static bool pwr_pressed_flag = false;
@@ -66,6 +67,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
}
void power_hal_tick(void) {
@@ -79,6 +81,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now;
cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
}
if (now - last_pwr_ms >= PWR_POLL_MS) {
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_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false;
@@ -10,9 +10,11 @@
// reg 0x03 / 0x04: X1 high (low nibble) + X1 low
// reg 0x05 / 0x06: Y1 high (low nibble) + Y1 low
//
// Axis orientation: started with no swap/mirror (matches the portrait FT3168
// on the 1.8 board). If touch coordinates come out flipped/swapped on this
// panel, mirror/swap them in touch_read_into_shared_state() below.
// Axis orientation: verified on hardware (v2 Phase 1). The FT3168 reports
// directly in the panel's pixel space (~0..409 x ~0..501), correctly oriented
// (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_pressed = false;
@@ -64,3 +64,8 @@ void imu_hal_tick(void) {
}
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 int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false;
static bool cached_vbus = false;
static bool pwr_pressed_flag = false;
@@ -49,6 +50,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
}
void power_hal_tick(void) {
@@ -62,6 +64,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now;
cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
}
if (now - last_pwr_ms >= PWR_POLL_MS) {
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_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false;
@@ -24,3 +24,7 @@ void imu_hal_tick(void) {
}
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
static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false;
static bool cached_vbus = false;
static bool pwr_pressed_flag = false;
@@ -50,6 +51,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
}
void power_hal_tick(void) {
@@ -63,6 +65,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now;
cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
}
if (now - last_pwr_ms >= PWR_POLL_MS) {
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_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false;
+12
View File
@@ -9,4 +9,16 @@ struct UsageData {
char status[16]; // "allowed" or "limited"
bool ok; // data parse succeeded
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_tick(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);
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_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.
bool power_hal_pwr_pressed(void);
+152 -14
View File
@@ -7,8 +7,10 @@
#include "data.h"
#include "ui.h"
#include "ble.h"
#include "version.h"
#include "splash.h"
#include "usage_rate.h"
#include "battery_est.h"
#include "idle.h"
#include "idle_cfg.h"
#include "brightness.h"
@@ -22,6 +24,21 @@
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) ----
// 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
@@ -111,7 +128,58 @@ static bool parse_json(const char* json, UsageData* out) {
out->weekly_reset_mins = doc["wr"] | -1;
strlcpy(out->status, doc["st"] | "unknown", sizeof(out->status));
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;
// 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);
}
// v3: provider theme — "pv" (id) selects the logo, "ac" (0xRRGGBB) the brand
// accent. Stamped on every usage payload; ui_set_theme is change-guarded so
// it only repaints on an actual provider switch.
const char* pv = doc["pv"] | (const char*)nullptr;
uint32_t ac = doc["ac"] | 0u;
if (pv != nullptr || ac != 0u) ui_set_theme(pv, ac);
// v3 Provider screen badge — optional "pnm" (name) + "pi"/"pc" (1-based
// position / count among enabled providers). Present on the ~60s heartbeat.
const char* pnm = doc["pnm"] | (const char*)nullptr;
int pi = doc["pi"] | 0;
int pc = doc["pc"] | 0;
if (pnm != nullptr || pc != 0) ui_set_provider_badge(pnm, pi, pc);
return true;
}
@@ -180,7 +248,7 @@ extern "C" void board_init(void);
void setup() {
Serial.begin(115200);
delay(300);
Serial.println("{\"ready\":true}");
Serial.printf("{\"ready\":true,\"fw\":\"%s\"}\n", CLAWDMETER_VERSION);
board_init();
@@ -219,7 +287,7 @@ void setup() {
ui_init();
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);
Serial.printf("Dashboard ready (%s, %dx%d), waiting for data on BLE...\n",
@@ -299,14 +367,22 @@ void loop() {
{
static bool primary_was = 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);
if (primary_now != primary_was) {
if (primary_now) {
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 {
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;
}
@@ -329,30 +405,92 @@ void loop() {
if (power_hal_pwr_pressed()) {
if (!idle_consume_wake_press()) {
// On splash: cycle animations. On the usage view: cycle
// screen brightness (single non-splash view, no more screens).
if (ui_get_current_screen() == SCREEN_SPLASH) splash_next();
else brightness_cycle();
// On splash: cycle animations. On the dimmer: switch the
// controlled parameter (brightness ⇄ temp). Elsewhere: cycle
// screen brightness.
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();
}
// 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();
if (bs != last_ble_state) {
last_ble_state = bs;
ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address());
}
static int last_pct = -2;
static bool last_charging = false;
// ---- Battery telemetry → UI + time-left estimate ----
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 mv = power_hal_battery_mv();
bool charging = power_hal_is_charging();
if (pct != last_pct || charging != last_charging) {
last_pct = pct;
last_charging = charging;
ui_update_battery(pct, charging);
int mv_bucket = (mv <= 0) ? -1 : (mv / 20); // 20 mV buckets — ignore sub-bucket jitter
uint32_t now_ms = millis();
bool bat_changed = (pct != last_pct) || (charging != last_charging) || (mv_bucket != last_mv_bucket);
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();
+2 -1
View File
@@ -7,7 +7,8 @@
#define THEME_PANEL lv_color_hex(0x1f1f1e) // card/zone fill
#define THEME_TEXT lv_color_hex(0xfaf9f5) // primary text
#define THEME_DIM lv_color_hex(0xb0aea5) // secondary text
#define THEME_ACCENT lv_color_hex(0xd97757) // brand terra-cotta
#define THEME_ACCENT_HEX 0xd97757 // brand terra-cotta (raw, for runtime palette)
#define THEME_ACCENT lv_color_hex(THEME_ACCENT_HEX) // brand terra-cotta
#define THEME_GREEN lv_color_hex(0x788c5d)
#define THEME_AMBER lv_color_hex(0xd97757)
#define THEME_RED lv_color_hex(0xc0392b)
+1241 -20
View File
File diff suppressed because it is too large Load Diff
+55 -3
View File
@@ -3,16 +3,68 @@
#include "ble.h"
enum screen_t {
SCREEN_SPLASH,
SCREEN_USAGE,
SCREEN_SPLASH, // brand animation (boot) + "Animations" app
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_PROVIDER, // which usage source the watch displays (v3) + Switch
SCREEN_BATTERY, // battery detail (voltage + time left) — opened by tapping the battery icon
SCREEN_COUNT,
};
void ui_init(void);
// v3 multi-provider theming. The daemon tags each usage payload with the active
// provider id (`pv`, e.g. "anthropic"/"openai"/"zai") and its brand accent
// (`ac`, 0xRRGGBB). ui_set_theme recolors the UI live via a shared accent style
// and swaps the per-provider logo. accent_rgb == 0 keeps the current accent.
// Change-guarded, so it's safe to call on every payload.
void ui_set_theme(const char* provider_id, uint32_t accent_rgb);
// v3 Provider screen badge: the active provider's human name (`pnm`) and its
// 1-based position/count among the enabled providers (`pi`/`pc`), so the screen
// shows "Claude Code · 1 / 3" and hides the Switch button when only one exists.
// Safe to call on every payload. name==nullptr keeps the current name.
void ui_set_provider_badge(const char* name, int index, int count);
void ui_update(const UsageData* data);
void ui_tick_anim(void);
void ui_show_screen(screen_t screen);
void ui_toggle_splash(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_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)
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""One-off diagnostic for the z.ai GLM Coding Plan usage endpoint (the source
ZaiProvider reads). Dumps the raw quota JSON so the window mapping can be
checked against a live plan.
Usage (key via env so it never lands in shell history):
ZAI_API_KEY=... python tools/probe_zai.py [host]
Default host = https://api.z.ai . It's a status GET — it does NOT spend the
prompt-metered plan. The API key is read from the environment and never printed.
"""
import json
import os
import sys
import httpx
HOST = (sys.argv[1] if len(sys.argv) > 1 else "https://api.z.ai").rstrip("/")
KEY = os.environ.get("ZAI_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN")
if not KEY:
sys.exit("Set ZAI_API_KEY in the environment first (it is not printed).")
url = f"{HOST}/api/monitor/usage/quota/limit"
print(f"GET {url}")
try:
resp = httpx.get(url, headers={"Authorization": KEY, "Accept-Language": "en-US,en"}, timeout=30.0)
except httpx.HTTPError as e:
sys.exit(f"request failed: {e}")
print(f"HTTP {resp.status_code}\n")
try:
print(json.dumps(resp.json(), indent=2, ensure_ascii=False))
except ValueError:
print(resp.text[:1000])
+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)")