v3 M1c (host): provider selector — control panel + live-switch plumbing

Panel: new Providers tab (data-driven from GET /api/providers — labels/accents
from the daemon registry, enable/active/creds from config). z.ai base_url + key,
api_key masked and preserved on save like the HA token.

Daemon: request_provider_switch() lets the panel push an active-provider change
into the live BLE session (same _set_active_provider path as the watch) so it
takes effect now, not on the next 60s poll; _active_session exposes the session
to the panel thread. On-watch cycle groundwork: provnext -> _cycle_provider
(daemon owns the enabled set/order), and pnm/pi/pc pushed in the payload so the
watch's Provider screen can show the name + "1/3".

server.py: ConfigIn gains providers/active_provider/display_order; secrets masked
in _masked; active_provider change notifies the injected _command_sink.
tray wires set_command_sink(request_provider_switch).

Tests: test_server_providers (masking + sink), test_provider_switch (cycle +
offline persist). 106 passed, 2 Linux-only failures (baseline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wenil
2026-07-10 06:41:19 +03:00
co-authored by Claude Opus 4.8
parent 83bd2bed43
commit 1b14c363c2
6 changed files with 376 additions and 1 deletions
+68
View File
@@ -40,6 +40,11 @@ def _masked(cfg: dict) -> dict:
c = copy.deepcopy(cfg)
if c.get("ha", {}).get("token"):
c["ha"]["token"] = MASK
# Provider secrets (e.g. z.ai api_key) get the same treatment as the HA token:
# never leave this process. The UI echoes the mask back to keep the stored one.
for pconf in (c.get("providers") or {}).values():
if isinstance(pconf, dict) and pconf.get("api_key"):
pconf["api_key"] = MASK
return c
@@ -55,6 +60,9 @@ class ConfigIn(BaseModel):
ha: dict | None = None
buttons: list | None = None
settings: dict | None = None
providers: dict | None = None # v3: per-provider {enabled, base_url, api_key}
active_provider: str | None = None # which provider the watch displays
display_order: list | None = None # on-watch cycle order
@app.put("/api/config")
@@ -71,10 +79,61 @@ def put_config(incoming: ConfigIn) -> dict:
cfg["settings"].update(data["settings"])
if "buttons" in data:
cfg["buttons"] = data["buttons"]
# v3 providers — merge per known id; a masked api_key echoed back keeps the
# stored secret (same discipline as the HA token).
if isinstance(data.get("providers"), dict):
for pid, pconf in data["providers"].items():
if pid not in cfg["providers"] or not isinstance(pconf, dict):
continue
pconf = dict(pconf)
if pconf.get("api_key") == MASK:
pconf["api_key"] = cfg["providers"][pid].get("api_key", "")
cfg["providers"][pid].update(pconf)
if isinstance(data.get("display_order"), list):
cfg["display_order"] = data["display_order"]
switched = None
if isinstance(data.get("active_provider"), str):
if data["active_provider"] != cfg.get("active_provider"):
switched = data["active_provider"]
cfg["active_provider"] = data["active_provider"]
cfgmod.save_config(cfg)
# Push the switch to a connected watch now (same path as the on-watch button)
# instead of waiting for the ~60s poll to notice the config change. Best-effort.
if switched is not None and _command_sink is not None:
try:
_command_sink(switched)
except Exception:
pass
return _masked(cfg)
@app.get("/api/providers")
def get_providers() -> dict:
"""Provider list for the panel's Providers tab: brand label + accent come from
the daemon registry, enabled/creds state from config. Data-driven so adding a
provider (a daemon class + a config default) needs zero web-UI edits. Secrets
are never sent — only whether a key is stored (has_key)."""
cfg = cfgmod.load_config()
try:
from daemon.providers import get_provider
except ImportError:
from providers import get_provider
out = []
for pid in cfgmod.PROVIDER_IDS:
p = get_provider(pid)
pconf = cfg["providers"].get(pid, {})
out.append({
"id": pid,
"label": p.label,
"accent": p.accent,
"enabled": bool(pconf.get("enabled")),
"needs_key": "api_key" in pconf, # z.ai-style base_url + key creds
"base_url": pconf.get("base_url", ""),
"has_key": bool(pconf.get("api_key")),
})
return {"providers": out, "active": cfg["active_provider"], "order": cfg["display_order"]}
def _resolve_token(token: str) -> str:
return cfgmod.load_config()["ha"]["token"] if token == MASK else token
@@ -123,6 +182,7 @@ async def ha_entities() -> dict:
_status_provider = None # set by the tray to expose live BLE/daemon state
_command_sink = None # set by the tray: called with a provider id to live-switch the watch
def set_status_provider(fn) -> None:
@@ -132,6 +192,14 @@ def set_status_provider(fn) -> None:
_status_provider = fn
def set_command_sink(fn) -> None:
"""The tray injects a callable(provider_id) that pushes an active-provider
switch to the live BLE session. Optional — a panel edit still persists to
config (and the daemon picks it up on its next poll) if this is unset."""
global _command_sink
_command_sink = fn
@app.get("/api/status")
def get_status() -> dict:
if _status_provider is not None: