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
+72 -4
View File
@@ -4,29 +4,43 @@ Single source of truth at ``%LOCALAPPDATA%\\Clawdmeter\\config.json``, shared by
the tray daemon and the FastAPI control panel. Replaces the Phase-6
``ha_config.json`` (auto-migrated on first load).
Shape::
Shape (v2)::
{
"version": 1,
"version": 2,
"ha": {"url": str, "token": str, "entities": [str, ...]},
"buttons": [{"id": str, "label": str, "icon": str,
"action": "toggle"|"bri"|"ct", "entity": str, "value": int|None}],
"providers": { # v3: multi-provider usage
"anthropic": {"enabled": bool},
"openai": {"enabled": bool},
"zai": {"enabled": bool, "base_url": str, "api_key": str},
},
"active_provider": "anthropic", # which provider the watch displays
"display_order": ["anthropic", "openai", "zai"], # on-watch cycle order
"settings": {"device_address": str, "autostart": bool}
}
The token is a secret: it lives only in this file (outside the repo, gitignored)
and is NEVER logged (only its length) — same discipline as ha_client.py. The
control panel masks it in API responses.
control panel masks it in API responses. Provider API keys (e.g. z.ai) are
secrets too and follow the same rule.
"""
import json
import os
from pathlib import Path
CONFIG_VERSION = 1
CONFIG_VERSION = 2
DEFAULT_PORT = 8723 # FastAPI control panel — bound to 127.0.0.1 only
VALID_ACTIONS = ("toggle", "bri", "ct")
# v3 providers. Order here is the default on-watch cycle order. Anthropic needs
# no extra config (it reads Claude Code's OAuth creds); z.ai needs a base URL +
# API key; OpenAI reads the local Codex install. Keep this list and the per-
# provider default shapes in sync with default_config().
PROVIDER_IDS = ("anthropic", "openai", "zai")
def _dir() -> Path:
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
@@ -43,11 +57,24 @@ def _legacy_ha_path() -> Path:
return _dir() / "ha_config.json"
def default_providers() -> dict:
"""Per-provider config defaults. Anthropic is enabled+active out of the box so
a v1 config (no providers section) keeps behaving exactly as before."""
return {
"anthropic": {"enabled": True},
"openai": {"enabled": False},
"zai": {"enabled": False, "base_url": "", "api_key": ""},
}
def default_config() -> dict:
return {
"version": CONFIG_VERSION,
"ha": {"url": "", "token": "", "entities": []},
"buttons": [],
"providers": default_providers(),
"active_provider": "anthropic",
"display_order": list(PROVIDER_IDS),
"settings": {"device_address": "", "autostart": False, "low_battery_pct": 15},
}
@@ -83,6 +110,24 @@ def load_config() -> dict:
cfg[section].update(loaded[section])
if isinstance(loaded.get("buttons"), list):
cfg["buttons"] = loaded["buttons"]
# v3 providers — deep-merge per provider so new keys added in later
# versions always resolve, and a v1 config (no providers) keeps the
# defaults (anthropic enabled + active) → behaviour unchanged.
if isinstance(loaded.get("providers"), dict):
for pid, pconf in loaded["providers"].items():
if pid in cfg["providers"] and isinstance(pconf, dict):
cfg["providers"][pid].update(pconf)
if isinstance(loaded.get("active_provider"), str):
cfg["active_provider"] = loaded["active_provider"]
if isinstance(loaded.get("display_order"), list):
cfg["display_order"] = [p for p in loaded["display_order"] if p in PROVIDER_IDS]
# active provider must be a known id; fall back to anthropic otherwise.
if cfg["active_provider"] not in PROVIDER_IDS:
cfg["active_provider"] = "anthropic"
# display order must cover every provider (append any missing at the end).
for pid in PROVIDER_IDS:
if pid not in cfg["display_order"]:
cfg["display_order"].append(pid)
if not cfg["ha"]["url"] and not cfg["ha"]["token"]:
cfg = _migrate_legacy(cfg)
# Normalize the HA section the same way ha_client expects it.
@@ -114,3 +159,26 @@ def ha_settings(cfg: dict | None = None) -> dict | None:
if not url or not token or token.startswith("PASTE_"):
return None
return {"url": url, "token": token, "entities": list(ha.get("entities") or [])}
# --- v3 provider accessors -------------------------------------------------
def active_provider_id(cfg: dict | None = None) -> str:
"""The provider id the watch should display right now (always a valid id)."""
if cfg is None:
cfg = load_config()
pid = cfg.get("active_provider", "anthropic")
return pid if pid in PROVIDER_IDS else "anthropic"
def provider_conf(cfg: dict, pid: str) -> dict:
"""Per-provider config dict (empty dict if the id is unknown)."""
return (cfg.get("providers") or {}).get(pid, {})
def enabled_providers(cfg: dict | None = None) -> list:
"""Enabled provider ids in the configured on-watch cycle order."""
if cfg is None:
cfg = load_config()
order = cfg.get("display_order") or list(PROVIDER_IDS)
return [p for p in order if p in PROVIDER_IDS and provider_conf(cfg, p).get("enabled")]