Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1cc4670b1 | ||
|
|
4eeb6e242b | ||
|
|
ac0742d92c | ||
|
|
b63a6a62ae | ||
|
|
a975eb434b | ||
|
|
1b14c363c2 | ||
|
|
83bd2bed43 | ||
|
|
6e3f8e9084 | ||
|
|
e99c27babd | ||
|
|
d55dc4a268 |
+1
-1
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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")]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,""");}
|
||||
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{
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user