v3 M1a: provider framework — config v2 + Provider abstraction (daemon)
Introduce the multi-provider seam on the host, behaviour-identical for Anthropic.
- config v2: `providers` (per-provider enable + creds), `active_provider`,
`display_order`; v1 configs migrate transparently (anthropic enabled+active).
New accessors: active_provider_id / provider_conf / enabled_providers.
- daemon/providers/: Provider ABC + normalized ProviderStatus (maps to the same
s/sr/w/wr/st/tk/to/tc/tn BLE fields), AnthropicProvider (wraps the existing
OAuth+poll+local-usage logic), StubProvider for openai/zai until M2/M3, and a
get_provider() registry.
- poll loop: polls the config-selected active provider each cycle and tags the
payload with `pv` (the brand-theme id the watch will wear). Watch->PC
`{"cmd":"prov","id":".."}` persists the active provider and repolls at once.
- clawdmeter.spec: bundle daemon.providers for the frozen exe.
- test: 7 new provider/config tests; fix a stale start_notify count assertion
(the M2 command channel means both REQ and CMD subscriptions are attempted).
Verified live: config loads as v2, active provider resolves to AnthropicProvider,
a real poll returns ok=True (s=53%, w=5%); full suite 87 passed (+7 new).
This commit is contained in:
@@ -423,6 +423,14 @@ 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
|
||||
# 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 +448,27 @@ 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 _handle_battery(self, payload: dict) -> None:
|
||||
pct = payload.get("bat")
|
||||
charging = bool(payload.get("chg", 0))
|
||||
@@ -775,6 +804,28 @@ 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)
|
||||
|
||||
|
||||
async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool:
|
||||
"""Connect to device and poll until disconnected or stopped.
|
||||
|
||||
@@ -853,55 +904,17 @@ 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
|
||||
last_claude_poll = now
|
||||
|
||||
# Now Playing (Phase 5) — best-effort Windows media session, read every
|
||||
|
||||
Reference in New Issue
Block a user