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:
+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")]
|
||||
|
||||
Reference in New Issue
Block a user