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:
wenil
2026-07-10 00:20:37 +03:00
parent e99c27babd
commit 6e3f8e9084
8 changed files with 405 additions and 55 deletions
+79
View File
@@ -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