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:
@@ -36,6 +36,10 @@ 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',
|
||||
# The exact winrt media modules read_now_playing() pulls in.
|
||||
'winrt.windows.media',
|
||||
'winrt.windows.media.control',
|
||||
|
||||
@@ -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
|
||||
|
||||
+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,28 @@
|
||||
"""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
|
||||
|
||||
# Brand accents / labels for the not-yet-implemented providers, so the stub still
|
||||
# carries the right theme hint to the watch once firmware theming lands.
|
||||
_STUB_META = {
|
||||
"openai": ("OpenAI Codex", "10a37f"),
|
||||
"zai": ("z.ai GLM", "3859ff"),
|
||||
}
|
||||
|
||||
|
||||
def get_provider(pid: str) -> Provider:
|
||||
if pid == "anthropic":
|
||||
return AnthropicProvider()
|
||||
label, accent = _STUB_META.get(pid, (pid, "d97757"))
|
||||
return StubProvider(pid, label=label, accent=accent)
|
||||
|
||||
|
||||
__all__ = ["Provider", "ProviderStatus", "StubProvider", "AnthropicProvider", "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,79 @@
|
||||
"""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, StubProvider, ProviderStatus
|
||||
|
||||
|
||||
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"
|
||||
stub = get_provider("openai")
|
||||
assert isinstance(stub, StubProvider)
|
||||
assert stub.id == "openai" and stub.accent == "10a37f"
|
||||
|
||||
|
||||
def test_stub_provider_poll_is_safe():
|
||||
st = asyncio.run(get_provider("zai").poll())
|
||||
assert isinstance(st, ProviderStatus)
|
||||
assert st.ok is False
|
||||
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user