15 Commits
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
48 changed files with 3973 additions and 143 deletions
+8
View File
@@ -27,3 +27,11 @@ __pycache__/
/build/ /build/
/dist/ /dist/
*_dbg.spec *_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
+1 -1
View File
@@ -141,7 +141,7 @@ Run `pio run -d firmware` with no env to see the available board envs.
### Pair the device ### Pair the device
The device is a bonded BLE HID keyboard, so pair it once: **Settings → Bluetooth & devices → Add device → Bluetooth**, then select "Claude Controller". Pairing is **required** — it enables the physical buttons and keeps a persistent connection (the device keeps showing your last-synced usage even after the daemon quits). To undo, use **Remove device** (this disables the buttons). The device is a bonded BLE HID keyboard, so pair it once: **Settings → Bluetooth & devices → Add device → Bluetooth**, then select "Clawdmeter". Pairing is **required** — it enables the physical buttons and keeps a persistent connection (the device keeps showing your last-synced usage even after the daemon quits). To undo, use **Remove device** (this disables the buttons).
### Install the daemon (recommended) ### Install the daemon (recommended)
+3 -3
View File
@@ -1,4 +1,4 @@
# build-exe.ps1 build the standalone Clawdmeter.exe (PyInstaller). # build-exe.ps1 - build the standalone Clawdmeter.exe (PyInstaller).
# #
# Produces dist\Clawdmeter.exe: a single self-contained tray + daemon executable # 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 # that runs on ANY Windows 11 machine with no Python install and no pip. Build it
@@ -26,7 +26,7 @@ Log "=== Clawdmeter exe build ==="
if (-not (Test-Path $PythonExe)) { if (-not (Test-Path $PythonExe)) {
Log "Creating virtual environment at .venv ..." Log "Creating virtual environment at .venv ..."
& python -m venv $VenvDir & python -m venv $VenvDir
if ($LASTEXITCODE -ne 0) { throw "venv creation failed (exit $LASTEXITCODE) is Python on PATH?" } if ($LASTEXITCODE -ne 0) { throw "venv creation failed (exit $LASTEXITCODE) - is Python on PATH?" }
} }
# 2. Runtime dependencies + PyInstaller (the only build-time extra) # 2. Runtime dependencies + PyInstaller (the only build-time extra)
@@ -45,4 +45,4 @@ $ExePath = Join-Path $RepoRoot "dist\Clawdmeter.exe"
if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" } if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" }
$sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1) $sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1)
Log "Build complete: $ExePath ($sizeMB MB)" Log "Build complete: $ExePath ($sizeMB MB)"
Log "Distribute this exe via a Gitea release it is intentionally not committed to git." Log "Distribute this exe via a Gitea release - it is intentionally not committed to git."
+27 -2
View File
@@ -21,18 +21,43 @@
from PyInstaller.utils.hooks import collect_all from PyInstaller.utils.hooks import collect_all
datas = [('firmware/src/logo.h', 'firmware/src')] # tray icon parsed at runtime 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 = [] binaries = []
hiddenimports = [ hiddenimports = [
# Imported lazily inside tray_windows.main(), so name them explicitly. # Imported lazily inside tray_windows.main(), so name them explicitly.
'daemon.claude_usage_daemon_windows', 'daemon.claude_usage_daemon_windows',
'daemon.autostart_windows', 'daemon.autostart_windows',
'daemon.icon_assets', '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. # The exact winrt media modules read_now_playing() pulls in.
'winrt.windows.media', 'winrt.windows.media',
'winrt.windows.media.control', 'winrt.windows.media.control',
# pywebview's Windows backend + pythonnet bridge load these dynamically.
'webview.platforms.edgechromium',
'clr',
] ]
for _pkg in ('winrt', 'bleak', 'pystray', 'PIL'): # 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) _d, _b, _h = collect_all(_pkg)
datas += _d datas += _d
binaries += _b binaries += _b
+7 -1
View File
@@ -65,8 +65,14 @@ 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 `Clawdmeter.exe`. It bundles its own Python, the WinRT BLE stack and the
media-session reader, so nothing needs to be installed. 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)). 1. Pair the device with Windows once (see [Pair the device](#pair-the-device-one-time)).
2. Get `Clawdmeter.exe` — download it from the project's Gitea release, or build it 2. Get `Clawdmeter.exe` — download it from the
[latest Gitea release](https://gitea.bvrdo.online/wenil/clawdmeter/releases/latest), or build it
yourself (below). yourself (below).
3. Double-click `Clawdmeter.exe`. The tray icon appears and the watch starts 3. Double-click `Clawdmeter.exe`. The tray icon appears and the watch starts
updating within ~10 seconds. updating within ~10 seconds.
+339 -55
View File
@@ -32,6 +32,14 @@ DEVICE_ADDRESS = os.environ.get("CLAWDMETER_ADDRESS")
SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001" SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001"
RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002" RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002"
REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004" REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004"
CMD_CHAR_UUID = "4c41555a-4465-7669-6365-000000000005" # watch → host commands (Phase 6 HA)
# 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) POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds)
NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The
@@ -296,9 +304,58 @@ async def scan_for_device():
class Session: class Session:
def __init__(self, client: BleakClient) -> None: def __init__(self, client: BleakClient, tray_state=None) -> None:
self.client = client self.client = client
self.tray_state = tray_state
self.refresh_requested = asyncio.Event() self.refresh_requested = asyncio.Event()
self.dim_requested = asyncio.Event() # watch opened the Dimmer screen → push a fresh light snapshot
self.ha = None # HAClient once a valid HA config is loaded
self.ha_entities: list = [] # controllable entity_ids from the config
self._loop = None # event loop captured for thread-safe BLE dispatch
self.low_bat_pct = 15 # warn at/below this watch battery %
self._battery_warned = False
self._last_logged_bat = None
self.buttons = [] # config buttons; list index == watch button index
def reload_buttons(self) -> None:
"""Re-read the config's button list so panel edits propagate to the watch
(called each poll). The list index is the index the watch reports back."""
try:
try:
from daemon.config import load_config
except ImportError:
from config import load_config
self.buttons = load_config().get("buttons") or []
except Exception as e:
log(f"Button reload failed: {e!r}")
def button_labels(self) -> list:
"""Compact label list for the RX 'btns' payload (count + length capped so
the merged BLE write stays under the firmware's 512-byte buffer)."""
out = []
for b in self.buttons[:WATCH_MAX_BUTTONS]:
lbl = str(b.get("label") or b.get("entity") or "Button").strip()
out.append(lbl[:WATCH_LABEL_MAX])
return out
async def dim_snapshot(self) -> dict | None:
"""Compact live state of the tilt-dimmer's light for the RX 'dim' object,
so the watch dial seeds from reality. The dimmer targets the first HA
entity (same default the bri/ct commands fall back to). Returns None when
HA is off or the read fails — the watch then just shows its last value."""
if not self.ha or not self.ha_entities:
return None
snap = await self.ha.light_snapshot(self.ha_entities[0])
if not snap:
return None
out = {"on": 1 if snap["on"] else 0,
"mink": int(snap["min_kelvin"]),
"maxk": int(snap["max_kelvin"])}
if snap["brightness_pct"] is not None:
out["bri"] = int(snap["brightness_pct"])
if snap["color_temp_kelvin"] is not None:
out["ct"] = int(snap["color_temp_kelvin"])
return out
def _on_refresh(self, _char, _data: bytearray) -> None: def _on_refresh(self, _char, _data: bytearray) -> None:
log("Refresh requested by device") log("Refresh requested by device")
@@ -316,6 +373,163 @@ class Session:
except (BleakError, ValueError, OSError) as e: except (BleakError, ValueError, OSError) as e:
log(f"Refresh subscription unavailable: {e}") log(f"Refresh subscription unavailable: {e}")
async def setup_command_subscription(self) -> None:
# Watch → host channel (…0005): battery telemetry (always) plus HA commands
# (when HA is configured). We subscribe regardless of HA so the low-battery
# warning works even with Home Assistant unset. Imports are lazy so a
# missing config/dep can never break daemon startup, and so they resolve
# after `log` is defined (ha_client imports it).
self._loop = asyncio.get_running_loop() # thread-safe dispatch from the BLE callback
try:
try:
from daemon.ha_client import HAClient
from daemon.config import load_config, ha_settings
except ImportError:
from ha_client import HAClient
from config import load_config, ha_settings
cfg = load_config()
self.low_bat_pct = int(cfg.get("settings", {}).get("low_battery_pct", 15))
self.buttons = cfg.get("buttons") or []
ha = ha_settings(cfg)
if ha:
self.ha = HAClient(ha["url"], ha["token"])
self.ha_entities = ha["entities"]
except Exception as e:
log(f"Config/HA init failed ({e!r}); HA control disabled")
try:
await self.client.start_notify(CMD_CHAR_UUID, self._on_command)
log(f"Command channel ready (HA={'on' if self.ha else 'off'}, "
f"low-batt={self.low_bat_pct}%, entities={self.ha_entities})")
except (BleakError, ValueError, OSError) as e:
log(f"Command subscription unavailable: {e}")
def _on_command(self, _char, data: bytearray) -> None:
try:
payload = json.loads(bytes(data).decode("utf-8", "replace"))
except (ValueError, UnicodeDecodeError):
log(f"Watch msg: bad payload {bytes(data)!r}")
return
# Battery telemetry ({"bat":pct,"mv":..,"chg":0/1}) is handled inline (quick,
# no await). HA commands ({"cmd":...}) are dispatched onto the loop because
# bleak may deliver this callback on a non-loop thread.
if "bat" in payload:
self._handle_battery(payload)
return
# Dimmer screen opened (M3): ask the poll loop to push a fresh light
# snapshot on its next tick (~3s). asyncio.Event isn't thread-safe and
# this callback may run off-loop, so flip it via the loop.
if payload.get("cmd") == "dimreq":
loop = self._loop
if loop is not None:
loop.call_soon_threadsafe(self.dim_requested.set)
return
# 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: async def write_payload(self, payload: dict) -> bool:
# ensure_ascii=False keeps non-Latin titles as compact UTF-8 (~2 bytes/char) # 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 # instead of \uXXXX (6 bytes), which roughly thirds the size of a Cyrillic
@@ -612,6 +826,75 @@ async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
await asyncio.gather(*tasks, return_exceptions=True) 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: async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool:
"""Connect to device and poll until disconnected or stopped. """Connect to device and poll until disconnected or stopped.
@@ -661,8 +944,11 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
return False return False
log("Connected") log("Connected")
session = Session(client) session = Session(client, tray_state)
global _active_session
_active_session = session # let the control panel reach this session for live switches
await session.setup_refresh_subscription() await session.setup_refresh_subscription()
await session.setup_command_subscription()
# Two cadences share one connection: the Anthropic usage / rate-limit poll runs # Two cadences share one connection: the Anthropic usage / rate-limit poll runs
# every POLL_INTERVAL (60s) while the Windows media session is read every # every POLL_INTERVAL (60s) while the Windows media session is read every
@@ -680,60 +966,36 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
now = time.time() now = time.time()
claude_due = (session.refresh_requested.is_set() claude_due = (session.refresh_requested.is_set()
or (now - last_claude_poll) >= POLL_INTERVAL) or (now - last_claude_poll) >= POLL_INTERVAL)
dim_due = session.dim_requested.is_set() # watch opened the Dimmer screen
if dim_due:
session.dim_requested.clear()
auth_problem = False auth_problem = False
if claude_due: if claude_due:
session.refresh_requested.clear() session.refresh_requested.clear()
session.reload_buttons() # pick up desktop-panel edits to the button set
# Local token usage (Session screen) needs no network — compute it # v3: poll whichever provider is active (config-driven, so an
# every cycle so the watch keeps updating even with no/expired token. # on-watch switch takes effect on the next cycle). The provider
fresh = compute_today_usage() # normalizes its own auth + rate-limit + local usage into a
# ProviderStatus and never raises — a genuine auth failure sets
# Rate-limit utilization is best-effort. A genuine 401/403 flags the # auth_problem, a transient blip just leaves ok=False (SC#5).
# token; a transient failure (network/DNS/5xx) leaves the tray state # Today only Anthropic is real; OpenAI/z.ai are stubs until M2/M3.
# alone (SC#5: a DNS blip must not read as "token expired"). active_pv, provider = _active_provider()
rl = None status = await provider.poll()
auth_problem = status.auth_problem
# Self-refresh the OAuth token before using it. In a managed cached = status.to_payload()
# (Agent SDK) environment `claude login` is unavailable, so the cached["pv"] = active_pv # which brand theme the watch wears
# daemon renews its own access token from the refresh token — cached["pnm"] = str(provider.label)[:16] # name for the Provider screen
# otherwise the rate-limit % goes dark forever once it expires. try: # brand accent (0xRRGGBB) for the theme
try: cached["ac"] = int(provider.accent, 16)
await refresh_token_if_needed() except (ValueError, TypeError):
except Exception as e: # belt-and-braces: never crash the loop pass
log(f"Token refresh skipped: {e!r}") # 1-based position + count among enabled providers, so the watch's
# Provider screen shows "1/3" and hides the switch hint when alone.
token = read_token() # D-09: fresh each cycle (post-refresh) _ids = _enabled_ids()
if not token: cached["pc"] = len(_ids)
auth_problem = True cached["pi"] = (_ids.index(active_pv) + 1) if active_pv in _ids else 1
log("No token; sending local usage only")
else:
try:
rl = await poll_api(token)
except AuthError:
# Rejected despite the proactive refresh (e.g. expiresAt
# was stale/missing so we skipped it). Force one refresh
# and retry the poll once before flagging the token 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
if rl is not None:
fresh.update(rl)
fresh["ok"] = True # rate-limit data is fresh
else:
fresh["ok"] = False # rate-limit unknown -> watch usage view goes idle
cached = fresh
last_claude_poll = now last_claude_poll = now
# Now Playing (Phase 5) — best-effort Windows media session, read every # Now Playing (Phase 5) — best-effort Windows media session, read every
@@ -744,13 +1006,29 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
log(f"Now-playing skipped: {e!r}") log(f"Now-playing skipped: {e!r}")
np = {"np": 0} 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 # Write when the usage data was just refreshed (this is also the ~60s
# heartbeat) or when the track / playback state changed since the last # heartbeat), when the track / playback state changed since the last
# write — skip otherwise so the link, and the field log, stay quiet # write, or when the watch asked for a dimmer snapshot — skip otherwise
# between changes instead of repeating an identical payload every 3s. # so the link, and the field log, stay quiet between changes.
if claude_due or np != last_np_sent: if claude_due or dim_due or np != last_np_sent:
payload = dict(cached) payload = dict(cached)
payload.update(np) 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): if await session.write_payload(payload):
used_successfully = True used_successfully = True
consecutive_failures = 0 # D-03: reset on success consecutive_failures = 0 # D-03: reset on success
@@ -783,6 +1061,7 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
await _wait_first(session.refresh_requested, stop_event, await _wait_first(session.refresh_requested, stop_event,
timeout=NOWPLAYING_INTERVAL) timeout=NOWPLAYING_INTERVAL)
finally: 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 # Clean GATT disconnect on the way out — this is what tells the peripheral
# the link is gone. WinRT can surface a raw OSError (not BleakError) here, # the link is gone. WinRT can surface a raw OSError (not BleakError) here,
# so swallow both; the link tears down regardless once we exit. # so swallow both; the link tears down regardless once we exit.
@@ -790,6 +1069,11 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
await client.disconnect() await client.disconnect()
except (BleakError, OSError): except (BleakError, OSError):
pass pass
if session.ha:
try:
await session.ha.aclose()
except Exception:
pass
log("Device disconnected" if not stop_event.is_set() else "Stopping") log("Device disconnected" if not stop_event.is_set() else "Stopping")
return used_successfully return used_successfully
+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,
)
+7
View File
@@ -9,3 +9,10 @@ Pillow
# media-control projection. pip resolves them to the same winrt-runtime as bleak. # media-control projection. pip resolves them to the same winrt-runtime as bleak.
winrt-Windows.Media winrt-Windows.Media
winrt-Windows.Media.Control 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 -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. # Must NOT raise OSError — graceful degradation into the poll loop.
result = _run(connect_and_run(device, stop_event)) result = _run(connect_and_run(device, stop_event))
# start_notify was actually attempted (and raised), but was swallowed. # start_notify was actually attempted (and raised), but was swallowed — for
assert mock_client.start_notify.call_count == 1 # 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. # Function returned normally instead of propagating the OSError.
assert result is False assert result is False
# The link was cleaned up via the finally block. # 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
+98 -1
View File
@@ -18,6 +18,8 @@ Run: python -m pytest daemon/tests/test_windows_tray.py -x -q
""" """
import os import os
import queue
import subprocess
import sys import sys
import threading import threading
import time import time
@@ -67,6 +69,8 @@ class TrayState:
self.state: str = "scanning" # "connected" | "scanning" | "error" self.state: str = "scanning" # "connected" | "scanning" | "error"
self.reason: str = "" # error reason string (D-04) self.reason: str = "" # error reason string (D-04)
self.last_sync: float | None = None # time.time() of last successful write self.last_sync: float | None = None # time.time() of last successful write
self.battery_pct: int | None = None # latest watch battery %, from the …0005 channel
self.toasts: "queue.Queue" = queue.Queue() # (title, message) toasts for the tray to show
# Populated by daemon main() at startup: # Populated by daemon main() at startup:
self.loop = None # asyncio running loop (for call_soon_threadsafe) self.loop = None # asyncio running loop (for call_soon_threadsafe)
@@ -162,6 +166,34 @@ def _acquire_single_instance():
return handle return handle
# ---------------------------------------------------------------------------
# control-panel glue (Phase 7): live status feed + settings-window subprocess
# ---------------------------------------------------------------------------
def _status_dict(ts: TrayState) -> dict:
"""Live status for the control panel's GET /api/status. Shape matches what
web/index.html reads (connected / state / battery / last_sync); a pure read
of TrayState scalars, safe to call from the server's request thread."""
return {
"connected": ts.state == "connected",
"state": ts.state,
"reason": ts.reason,
"battery": ts.battery_pct,
"last_sync": ts.last_sync,
}
def _panel_argv() -> list:
"""Command that launches the settings window as a SEPARATE process. Frozen:
re-invoke this same exe with --panel. Source: run panel.py by ABSOLUTE path
not ``-m daemon.panel``, which would break under autostart (cwd = System32).
panel.py rebuilds its own sys.path from __file__, so cwd doesn't matter. Kept
a separate process because pywebview wants the main thread, which pystray owns."""
if getattr(sys, "frozen", False):
return [sys.executable, "--panel"]
return [sys.executable, os.path.join(_REPO_ROOT, "daemon", "panel.py")]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# main() — tray entry (pystray on main thread, daemon loop in bg thread) # main() — tray entry (pystray on main thread, daemon loop in bg thread)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -173,6 +205,14 @@ def main() -> None:
so the module can be imported on a GTK-less Linux dev box for unit tests so the module can be imported on a GTK-less Linux dev box for unit tests
of the pure helpers (TrayState, header_text) without pystray failing. of the pure helpers (TrayState, header_text) without pystray failing.
""" """
# --panel: we ARE the settings window, launched as a child of the tray. Open
# it and exit WITHOUT touching the single-instance mutex or the BLE daemon —
# pywebview owns this process's main thread; the tray owns the other one.
if "--panel" in sys.argv:
from daemon.panel import run as run_panel
run_panel()
return
# Single-instance guard FIRST — before icons, the daemon thread, or any BLE # Single-instance guard FIRST — before icons, the daemon thread, or any BLE
# work. If another tray already owns the session mutex (e.g. ARSO restored a # work. If another tray already owns the session mutex (e.g. ARSO restored a
# console instance and the headless autostart also fired), exit silently. # console instance and the headless autostart also fired), exit silently.
@@ -186,7 +226,8 @@ def main() -> None:
from pystray import Menu, MenuItem from pystray import Menu, MenuItem
import daemon.autostart_windows as autostart 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 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). # Build per-state icons once at startup; swap icon.icon per tick (never recomposite).
@@ -227,6 +268,26 @@ def main() -> None:
daemon_thread = threading.Thread(target=_run_daemon, daemon=True) daemon_thread = threading.Thread(target=_run_daemon, daemon=True)
daemon_thread.start() daemon_thread.start()
# --- control-panel HTTP server (one process, one exe) ---
# Serve the brand UI + REST API on 127.0.0.1 in a daemon thread, and feed it
# live BLE/daemon status. Best-effort: a server failure must never stop the
# tray itself from coming up (the watch sync is the primary job).
panel_port = None
try:
from daemon import server as panel_server
from daemon.config import DEFAULT_PORT
panel_port = DEFAULT_PORT
panel_server.set_status_provider(lambda: _status_dict(ts))
panel_server.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 --- # --- menu ---
def _on_quit(icon_ref, _item) -> None: def _on_quit(icon_ref, _item) -> None:
# NEVER call ts.stop_event.set() directly from the tray thread; # NEVER call ts.stop_event.set() directly from the tray thread;
@@ -246,6 +307,13 @@ def main() -> None:
except RuntimeError: except RuntimeError:
pass # loop already closed (e.g. mid-restart) — quit_evt handles it pass # loop already closed (e.g. mid-restart) — quit_evt handles it
daemon_thread.join(timeout=6.0) daemon_thread.join(timeout=6.0)
# Close the settings window too, if the user left it open.
proc = _panel["proc"]
if proc is not None and proc.poll() is None:
try:
proc.terminate()
except Exception:
pass
icon_ref.stop() icon_ref.stop()
def _on_toggle(_icon_ref, _item) -> None: def _on_toggle(_icon_ref, _item) -> None:
@@ -258,9 +326,30 @@ def main() -> None:
autostart.enable(tray_script=os.path.abspath(__file__)) autostart.enable(tray_script=os.path.abspath(__file__))
icon.update_menu() icon.update_menu()
def _on_settings(_icon_ref, _item) -> None:
# Open the WebView2 settings window as a child process. If one is already
# alive, leave it — re-spawning would stack duplicate windows.
proc = _panel["proc"]
if proc is not None and proc.poll() is None:
return
env = dict(os.environ)
if panel_port:
env["CLAWDMETER_PANEL_PORT"] = str(panel_port)
kwargs = {}
if sys.platform == "win32":
# No phantom console window for the child (it's a GUI of its own).
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
try:
_panel["proc"] = subprocess.Popen(_panel_argv(), env=env, **kwargs)
except Exception as e:
daemon_log(f"Could not open settings window: {e!r}")
icon.menu = Menu( icon.menu = Menu(
# Non-clickable status header; text updates via update_menu() on state change. # Non-clickable status header; text updates via update_menu() on state change.
MenuItem(lambda _item: header_text(ts), None, enabled=False), MenuItem(lambda _item: header_text(ts), None, enabled=False),
# Settings = the WebView2 control panel. default=True opens it on a plain
# left-click of the tray icon (right-click still shows the full menu).
MenuItem("Settings", _on_settings, default=True),
# Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6). # Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6).
MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()), MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()),
MenuItem("Quit", _on_quit), MenuItem("Quit", _on_quit),
@@ -290,6 +379,14 @@ def main() -> None:
prev_state["state"] = current prev_state["state"] = current
prev_state["last_sync"] = last_sync prev_state["last_sync"] = last_sync
_icon.update_menu() _icon.update_menu()
# Drain daemon-queued toasts (e.g. low watch battery) — runs every
# tick regardless of state change.
try:
while True:
_title, _msg = ts.toasts.get_nowait()
_icon.notify(_msg, _title)
except queue.Empty:
pass
time.sleep(1.0) time.sleep(1.0)
# Blocks the main thread until icon.stop() is called from _on_quit. # Blocks the main thread until icon.stop() is called from _on_quit.
+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>
+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.
+15
View File
@@ -42,6 +42,11 @@ build_flags =
-DLV_USE_ANIMIMG=0 -DLV_USE_ANIMIMG=0
-DLV_TICK_CUSTOM=1 -DLV_TICK_CUSTOM=1
-DLV_USE_SNAPSHOT=1 -DLV_USE_SNAPSHOT=1
; Montserrat carries the LVGL symbol glyphs (FontAwesome subset) used for the
; v2 launcher tile icons and the back chevron. The brand Styrene/Tiempos fonts
; are Latin-only and have no symbols. Shared ui.cpp needs these on every board.
-DLV_FONT_MONTSERRAT_20=1
-DLV_FONT_MONTSERRAT_28=1
lib_deps = lib_deps =
moononournation/GFX Library for Arduino@^1.5.6 moononournation/GFX Library for Arduino@^1.5.6
@@ -99,6 +104,11 @@ build_flags =
-DLV_USE_ANIMIMG=0 -DLV_USE_ANIMIMG=0
-DLV_TICK_CUSTOM=1 -DLV_TICK_CUSTOM=1
-DLV_USE_SNAPSHOT=1 -DLV_USE_SNAPSHOT=1
; Montserrat carries the LVGL symbol glyphs (FontAwesome subset) used for the
; v2 launcher tile icons and the back chevron. The brand Styrene/Tiempos fonts
; are Latin-only and have no symbols. Shared ui.cpp needs these on every board.
-DLV_FONT_MONTSERRAT_20=1
-DLV_FONT_MONTSERRAT_28=1
lib_deps = lib_deps =
; 1.6.4+ ships Arduino_SH8601 in mainline ; 1.6.4+ ships Arduino_SH8601 in mainline
@@ -230,6 +240,11 @@ build_flags =
; 480×480) which doesn't fit in C6 internal SRAM. send_screenshot ; 480×480) which doesn't fit in C6 internal SRAM. send_screenshot
; prints SCREENSHOT_UNSUPPORTED on this board. ; prints SCREENSHOT_UNSUPPORTED on this board.
-DLV_USE_SNAPSHOT=0 -DLV_USE_SNAPSHOT=0
; Montserrat carries the LVGL symbol glyphs (FontAwesome subset) used for the
; v2 launcher tile icons and the back chevron. The brand Styrene/Tiempos fonts
; are Latin-only and have no symbols. Shared ui.cpp needs these on every board.
-DLV_FONT_MONTSERRAT_20=1
-DLV_FONT_MONTSERRAT_28=1
lib_deps = lib_deps =
; this board uses the CO5300 (same controller as the S3 2.16); Arduino_CO5300 ; this board uses the CO5300 (same controller as the S3 2.16); Arduino_CO5300
+55
View File
@@ -0,0 +1,55 @@
#include "battery_est.h"
#include <Arduino.h>
// Anchor-based, not a ring buffer (cf. usage_rate.cpp): we remember (time, pct)
// at the moment discharge began and project from the average rate since then.
// rate = (anchor_pct - pct_now) / minutes_elapsed
// minutes_left = pct_now / rate
// A long baseline beats a short ring for a signal that only ticks every few
// minutes — the estimate just keeps tightening as the session runs. We withhold
// a number until the cell has dropped a meaningful amount over a few minutes, so
// the first figure isn't built on fuel-gauge settling noise right after boot.
#define EST_MIN_DROP_PCT 3 // need >= 3% drained since the anchor
#define EST_MIN_ELAPSED_MS 300000UL // ...over at least 5 minutes
#define EST_MAX_MINUTES (100 * 60)
static bool have_anchor = false;
static uint32_t anchor_ms = 0;
static int anchor_pct = -1;
static int last_pct = -1;
static bool last_charging = false;
void battery_est_update(int percent, bool charging) {
last_charging = charging;
last_pct = percent;
if (charging || percent < 0) {
// On USB / charging / no reading the estimate is meaningless. Drop the
// anchor so the next discharge starts from a fresh baseline.
have_anchor = false;
return;
}
// Discharging. Start an anchor, or re-anchor if the gauge ticked UP (it
// relaxes upward when load drops) so we never compute a negative rate.
if (!have_anchor || percent > anchor_pct) {
have_anchor = true;
anchor_ms = millis();
anchor_pct = percent;
}
}
int battery_est_minutes(void) {
if (last_charging || last_pct < 0) return -2;
if (!have_anchor) return -1;
int drop = anchor_pct - last_pct;
uint32_t dt = millis() - anchor_ms;
if (drop < EST_MIN_DROP_PCT || dt < EST_MIN_ELAPSED_MS) return -1;
// Minutes each 1% has been taking, extrapolated across the remaining pct.
float minutes_per_pct = (float)dt / 60000.0f / (float)drop;
float mins = (float)last_pct * minutes_per_pct;
if (mins < 0) return -1;
if (mins > EST_MAX_MINUTES) mins = EST_MAX_MINUTES;
return (int)(mins + 0.5f);
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
// Rough "time left on battery" estimator. Watches the AXP fuel-gauge percentage
// while discharging and projects minutes-to-empty from the average drain rate
// since discharge began. Coarse by design — the gauge moves in slow 1% steps —
// so the UI shows it as "~Xh Ym left", emphasis on the ~.
//
// Feed it the latest percent + charging flag whenever a fresh battery reading
// lands (the daemon-independent battery poll in main.cpp).
void battery_est_update(int percent, bool charging);
// Estimated minutes remaining, or:
// -2 charging / on USB (no estimate to give)
// -1 not enough discharge history yet ("estimating…")
int battery_est_minutes(void);
+18
View File
@@ -10,6 +10,7 @@
#define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here #define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here
#define TX_CHAR_UUID "4c41555a-4465-7669-6365-000000000003" // device ack/nack notifies #define TX_CHAR_UUID "4c41555a-4465-7669-6365-000000000003" // device ack/nack notifies
#define REQ_CHAR_UUID "4c41555a-4465-7669-6365-000000000004" // device-initiated refresh request #define REQ_CHAR_UUID "4c41555a-4465-7669-6365-000000000004" // device-initiated refresh request
#define CMD_CHAR_UUID "4c41555a-4465-7669-6365-000000000005" // device → host command (Phase 6)
#define BLE_BUF_SIZE 512 #define BLE_BUF_SIZE 512
@@ -60,6 +61,7 @@ static NimBLECharacteristic* input_kbd = nullptr;
static NimBLECharacteristic* tx_char = nullptr; static NimBLECharacteristic* tx_char = nullptr;
static NimBLECharacteristic* rx_char = nullptr; static NimBLECharacteristic* rx_char = nullptr;
static NimBLECharacteristic* req_char = nullptr; static NimBLECharacteristic* req_char = nullptr;
static NimBLECharacteristic* cmd_char = nullptr;
static ble_state_t state = BLE_STATE_INIT; static ble_state_t state = BLE_STATE_INIT;
static bool need_advertise = false; static bool need_advertise = false;
@@ -201,6 +203,14 @@ void ble_init(void) {
static ReqCallbacks reqCb; static ReqCallbacks reqCb;
req_char->setCallbacks(&reqCb); req_char->setCallbacks(&reqCb);
// Watch → host command channel (Phase 6). The daemon subscribes; the watch
// notifies a short JSON command which the daemon maps to a Home Assistant
// service call. Notify-only, same direction as the refresh char.
cmd_char = svc->createCharacteristic(
CMD_CHAR_UUID,
NIMBLE_PROPERTY::NOTIFY
);
svc->start(); svc->start();
server->start(); server->start();
start_advertising(); start_advertising();
@@ -272,6 +282,14 @@ void ble_request_refresh(void) {
} }
} }
void ble_send_command(const char* json) {
if (state == BLE_STATE_CONNECTED && cmd_char) {
cmd_char->setValue(json);
cmd_char->notify();
Serial.printf("BLE: command sent %s\n", json);
}
}
void ble_keyboard_press(uint8_t key, uint8_t modifier) { void ble_keyboard_press(uint8_t key, uint8_t modifier) {
if (state != BLE_STATE_CONNECTED || !input_kbd) return; if (state != BLE_STATE_CONNECTED || !input_kbd) return;
// HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6] // HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6]
+5
View File
@@ -21,6 +21,11 @@ void ble_send_ack(void);
void ble_send_nack(void); void ble_send_nack(void);
void ble_request_refresh(void); void ble_request_refresh(void);
// Watch → PC command channel (Phase 6). Notifies a short JSON command (e.g.
// {"cmd":"toggle"}) to the host daemon on the CMD characteristic; the daemon
// turns it into a Home Assistant service call. No-op unless connected.
void ble_send_command(const char* json);
// BLE HID keyboard // BLE HID keyboard
void ble_keyboard_press(uint8_t key, uint8_t modifier); void ble_keyboard_press(uint8_t key, uint8_t modifier);
void ble_keyboard_release(void); void ble_keyboard_release(void);
+1
View File
@@ -7,3 +7,4 @@
void imu_hal_init(void) {} void imu_hal_init(void) {}
void imu_hal_tick(void) {} void imu_hal_tick(void) {}
uint8_t imu_hal_rotation_quadrant(void) { return 0; } uint8_t imu_hal_rotation_quadrant(void) { return 0; }
bool imu_hal_read_accel(float* x, float* y, float* z) { return false; }
+2
View File
@@ -17,8 +17,10 @@ void power_hal_init(void) {}
void power_hal_tick(void) {} void power_hal_tick(void) {}
int power_hal_battery_pct(void) { return -1; } int power_hal_battery_pct(void) { return -1; }
int power_hal_battery_mv(void) { return 0; } // 0 = no battery / unsupported
bool power_hal_is_charging(void) { return false; } bool power_hal_is_charging(void) { return false; }
bool power_hal_is_vbus_in(void) { return false; } bool power_hal_is_vbus_in(void) { return false; }
void power_hal_shutdown(void) {} // no controllable PMU
bool power_hal_pwr_pressed(void) { return false; } bool power_hal_pwr_pressed(void) { return false; }
// Hold-to-pair gesture signals. Mirror the 216 (PMU PKEY long/positive IRQs) // Hold-to-pair gesture signals. Mirror the 216 (PMU PKEY long/positive IRQs)
// or the 1.8" (software hold-timing off a polled GPIO) port. Stub = no gesture. // or the 1.8" (software hold-timing off a polled GPIO) port. Stub = no gesture.
@@ -23,3 +23,7 @@ void imu_hal_tick(void) {
} }
uint8_t imu_hal_rotation_quadrant(void) { return 0; } uint8_t imu_hal_rotation_quadrant(void) { return 0; }
// Accelerometer is not configured on this board (only the bus is brought up),
// so there is no tilt data to report.
bool imu_hal_read_accel(float* x, float* y, float* z) { return false; }
@@ -23,6 +23,7 @@
static XPowersPMU pmu; static XPowersPMU pmu;
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -49,6 +50,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -62,6 +64,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; last_pwr_ms = now;
@@ -83,9 +86,12 @@ void power_hal_tick(void) {
} }
int power_hal_battery_pct(void) { return cached_pct; } int power_hal_battery_pct(void) { return cached_pct; }
int power_hal_battery_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return false;
@@ -5,21 +5,34 @@
#include <SensorQMI8658.hpp> #include <SensorQMI8658.hpp>
// AMOLED-2.06 ships with QMI8658 populated, but the panel is non-square and // AMOLED-2.06 ships with QMI8658 populated, but the panel is non-square and
// mounts in a fixed orientation, so rotation is disabled. We initialize the // mounts in a fixed orientation, so screen rotation is disabled and the quadrant
// device anyway to keep the shared I2C bus healthy, but always report 0. // is always 0. The accelerometer IS enabled, though: the tilt-dimmer screen
// reads raw g-vectors via imu_hal_read_accel() to derive wrist pitch/roll.
static SensorQMI8658 imu; static SensorQMI8658 imu;
static bool imu_ok = false;
void imu_hal_init(void) { void imu_hal_init(void) {
if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) { if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) {
Serial.println("QMI8658 init failed"); Serial.println("QMI8658 init failed");
return; return;
} }
Serial.println("QMI8658 init OK (rotation disabled on this board)"); // 2g range is plenty for static tilt sensing (gravity is 1g); 62.5Hz keeps
// the dial responsive without flooding the I2C bus. LPF on (default) damps
// hand jitter so the brightness rate doesn't twitch.
imu.configAccelerometer(SensorQMI8658::ACC_RANGE_2G, SensorQMI8658::ACC_ODR_62_5Hz);
imu.enableAccelerometer();
imu_ok = true;
Serial.println("QMI8658 init OK (accel enabled, screen rotation disabled)");
} }
void imu_hal_tick(void) { void imu_hal_tick(void) {
// No-op — rotation is disabled. // No-op — screen rotation is disabled; accel is polled on demand.
} }
uint8_t imu_hal_rotation_quadrant(void) { return 0; } uint8_t imu_hal_rotation_quadrant(void) { return 0; }
bool imu_hal_read_accel(float* x, float* y, float* z) {
if (!imu_ok || !imu.getDataReady()) return false;
return imu.getAccelerometer(*x, *y, *z);
}
@@ -26,6 +26,7 @@
static XPowersPMU pmu; static XPowersPMU pmu;
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -66,6 +67,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -79,6 +81,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; last_pwr_ms = now;
@@ -100,9 +103,12 @@ void power_hal_tick(void) {
} }
int power_hal_battery_pct(void) { return cached_pct; } int power_hal_battery_pct(void) { return cached_pct; }
int power_hal_battery_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return false;
@@ -64,3 +64,8 @@ void imu_hal_tick(void) {
} }
uint8_t imu_hal_rotation_quadrant(void) { return current_rotation; } uint8_t imu_hal_rotation_quadrant(void) { return current_rotation; }
bool imu_hal_read_accel(float* x, float* y, float* z) {
if (!imu_ok || !imu.getDataReady()) return false;
return imu.getAccelerometer(*x, *y, *z);
}
@@ -16,6 +16,7 @@
static XPowersPMU pmu; static XPowersPMU pmu;
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -49,6 +50,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -62,6 +64,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; last_pwr_ms = now;
@@ -74,9 +77,12 @@ void power_hal_tick(void) {
} }
int power_hal_battery_pct(void) { return cached_pct; } int power_hal_battery_pct(void) { return cached_pct; }
int power_hal_battery_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return false;
@@ -24,3 +24,7 @@ void imu_hal_tick(void) {
} }
uint8_t imu_hal_rotation_quadrant(void) { return 0; } uint8_t imu_hal_rotation_quadrant(void) { return 0; }
// Accelerometer is not configured on this board (only the bus is brought up),
// so there is no tilt data to report.
bool imu_hal_read_accel(float* x, float* y, float* z) { return false; }
@@ -17,6 +17,7 @@ extern XPowersPMU board_pmu;
#define pmu board_pmu #define pmu board_pmu
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -50,6 +51,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -63,6 +65,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; last_pwr_ms = now;
@@ -75,9 +78,12 @@ void power_hal_tick(void) {
} }
int power_hal_battery_pct(void) { return cached_pct; } int power_hal_battery_pct(void) { return cached_pct; }
int power_hal_battery_mv(void) { return cached_mv; }
bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return false;
+6
View File
@@ -9,3 +9,9 @@
void imu_hal_init(void); void imu_hal_init(void);
void imu_hal_tick(void); void imu_hal_tick(void);
uint8_t imu_hal_rotation_quadrant(void); uint8_t imu_hal_rotation_quadrant(void);
// Raw accelerometer read in g-units (gravity = 1.0). Used by the tilt-dimmer
// screen to derive wrist pitch/roll. Returns true and fills x/y/z when a fresh
// sample is available; returns false (leaving the outputs untouched) on boards
// without an accelerometer or when no new data is ready yet.
bool imu_hal_read_accel(float* x, float* y, float* z);
+5
View File
@@ -12,9 +12,14 @@ void power_hal_init(void);
void power_hal_tick(void); void power_hal_tick(void);
int power_hal_battery_pct(void); // 0..100, or -1 if no battery (see BoardCaps.has_battery) int power_hal_battery_pct(void); // 0..100, or -1 if no battery (see BoardCaps.has_battery)
int power_hal_battery_mv(void); // battery voltage in mV, or 0 if no battery / unsupported
bool power_hal_is_charging(void); bool power_hal_is_charging(void);
bool power_hal_is_vbus_in(void); // USB cable present (true even without a battery) bool power_hal_is_vbus_in(void); // USB cable present (true even without a battery)
// Power the device fully off (PMU shutdown). Used by the low-voltage protective
// cutoff in main.cpp. No-op on boards without a controllable PMU.
void power_hal_shutdown(void);
// Edge-triggered: returns true once per PWR short-press, then clears. // Edge-triggered: returns true once per PWR short-press, then clears.
bool power_hal_pwr_pressed(void); bool power_hal_pwr_pressed(void);
+143 -13
View File
@@ -10,6 +10,7 @@
#include "version.h" #include "version.h"
#include "splash.h" #include "splash.h"
#include "usage_rate.h" #include "usage_rate.h"
#include "battery_est.h"
#include "idle.h" #include "idle.h"
#include "idle_cfg.h" #include "idle_cfg.h"
#include "brightness.h" #include "brightness.h"
@@ -23,6 +24,21 @@
static UsageData usage = {}; static UsageData usage = {};
// ---- Low-voltage protective cutoff ----
// Below LOW_V_CUTOFF_MV on battery power (USB absent), the firmware warns on
// screen for LOW_V_WARN_MS then powers the PMU fully off, so the cell isn't
// driven into deep over-discharge. The reading must stay below for
// LOW_V_SUSTAIN_MS first: the cell voltage sags under the BLE + AMOLED load, so
// a momentary dip must not trigger a shutdown.
//
// 3000 mV (3.0 V) is a gentle Li-ion floor — well clear of the deep
// over-discharge zone (~2.52.8 V). Mirrored as the "Auto-off below 3.0 V"
// note on the battery screen (ui.cpp). Raise toward 3300 mV to be even kinder
// to the cell, or down to 2800 to squeeze out the last drops.
#define LOW_V_CUTOFF_MV 3000
#define LOW_V_SUSTAIN_MS 6000UL
#define LOW_V_WARN_MS 5000UL
// ---- LVGL draw buffers (partial render mode) ---- // ---- LVGL draw buffers (partial render mode) ----
// PSRAM-equipped boards (S3) can comfortably hold larger strips. PSRAM-free // PSRAM-equipped boards (S3) can comfortably hold larger strips. PSRAM-free
// boards (e.g. ESP32-C6) allocate from internal SRAM, so we shrink the strip // boards (e.g. ESP32-C6) allocate from internal SRAM, so we shrink the strip
@@ -120,6 +136,50 @@ static bool parse_json(const char* json, UsageData* out) {
strlcpy(out->np_title, doc["nt"] | "", sizeof(out->np_title)); strlcpy(out->np_title, doc["nt"] | "", sizeof(out->np_title));
strlcpy(out->np_artist, doc["na"] | "", sizeof(out->np_artist)); strlcpy(out->np_artist, doc["na"] | "", sizeof(out->np_artist));
out->valid = true; out->valid = true;
// Dynamic Home buttons (Phase 7 M2) — optional "btns" array of labels pushed
// by the daemon from the desktop config (only in the ~60s heartbeat write).
// Absent (e.g. the frequent now-playing-only writes) => leave the current
// buttons untouched; present => replace the whole set. The label strings live
// in `doc`, which is valid until this function returns, and ui_set_buttons
// copies them immediately.
JsonArray btns = doc["btns"].as<JsonArray>();
if (!btns.isNull()) {
const char* labels[UI_MAX_BUTTONS];
int n = 0;
for (JsonVariant v : btns) {
if (n >= UI_MAX_BUTTONS) break;
labels[n++] = v.as<const char*>();
}
ui_set_buttons(labels, n);
}
// Tilt-dimmer snapshot (Phase 6 step 3 / M3) — optional "dim" object carrying
// the controlled light's live state so the dial seeds from reality. Pushed on
// the ~60s heartbeat and on demand when the watch opens the Dimmer screen.
JsonObject dim = doc["dim"].as<JsonObject>();
if (!dim.isNull()) {
bool on = dim["on"] | 0;
int bri = dim["bri"] | -1; // -1 = unknown (light off)
int ct = dim["ct"] | -1;
int mink = dim["mink"] | 2000;
int maxk = dim["maxk"] | 6500;
ui_dimmer_set_snapshot(on, bri, ct, mink, maxk);
}
// 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; return true;
} }
@@ -227,7 +287,7 @@ void setup() {
ui_init(); ui_init();
ui_update_ble_status(ble_get_state(), ble_get_device_name(), ble_get_mac_address()); ui_update_ble_status(ble_get_state(), ble_get_device_name(), ble_get_mac_address());
ui_update_battery(power_hal_battery_pct(), power_hal_is_charging()); ui_update_battery(power_hal_battery_pct(), power_hal_battery_mv(), -1, power_hal_is_charging());
ui_show_screen(SCREEN_SPLASH); ui_show_screen(SCREEN_SPLASH);
Serial.printf("Dashboard ready (%s, %dx%d), waiting for data on BLE...\n", Serial.printf("Dashboard ready (%s, %dx%d), waiting for data on BLE...\n",
@@ -307,14 +367,22 @@ void loop() {
{ {
static bool primary_was = false; static bool primary_was = false;
static bool primary_wake_swallowed = false; static bool primary_wake_swallowed = false;
static bool primary_was_dimmer = false; // press consumed by the dimmer (no HID)
bool primary_now = input_hal_is_held(INPUT_BTN_PRIMARY); bool primary_now = input_hal_is_held(INPUT_BTN_PRIMARY);
if (primary_now != primary_was) { if (primary_now != primary_was) {
if (primary_now) { if (primary_now) {
if (idle_consume_wake_press()) primary_wake_swallowed = true; if (idle_consume_wake_press()) primary_wake_swallowed = true;
else ble_keyboard_press(0x2C, 0); // HID Space, no mods else if (ui_get_current_screen() == SCREEN_DIMMER) {
ui_dimmer_arm(); // BOOT arms the tilt-dimmer (no HID here)
primary_was_dimmer = true;
} else {
ble_keyboard_press(0x2C, 0); // HID Space, no mods
primary_was_dimmer = false;
}
} else { } else {
if (primary_wake_swallowed) primary_wake_swallowed = false; if (primary_wake_swallowed) primary_wake_swallowed = false;
else ble_keyboard_release(); else if (primary_was_dimmer) primary_was_dimmer = false;
else ble_keyboard_release();
} }
primary_was = primary_now; primary_was = primary_now;
} }
@@ -337,30 +405,92 @@ void loop() {
if (power_hal_pwr_pressed()) { if (power_hal_pwr_pressed()) {
if (!idle_consume_wake_press()) { if (!idle_consume_wake_press()) {
// On splash: cycle animations. On the usage view: cycle // On splash: cycle animations. On the dimmer: switch the
// screen brightness (single non-splash view, no more screens). // controlled parameter (brightness ⇄ temp). Elsewhere: cycle
if (ui_get_current_screen() == SCREEN_SPLASH) splash_next(); // screen brightness.
else brightness_cycle(); screen_t cs = ui_get_current_screen();
if (cs == SCREEN_SPLASH) splash_next();
else if (cs == SCREEN_DIMMER) ui_dimmer_switch_param();
else brightness_cycle();
} }
} }
pair_tick(); pair_tick();
} }
// Tilt-dimmer control loop (no-op unless the Dimmer screen is armed). Keep
// the panel awake while adjusting so the idle timer can't sleep mid-tilt.
ui_dimmer_tick();
if (ui_dimmer_is_armed()) idle_note_activity();
ble_state_t bs = ble_get_state(); ble_state_t bs = ble_get_state();
if (bs != last_ble_state) { if (bs != last_ble_state) {
last_ble_state = bs; last_ble_state = bs;
ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address()); ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address());
} }
static int last_pct = -2; // ---- Battery telemetry → UI + time-left estimate ----
static bool last_charging = false; static int last_pct = -2;
static int last_mv_bucket = -2;
static bool last_charging = false;
static uint32_t last_bat_ui_ms = 0;
int pct = power_hal_battery_pct(); int pct = power_hal_battery_pct();
int mv = power_hal_battery_mv();
bool charging = power_hal_is_charging(); bool charging = power_hal_is_charging();
if (pct != last_pct || charging != last_charging) { int mv_bucket = (mv <= 0) ? -1 : (mv / 20); // 20 mV buckets — ignore sub-bucket jitter
last_pct = pct; uint32_t now_ms = millis();
last_charging = charging; bool bat_changed = (pct != last_pct) || (charging != last_charging) || (mv_bucket != last_mv_bucket);
ui_update_battery(pct, charging); bool bat_periodic = (now_ms - last_bat_ui_ms >= 2000); // keep "time left" counting down live
if (bat_changed || bat_periodic) {
last_pct = pct;
last_mv_bucket = mv_bucket;
last_charging = charging;
last_bat_ui_ms = now_ms;
battery_est_update(pct, charging);
ui_update_battery(pct, mv, battery_est_minutes(), charging);
}
// Push battery state to the host (~60s, plus on charge-state flip) so the PC
// app can warn on low charge. Reuses the watch→PC command char (…0005), so no
// new GATT characteristic / re-pair. ble_send_command no-ops if disconnected.
{
static uint32_t last_bat_tx_ms = 0;
static bool last_tx_charging = false;
if (mv > 0 && (now_ms - last_bat_tx_ms >= 60000 || charging != last_tx_charging)) {
last_bat_tx_ms = now_ms;
last_tx_charging = charging;
char bm[64];
snprintf(bm, sizeof bm, "{\"bat\":%d,\"mv\":%d,\"chg\":%d}",
pct, mv, charging ? 1 : 0);
ble_send_command(bm);
}
}
// ---- Low-voltage protective cutoff ----
// Sustained below the floor on battery power → warn, then power fully off.
{
static uint32_t low_v_since = 0;
bool batt_present = (mv > 0); // 0 == no battery / not measurable
if (batt_present && !power_hal_is_vbus_in() && mv < LOW_V_CUTOFF_MV) {
if (low_v_since == 0) low_v_since = now_ms;
if (now_ms - low_v_since >= LOW_V_SUSTAIN_MS) {
Serial.printf("Low battery %d mV < %d mV - protective shutdown\n", mv, LOW_V_CUTOFF_MV);
idle_note_activity(); // wake the panel so the warning is visible
ui_show_low_battery();
uint32_t t0 = millis();
while (millis() - t0 < LOW_V_WARN_MS) { // pump LVGL so the warning paints + fades in
idle_tick(); // drive the wake-from-sleep brightness ramp
lv_timer_handler();
if (!idle_is_asleep()) display_hal_tick();
delay(20);
}
power_hal_shutdown();
delay(3000); // let the rail collapse
low_v_since = 0; // bench-supply fallback: don't spin if still powered
}
} else {
low_v_since = 0;
}
} }
check_serial_cmd(); check_serial_cmd();
+2 -1
View File
@@ -7,7 +7,8 @@
#define THEME_PANEL lv_color_hex(0x1f1f1e) // card/zone fill #define THEME_PANEL lv_color_hex(0x1f1f1e) // card/zone fill
#define THEME_TEXT lv_color_hex(0xfaf9f5) // primary text #define THEME_TEXT lv_color_hex(0xfaf9f5) // primary text
#define THEME_DIM lv_color_hex(0xb0aea5) // secondary 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_GREEN lv_color_hex(0x788c5d)
#define THEME_AMBER lv_color_hex(0xd97757) #define THEME_AMBER lv_color_hex(0xd97757)
#define THEME_RED lv_color_hex(0xc0392b) #define THEME_RED lv_color_hex(0xc0392b)
+774 -59
View File
File diff suppressed because it is too large Load Diff
+47 -1
View File
@@ -10,15 +10,61 @@ enum screen_t {
SCREEN_SESSION, // today's tokens / cost (Phase 4) SCREEN_SESSION, // today's tokens / cost (Phase 4)
SCREEN_NOWPLAYING, // media now-playing (Phase 5) SCREEN_NOWPLAYING, // media now-playing (Phase 5)
SCREEN_HOMEASSIST, // Home Assistant controls (Phase 6) 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_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, SCREEN_COUNT,
}; };
void ui_init(void); 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_update(const UsageData* data);
void ui_tick_anim(void); void ui_tick_anim(void);
void ui_show_screen(screen_t screen); void ui_show_screen(screen_t screen);
void ui_toggle_splash(void); void ui_toggle_splash(void);
screen_t ui_get_current_screen(void); screen_t ui_get_current_screen(void);
void ui_update_ble_status(ble_state_t state, const char* name, const char* mac); void ui_update_ble_status(ble_state_t state, const char* name, const char* mac);
void ui_update_battery(int percent, bool charging);
// minutes_left: >=0 estimated minutes remaining, -1 estimating, -2 charging.
void ui_update_battery(int percent, int voltage_mv, int minutes_left, bool charging);
// Full-screen "battery critically low" overlay shown by the protective cutoff in
// main.cpp just before power_hal_shutdown(). Drawn on the top layer so it covers
// whatever screen was active.
void ui_show_low_battery(void);
// Dynamic Home-control buttons (Phase 7 M2). The desktop config defines each
// button's action/entity; the watch stays dumb — it only renders the labels and
// reports the pressed index (the daemon maps index → action). Labels arrive in
// the RX payload's optional "btns" array. Cap kept small for the 2-column grid.
#define UI_MAX_BUTTONS 6
void ui_set_buttons(const char* const* labels, int count);
// Tilt-dimmer (Phase 6 step 3 / M3). On the Dimmer screen the watch reads its
// IMU and ramps the brightness OR color temperature of the configured light
// while "armed", sending absolute {"cmd":"bri"|"ct","v":..} to the daemon
// (which maps it to the first HA entity). The wrist acts as a joystick: tilt
// past the deadzone to change the value at a rate proportional to the tilt,
// hold neutral to stop. BOOT arms and captures the neutral pose; PWR switches
// the controlled parameter; a tap (or a few seconds of stillness) commits and
// locks. The daemon pushes the light's current state as an RX "dim" object so
// the dial starts from reality.
void ui_dimmer_set_snapshot(bool on, int bri_pct, int ct_kelvin, int min_k, int max_k);
void ui_dimmer_arm(void); // BOOT press on the Dimmer screen
void ui_dimmer_switch_param(void); // PWR press on the Dimmer screen
void ui_dimmer_tick(void); // called every loop; no-op unless armed
bool ui_dimmer_is_armed(void); // main.cpp keeps the panel awake while true
+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])