10 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
24 changed files with 1809 additions and 63 deletions
+1 -1
View File
@@ -33,5 +33,5 @@ daemon/ha_config.json
# Session scratch — BLE daemon run logs + one-off on-device QA screenshots
/m3_daemon*.log
/dimmer_qa.png
/*_qa.png
/build-exe.log
+6
View File
@@ -36,6 +36,12 @@ hiddenimports = [
'daemon.server',
'daemon.panel',
'daemon.ha_client',
# v3 provider package (registry + providers are lazy-imported in the loop).
'daemon.providers',
'daemon.providers.base',
'daemon.providers.anthropic',
'daemon.providers.openai_codex',
'daemon.providers.zai',
# The exact winrt media modules read_now_playing() pulls in.
'winrt.windows.media',
'winrt.windows.media.control',
+144 -49
View File
@@ -423,6 +423,21 @@ class Session:
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":
@@ -440,6 +455,42 @@ class Session:
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))
@@ -775,6 +826,75 @@ async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
await asyncio.gather(*tasks, return_exceptions=True)
def _active_provider():
"""(id, Provider) for the currently configured active provider. Lazy imports
mirror the rest of the daemon so this resolves under `-m`, the frozen exe,
and pytest alike. Falls back to Anthropic if the config can't be read."""
try:
try:
from daemon.providers import get_provider
from daemon.config import active_provider_id
except ImportError:
from providers import get_provider
from config import active_provider_id
pid = active_provider_id()
except Exception as e: # never let provider selection break the loop
log(f"Provider select failed ({e!r}); using anthropic")
try:
from daemon.providers import get_provider
except ImportError:
from providers import get_provider
pid = "anthropic"
return pid, get_provider(pid)
def _enabled_ids() -> list:
"""Enabled provider ids in on-watch cycle order (never empty — the watch
always has at least Anthropic to cycle/show). Used for both the on-watch
'switch' cycle and the pi/pc badge sent to the Provider screen."""
try:
try:
from daemon.config import enabled_providers
except ImportError:
from config import enabled_providers
return enabled_providers() or ["anthropic"]
except Exception:
return ["anthropic"]
# The live Session, exposed module-wide so the control-panel HTTP thread can push
# an active-provider switch into the BLE loop (set on connect, cleared on exit).
_active_session = None
def request_provider_switch(pid: str) -> None:
"""Control-panel hook: switch the watch's displayed provider immediately,
reusing the exact on-watch path (persist config + refresh-poll + BLE push).
Called from the panel's HTTP thread, so dispatch onto the BLE loop. If no
watch is connected, persist directly so the next connect uses it. Never raises."""
if not isinstance(pid, str):
return
sess = _active_session
loop = getattr(sess, "_loop", None) if sess is not None else None
if sess is not None and loop is not None:
loop.call_soon_threadsafe(sess._set_active_provider, pid)
return
# No live session/loop yet: persist directly. (The panel PUT already saved the
# config, so this is usually a no-op — but it keeps the hook correct on its own.)
try:
try:
from daemon.config import load_config, save_config, PROVIDER_IDS
except ImportError:
from config import load_config, save_config, PROVIDER_IDS
if pid in PROVIDER_IDS:
cfg = load_config()
if cfg.get("active_provider") != pid:
cfg["active_provider"] = pid
save_config(cfg)
except Exception as e:
log(f"Provider switch (offline) failed: {e!r}")
async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool:
"""Connect to device and poll until disconnected or stopped.
@@ -825,6 +945,8 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
log("Connected")
session = Session(client, tray_state)
global _active_session
_active_session = session # let the control panel reach this session for live switches
await session.setup_refresh_subscription()
await session.setup_command_subscription()
@@ -853,55 +975,27 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
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
# every cycle so the watch keeps updating even with no/expired token.
fresh = compute_today_usage()
# Rate-limit utilization is best-effort. A genuine 401/403 flags the
# token; a transient failure (network/DNS/5xx) leaves the tray state
# alone (SC#5: a DNS blip must not read as "token expired").
rl = None
# Self-refresh the OAuth token before using it. In a managed
# (Agent SDK) environment `claude login` is unavailable, so the
# daemon renews its own access token from the refresh token —
# otherwise the rate-limit % goes dark forever once it expires.
try:
await refresh_token_if_needed()
except Exception as e: # belt-and-braces: never crash the loop
log(f"Token refresh skipped: {e!r}")
token = read_token() # D-09: fresh each cycle (post-refresh)
if not token:
auth_problem = True
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
# v3: poll whichever provider is active (config-driven, so an
# on-watch switch takes effect on the next cycle). The provider
# normalizes its own auth + rate-limit + local usage into a
# ProviderStatus and never raises — a genuine auth failure sets
# auth_problem, a transient blip just leaves ok=False (SC#5).
# Today only Anthropic is real; OpenAI/z.ai are stubs until M2/M3.
active_pv, provider = _active_provider()
status = await provider.poll()
auth_problem = status.auth_problem
cached = status.to_payload()
cached["pv"] = active_pv # which brand theme the watch wears
cached["pnm"] = str(provider.label)[:16] # name for the Provider screen
try: # brand accent (0xRRGGBB) for the theme
cached["ac"] = int(provider.accent, 16)
except (ValueError, TypeError):
pass
# 1-based position + count among enabled providers, so the watch's
# Provider screen shows "1/3" and hides the switch hint when alone.
_ids = _enabled_ids()
cached["pc"] = len(_ids)
cached["pi"] = (_ids.index(active_pv) + 1) if active_pv in _ids else 1
last_claude_poll = now
# Now Playing (Phase 5) — best-effort Windows media session, read every
@@ -967,6 +1061,7 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
await _wait_first(session.refresh_requested, stop_event,
timeout=NOWPLAYING_INTERVAL)
finally:
_active_session = None # no live session for the panel to push into
# Clean GATT disconnect on the way out — this is what tells the peripheral
# the link is gone. WinRT can surface a raw OSError (not BleakError) here,
# so swallow both; the link tears down regardless once we exit.
+72 -4
View File
@@ -4,29 +4,43 @@ 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::
Shape (v2)::
{
"version": 1,
"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.
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 = 1
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"))
@@ -43,11 +57,24 @@ 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},
}
@@ -83,6 +110,24 @@ def load_config() -> 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.
@@ -114,3 +159,26 @@ def ha_settings(cfg: dict | None = None) -> dict | None:
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")]
+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,
)
+74 -1
View File
@@ -40,6 +40,11 @@ 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
@@ -55,6 +60,9 @@ 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")
@@ -71,10 +79,61 @@ def put_config(incoming: ConfigIn) -> dict:
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
@@ -123,6 +182,7 @@ async def ha_entities() -> dict:
_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:
@@ -132,6 +192,14 @@ def set_status_provider(fn) -> None:
_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:
@@ -153,8 +221,13 @@ def serve_in_thread(port: int | None = None) -> threading.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"))
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
+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.
result = _run(connect_and_run(device, stop_event))
# start_notify was actually attempted (and raised), but was swallowed.
assert mock_client.start_notify.call_count == 1
# start_notify was actually attempted (and raised), but was swallowed — for
# BOTH subscriptions the daemon sets up: the REQ refresh channel and the CMD
# command channel (battery + HA). Both degrade gracefully.
assert mock_client.start_notify.call_count == 2
# Function returned normally instead of propagating the OSError.
assert result is False
# The link was cleaned up via the finally block.
+160
View File
@@ -0,0 +1,160 @@
"""v3 M3 — z.ai (GLM) provider: usage from the monitor/quota endpoint.
Uses httpx.MockTransport so no real z.ai call is made. Covers the token-window
mapping (5h -> s, weekly -> w), raw-key auth, the ignored monthly TIME_LIMIT,
and auth/error handling.
"""
import asyncio
import json
import time
import httpx
from daemon.providers.zai import ZaiProvider, DEFAULT_HOST, _MONITOR_PATH
def _run(coro):
# test_zai sorts last; an earlier module's asyncio.run() can leave the shared
# loop closed, so fall back to a fresh loop rather than raising (Python 3.13).
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
raise RuntimeError
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
def _config(tmp_path, monkeypatch, zai: dict):
p = tmp_path / "config.json"
p.write_text(json.dumps({"version": 2, "providers": {"zai": zai}}), encoding="utf-8")
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
def _tok(pct, reset_ms, unit=3, number=5):
return {"type": "TOKENS_LIMIT", "unit": unit, "number": number,
"percentage": pct, "nextResetTime": reset_ms}
def _time_limit(pct=0):
return {"type": "TIME_LIMIT", "unit": 5, "number": 1, "usage": 100,
"currentValue": 0, "remaining": 100, "percentage": pct,
"nextResetTime": 9999999999999, "usageDetails": []}
def _ok_resp(limits, level="lite"):
return httpx.Response(200, json={"code": 200, "msg": "Operation successful",
"success": True, "data": {"level": level, "limits": limits}})
def _provider_returning(resp_or_fn):
def handler(request):
return resp_or_fn(request) if callable(resp_or_fn) else resp_or_fn
return ZaiProvider(transport=httpx.MockTransport(handler))
def test_maps_token_windows(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk-zai",
"base_url": "https://api.z.ai/api/anthropic"})
now_ms = time.time() * 1000
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["auth"] = request.headers.get("authorization")
seen["lang"] = request.headers.get("accept-language")
seen["method"] = request.method
return _ok_resp([
_time_limit(), # ignored monthly web-tool quota
_tok(1, now_ms + 3600_000, unit=3, number=5), # 5h window -> short
_tok(2, now_ms + 6 * 86400_000, unit=6, number=1), # weekly window -> weekly
])
st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
assert st.ok is True
assert st.s == 1.0 and 58 <= st.sr <= 60
assert st.w == 2.0 and 8000 <= st.wr <= 8700 # ~6 days in minutes
assert st.st == "Lite"
# raw key auth (no "Bearer"), monitor endpoint on the configured host, GET
assert seen["method"] == "GET"
assert seen["url"] == f"{DEFAULT_HOST}{_MONITOR_PATH}"
assert seen["auth"] == "sk-zai"
assert seen["lang"] == "en-US,en"
def test_no_key(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": ""})
calls = {"n": 0}
def handler(request):
calls["n"] += 1
return _ok_resp([])
st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
assert st.ok is False and st.st == "no key" and st.auth_problem is True
assert calls["n"] == 0
def test_http_401_is_bad_key(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "bad"})
p = _provider_returning(httpx.Response(401, json={"error": "unauthorized"}))
st = _run(p.poll())
assert st.ok is False and st.st == "bad key" and st.auth_problem is True
def test_body_success_false_is_bad_key(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "bad"})
p = _provider_returning(httpx.Response(200, json={"code": 401, "msg": "auth", "success": False, "data": None}))
st = _run(p.poll())
assert st.ok is False and st.st == "bad key" and st.auth_problem is True
def test_single_token_window(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
now_ms = time.time() * 1000
p = _provider_returning(_ok_resp([_tok(37, now_ms + 3600_000)]))
st = _run(p.poll())
assert st.s == 37.0 and st.w == 0.0 and st.wr == -1 and st.ok is True
def test_hundred_percent_is_limited(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
now_ms = time.time() * 1000
p = _provider_returning(_ok_resp([
_tok(100, now_ms + 60_000, unit=3, number=5),
_tok(40, now_ms + 6 * 86400_000, unit=6, number=1)]))
st = _run(p.poll())
assert st.st == "limited" and st.s == 100.0
def test_only_time_limit_reports_connected(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
p = _provider_returning(_ok_resp([_time_limit(5)], level="pro"))
st = _run(p.poll())
assert st.ok is True and st.st == "Pro" and st.s == 0.0
def test_transient_error_reported(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
def handler(request):
raise httpx.ConnectError("boom")
st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
assert st.ok is False and st.st == "offline"
def test_custom_base_host(tmp_path, monkeypatch):
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk",
"base_url": "https://zzz.example/api/anthropic"})
seen = {}
def handler(request):
seen["url"] = str(request.url)
return _ok_resp([])
_run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
assert seen["url"] == f"https://zzz.example{_MONITOR_PATH}" # monitor path on the same host
+3 -1
View File
@@ -226,7 +226,8 @@ def main() -> None:
from pystray import Menu, MenuItem
import daemon.autostart_windows as autostart
from daemon.claude_usage_daemon_windows import main as daemon_main, log as daemon_log
from daemon.claude_usage_daemon_windows import (
main as daemon_main, log as daemon_log, request_provider_switch)
from daemon.icon_assets import load_logo_rgba, build_state_icons
# Build per-state icons once at startup; swap icon.icon per tick (never recomposite).
@@ -277,6 +278,7 @@ def main() -> None:
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:
+72
View File
@@ -51,6 +51,10 @@
.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>
@@ -60,6 +64,7 @@
<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>
@@ -79,6 +84,14 @@
</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>
@@ -146,6 +159,7 @@ 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 ----
@@ -251,6 +265,64 @@ $("#set-save").onclick = async ()=>{
}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{
+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.
+14
View File
@@ -166,6 +166,20 @@ static bool parse_json(const char* json, UsageData* out) {
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;
}
+2 -1
View File
@@ -7,7 +7,8 @@
#define THEME_PANEL lv_color_hex(0x1f1f1e) // card/zone fill
#define THEME_TEXT lv_color_hex(0xfaf9f5) // primary text
#define THEME_DIM lv_color_hex(0xb0aea5) // secondary text
#define THEME_ACCENT lv_color_hex(0xd97757) // brand terra-cotta
#define THEME_ACCENT_HEX 0xd97757 // brand terra-cotta (raw, for runtime palette)
#define THEME_ACCENT lv_color_hex(THEME_ACCENT_HEX) // brand terra-cotta
#define THEME_GREEN lv_color_hex(0x788c5d)
#define THEME_AMBER lv_color_hex(0xd97757)
#define THEME_RED lv_color_hex(0xc0392b)
+155 -4
View File
@@ -100,7 +100,13 @@ static void compute_layout(const BoardCaps& c) {
#define COL_PANEL THEME_PANEL
#define COL_TEXT THEME_TEXT
#define COL_DIM THEME_DIM
#define COL_ACCENT THEME_ACCENT
// COL_ACCENT is runtime (v3): the daemon pushes each provider's brand accent and
// ui_set_theme() swaps it live. Widgets that read COL_ACCENT directly pick up the
// new value on their next update; static widgets attach s_accent_style so a
// provider switch recolors them instantly (lv_obj_report_style_change).
static lv_color_t g_accent = lv_color_hex(THEME_ACCENT_HEX);
static lv_style_t s_accent_style;
#define COL_ACCENT g_accent
#define COL_GREEN THEME_GREEN
#define COL_AMBER THEME_AMBER
#define COL_RED THEME_RED
@@ -145,6 +151,11 @@ static lv_obj_t* sess_cost_lbl;
static lv_obj_t* sess_tokens_lbl;
static lv_obj_t* sess_gen_lbl;
static lv_obj_t* sess_msgs_lbl;
static lv_obj_t* provider_container; // which usage source the watch shows (v3)
static lv_obj_t* prov_name_lbl; // active provider's brand name (accent-coloured)
static lv_obj_t* prov_pos_lbl; // "1 / 3" position among enabled providers
static lv_obj_t* prov_switch_btn; // tap → {"cmd":"provnext"} (daemon cycles)
static lv_obj_t* prov_hint_lbl; // shown instead of the button when only one is enabled
static lv_obj_t* nowplaying_container; // media now-playing (Phase 5)
static lv_obj_t* np_icon_lbl; // play / pause / music glyph
static lv_obj_t* np_title_lbl; // track title (scrolls if long)
@@ -186,6 +197,7 @@ static const AppEntry APPS[] = {
{ SCREEN_NOWPLAYING, "Now Playing", LV_SYMBOL_PLAY },
{ SCREEN_HOMEASSIST, "Home", LV_SYMBOL_HOME },
{ SCREEN_DIMMER, "Dimmer", LV_SYMBOL_SETTINGS },
{ SCREEN_PROVIDER, "Provider", LV_SYMBOL_SHUFFLE },
{ SCREEN_BLUETOOTH, "Bluetooth", LV_SYMBOL_BLUETOOTH },
};
#define APP_COUNT (sizeof(APPS) / sizeof(APPS[0]))
@@ -477,7 +489,7 @@ static void init_usage_screen(lv_obj_t* scr) {
lbl_anim = lv_label_create(usage_container);
lv_label_set_text(lbl_anim, "");
lv_obj_set_style_text_font(lbl_anim, &font_mono_32, 0);
lv_obj_set_style_text_color(lbl_anim, COL_ACCENT, 0);
lv_obj_add_style(lbl_anim, &s_accent_style, 0); // v3: recolors on provider switch
lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15);
}
@@ -516,7 +528,7 @@ static lv_obj_t* make_tile(lv_obj_t* parent, const AppEntry* app, int w, int h)
lv_obj_t* icon = lv_label_create(tile);
lv_label_set_text(icon, app->symbol);
lv_obj_set_style_text_font(icon, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(icon, COL_ACCENT, 0);
lv_obj_add_style(icon, &s_accent_style, 0); // v3: recolors on provider switch
lv_obj_t* lbl = lv_label_create(tile);
lv_label_set_text(lbl, app->label);
@@ -712,7 +724,7 @@ static void init_session_screen(lv_obj_t* scr) {
sess_tokens_lbl = lv_label_create(session_container);
lv_label_set_text(sess_tokens_lbl, "\xE2\x80\x94 tokens");
lv_obj_set_style_text_font(sess_tokens_lbl, &font_styrene_28, 0);
lv_obj_set_style_text_color(sess_tokens_lbl, COL_ACCENT, 0);
lv_obj_add_style(sess_tokens_lbl, &s_accent_style, 0); // v3: recolors on provider switch
lv_obj_align(sess_tokens_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 152);
sess_gen_lbl = lv_label_create(session_container);
@@ -730,6 +742,78 @@ static void init_session_screen(lv_obj_t* scr) {
lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
}
// Provider (v3): which usage source the watch is displaying. Shows the active
// provider's brand name in the accent colour and its position among the enabled
// set; the Switch button asks the daemon to advance to the next one (the daemon
// owns the enabled list/order, so the watch just says "next"). Name/position are
// refreshed from ui_set_provider_badge() as payloads arrive.
static void prov_switch_cb(lv_event_t* e) {
(void)e;
ble_send_command("{\"cmd\":\"provnext\"}");
if (prov_pos_lbl) lv_label_set_text(prov_pos_lbl, "Switching\xE2\x80\xA6"); // …
}
static void init_provider_screen(lv_obj_t* scr) {
provider_container = lv_obj_create(scr);
lv_obj_set_size(provider_container, L.scr_w, L.scr_h);
lv_obj_set_pos(provider_container, 0, 0);
lv_obj_set_style_bg_opa(provider_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(provider_container, 0, 0);
lv_obj_set_style_pad_all(provider_container, 0, 0);
lv_obj_clear_flag(provider_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(provider_container, "Provider");
lv_obj_t* cap = lv_label_create(provider_container);
lv_label_set_text(cap, "Showing on watch");
lv_obj_set_style_text_font(cap, &font_styrene_16, 0);
lv_obj_set_style_text_color(cap, COL_DIM, 0);
lv_obj_align(cap, LV_ALIGN_TOP_MID, 0, L.content_y + 34);
prov_name_lbl = lv_label_create(provider_container);
lv_label_set_text(prov_name_lbl, "Claude Code");
lv_obj_set_style_text_font(prov_name_lbl, &font_tiempos_34, 0);
lv_obj_add_style(prov_name_lbl, &s_accent_style, 0); // brand colour, recolors on switch
lv_obj_align(prov_name_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 70);
prov_pos_lbl = lv_label_create(provider_container);
lv_label_set_text(prov_pos_lbl, "");
lv_obj_set_style_text_font(prov_pos_lbl, &font_styrene_20, 0);
lv_obj_set_style_text_color(prov_pos_lbl, COL_DIM, 0);
lv_obj_align(prov_pos_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 122);
// Switch button — advance to the next enabled provider.
prov_switch_btn = lv_obj_create(provider_container);
lv_obj_set_size(prov_switch_btn, 190, 74);
lv_obj_align(prov_switch_btn, LV_ALIGN_TOP_MID, 0, L.content_y + 178);
lv_obj_set_style_bg_color(prov_switch_btn, COL_PANEL, 0);
lv_obj_set_style_bg_opa(prov_switch_btn, LV_OPA_COVER, 0);
lv_obj_set_style_radius(prov_switch_btn, 16, 0);
lv_obj_set_style_border_width(prov_switch_btn, 0, 0);
lv_obj_set_style_border_width(prov_switch_btn, 3, LV_STATE_PRESSED); // accent ring on press
lv_obj_set_style_border_color(prov_switch_btn, COL_ACCENT, LV_STATE_PRESSED);
lv_obj_clear_flag(prov_switch_btn, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(prov_switch_btn, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(prov_switch_btn, prov_switch_cb, LV_EVENT_CLICKED, NULL);
// Brand text is Styrene (ASCII-only); LVGL symbol glyphs live in Montserrat,
// so a symbol here would render as tofu. Keep the label plain "Switch".
lv_obj_t* sw_lbl = lv_label_create(prov_switch_btn);
lv_label_set_text(sw_lbl, "Switch");
lv_obj_set_style_text_font(sw_lbl, &font_styrene_20, 0);
lv_obj_set_style_text_color(sw_lbl, COL_TEXT, 0);
lv_obj_center(sw_lbl);
prov_hint_lbl = lv_label_create(provider_container);
lv_label_set_text(prov_hint_lbl, "Only provider enabled");
lv_obj_set_style_text_font(prov_hint_lbl, &font_styrene_16, 0);
lv_obj_set_style_text_color(prov_hint_lbl, COL_DIM, 0);
lv_obj_align(prov_hint_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 200);
lv_obj_add_flag(prov_hint_lbl, LV_OBJ_FLAG_HIDDEN); // shown only when count == 1
lv_obj_add_flag(provider_container, LV_OBJ_FLAG_HIDDEN);
}
// Now Playing: the Windows media session relayed by the daemon. A play/pause glyph
// and status line reflect playback; the title scrolls if it overflows. Independent
// of rate-limit data, so it stays live even when the OAuth token is unavailable.
@@ -1282,6 +1366,11 @@ static void battery_screen_refresh(void) {
void ui_init(void) {
compute_layout(board_caps());
// Shared accent style (v3 theming) — MUST be initialized before any screen is
// built, so widgets can attach it. ui_set_theme() recolors it live.
lv_style_init(&s_accent_style);
lv_style_set_text_color(&s_accent_style, g_accent);
lv_obj_t* scr = lv_screen_active();
lv_obj_set_style_bg_color(scr, COL_BG, 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
@@ -1302,6 +1391,7 @@ void ui_init(void) {
init_soundpad_screen(scr);
init_session_screen(scr);
init_nowplaying_screen(scr);
init_provider_screen(scr);
init_homeassist_screen(scr);
init_dimmer_screen(scr);
init_battery_screen(scr);
@@ -1535,6 +1625,7 @@ void ui_show_screen(screen_t screen) {
if (soundpad_container) lv_obj_add_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN);
if (session_container) lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
if (nowplaying_container) lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
if (provider_container) lv_obj_add_flag(provider_container, LV_OBJ_FLAG_HIDDEN);
if (homeassist_container) lv_obj_add_flag(homeassist_container, LV_OBJ_FLAG_HIDDEN);
if (dimmer_container) lv_obj_add_flag(dimmer_container, LV_OBJ_FLAG_HIDDEN);
if (battery_container) lv_obj_add_flag(battery_container, LV_OBJ_FLAG_HIDDEN);
@@ -1551,6 +1642,7 @@ void ui_show_screen(screen_t screen) {
case SCREEN_SOUNDPAD: lv_obj_clear_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_SESSION: lv_obj_clear_flag(session_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_NOWPLAYING: lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_PROVIDER: lv_obj_clear_flag(provider_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_BLUETOOTH: bt_refresh(); lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_HOMEASSIST:
lv_obj_clear_flag(homeassist_container, LV_OBJ_FLAG_HIDDEN);
@@ -1584,6 +1676,65 @@ screen_t ui_get_current_screen(void) {
return current_screen;
}
// ---- v3 provider theming --------------------------------------------------
// Per-provider logo on the usage screen. Only the Claude mark ships today; adding
// a provider's logo is one row here plus an RGB565A8 asset. Unknown / not-yet-
// added providers fall back to the Claude mark, so a switch still recolors.
struct ProviderLogo { const char* pv; const lv_image_dsc_t* dsc; };
static const ProviderLogo s_provider_logos[] = {
{ "anthropic", &logo_dsc },
// { "openai", &logo_openai_dsc }, // add when the asset lands
// { "zai", &logo_zai_dsc },
};
static const lv_image_dsc_t* logo_for(const char* pv) {
if (pv)
for (const auto& e : s_provider_logos)
if (strcmp(e.pv, pv) == 0) return e.dsc;
return &logo_dsc; // fallback: Claude mark until the provider ships a logo
}
// Apply a provider's brand: recolor the shared accent style (every widget using
// it repaints via report_style_change), retint the few non-text accent widgets,
// and swap the logo. accent_rgb == 0 keeps the current accent (logo-only change).
void ui_set_theme(const char* pv, uint32_t accent_rgb) {
// Change-guarded: the daemon stamps pv/ac on every write (~3s), so only act
// on an actual switch — otherwise report_style_change would churn constantly.
static uint32_t last_accent = THEME_ACCENT_HEX;
static char last_pv[16] = "";
if (accent_rgb && accent_rgb != last_accent) {
last_accent = accent_rgb;
g_accent = lv_color_hex(accent_rgb);
lv_style_set_text_color(&s_accent_style, g_accent);
lv_obj_report_style_change(&s_accent_style);
if (dim_arc) lv_obj_set_style_arc_color(dim_arc, g_accent, LV_PART_INDICATOR);
}
if (pv && strncmp(pv, last_pv, sizeof(last_pv)) != 0) {
strlcpy(last_pv, pv, sizeof(last_pv));
if (logo_img) lv_image_set_src(logo_img, logo_for(pv));
}
}
// Refresh the Provider screen's name + "i / c" position, and show the Switch
// button unless exactly one provider is enabled (then a hint replaces it). Safe
// to call on every payload; name==nullptr leaves the current name in place.
void ui_set_provider_badge(const char* name, int index, int count) {
if (!provider_container) return;
if (name && *name && prov_name_lbl) lv_label_set_text(prov_name_lbl, name);
if (prov_pos_lbl) {
if (count > 0 && index > 0) lv_label_set_text_fmt(prov_pos_lbl, "%d / %d", index, count);
else lv_label_set_text(prov_pos_lbl, "");
}
bool single = (count == 1); // count 0 (unknown) keeps the button visible
if (prov_switch_btn) {
if (single) lv_obj_add_flag(prov_switch_btn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_clear_flag(prov_switch_btn, LV_OBJ_FLAG_HIDDEN);
}
if (prov_hint_lbl) {
if (single) lv_obj_clear_flag(prov_hint_lbl, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(prov_hint_lbl, LV_OBJ_FLAG_HIDDEN);
}
}
void ui_update_ble_status(ble_state_t state, const char* name, const char* mac) {
(void)name; (void)mac;
bool was_connected = s_ble_connected;
+15
View File
@@ -12,11 +12,26 @@ enum screen_t {
SCREEN_HOMEASSIST, // Home Assistant controls (Phase 6)
SCREEN_DIMMER, // tilt-to-dim light control (Phase 6 step 3 / M3)
SCREEN_BLUETOOTH, // BLE connection info
SCREEN_PROVIDER, // which usage source the watch displays (v3) + Switch
SCREEN_BATTERY, // battery detail (voltage + time left) — opened by tapping the battery icon
SCREEN_COUNT,
};
void ui_init(void);
// v3 multi-provider theming. The daemon tags each usage payload with the active
// provider id (`pv`, e.g. "anthropic"/"openai"/"zai") and its brand accent
// (`ac`, 0xRRGGBB). ui_set_theme recolors the UI live via a shared accent style
// and swaps the per-provider logo. accent_rgb == 0 keeps the current accent.
// Change-guarded, so it's safe to call on every payload.
void ui_set_theme(const char* provider_id, uint32_t accent_rgb);
// v3 Provider screen badge: the active provider's human name (`pnm`) and its
// 1-based position/count among the enabled providers (`pi`/`pc`), so the screen
// shows "Claude Code · 1 / 3" and hides the Switch button when only one exists.
// Safe to call on every payload. name==nullptr keeps the current name.
void ui_set_provider_badge(const char* name, int index, int count);
void ui_update(const UsageData* data);
void ui_tick_anim(void);
void ui_show_screen(screen_t screen);
+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])