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:
@@ -431,6 +431,13 @@ class Session:
|
||||
if isinstance(pid, str) and loop is not None:
|
||||
loop.call_soon_threadsafe(self._set_active_provider, pid)
|
||||
return
|
||||
# v3: watch's Provider screen "switch" tap ({"cmd":"provnext"}) — the watch
|
||||
# doesn't know the enabled set/order, so the daemon cycles to the next one.
|
||||
if payload.get("cmd") == "provnext":
|
||||
loop = self._loop
|
||||
if loop is not None:
|
||||
loop.call_soon_threadsafe(self._cycle_provider)
|
||||
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":
|
||||
@@ -469,6 +476,21 @@ class Session:
|
||||
except Exception as e:
|
||||
log(f"Provider switch failed: {e!r}")
|
||||
|
||||
def _cycle_provider(self) -> None:
|
||||
"""Advance to the next enabled provider (on-watch 'switch' tap). Runs on
|
||||
the loop thread. With a single enabled provider this is a no-op poll."""
|
||||
try:
|
||||
try:
|
||||
from daemon.config import active_provider_id
|
||||
except ImportError:
|
||||
from config import active_provider_id
|
||||
ids = _enabled_ids()
|
||||
cur = active_provider_id()
|
||||
i = ids.index(cur) if cur in ids else -1
|
||||
self._set_active_provider(ids[(i + 1) % len(ids)])
|
||||
except Exception as e:
|
||||
log(f"Provider cycle failed: {e!r}")
|
||||
|
||||
def _handle_battery(self, payload: dict) -> None:
|
||||
pct = payload.get("bat")
|
||||
charging = bool(payload.get("chg", 0))
|
||||
@@ -826,6 +848,53 @@ def _active_provider():
|
||||
return pid, get_provider(pid)
|
||||
|
||||
|
||||
def _enabled_ids() -> list:
|
||||
"""Enabled provider ids in on-watch cycle order (never empty — the watch
|
||||
always has at least Anthropic to cycle/show). Used for both the on-watch
|
||||
'switch' cycle and the pi/pc badge sent to the Provider screen."""
|
||||
try:
|
||||
try:
|
||||
from daemon.config import enabled_providers
|
||||
except ImportError:
|
||||
from config import enabled_providers
|
||||
return enabled_providers() or ["anthropic"]
|
||||
except Exception:
|
||||
return ["anthropic"]
|
||||
|
||||
|
||||
# The live Session, exposed module-wide so the control-panel HTTP thread can push
|
||||
# an active-provider switch into the BLE loop (set on connect, cleared on exit).
|
||||
_active_session = None
|
||||
|
||||
|
||||
def request_provider_switch(pid: str) -> None:
|
||||
"""Control-panel hook: switch the watch's displayed provider immediately,
|
||||
reusing the exact on-watch path (persist config + refresh-poll + BLE push).
|
||||
Called from the panel's HTTP thread, so dispatch onto the BLE loop. If no
|
||||
watch is connected, persist directly so the next connect uses it. Never raises."""
|
||||
if not isinstance(pid, str):
|
||||
return
|
||||
sess = _active_session
|
||||
loop = getattr(sess, "_loop", None) if sess is not None else None
|
||||
if sess is not None and loop is not None:
|
||||
loop.call_soon_threadsafe(sess._set_active_provider, pid)
|
||||
return
|
||||
# No live session/loop yet: persist directly. (The panel PUT already saved the
|
||||
# config, so this is usually a no-op — but it keeps the hook correct on its own.)
|
||||
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 in PROVIDER_IDS:
|
||||
cfg = load_config()
|
||||
if cfg.get("active_provider") != pid:
|
||||
cfg["active_provider"] = pid
|
||||
save_config(cfg)
|
||||
except Exception as e:
|
||||
log(f"Provider switch (offline) failed: {e!r}")
|
||||
|
||||
|
||||
async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool:
|
||||
"""Connect to device and poll until disconnected or stopped.
|
||||
|
||||
@@ -876,6 +945,8 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
||||
|
||||
log("Connected")
|
||||
session = Session(client, tray_state)
|
||||
global _active_session
|
||||
_active_session = session # let the control panel reach this session for live switches
|
||||
await session.setup_refresh_subscription()
|
||||
await session.setup_command_subscription()
|
||||
|
||||
@@ -915,10 +986,16 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
||||
auth_problem = status.auth_problem
|
||||
cached = status.to_payload()
|
||||
cached["pv"] = active_pv # which brand theme the watch wears
|
||||
cached["pnm"] = str(provider.label)[:16] # name for the Provider screen
|
||||
try: # brand accent (0xRRGGBB) for the theme
|
||||
cached["ac"] = int(provider.accent, 16)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# 1-based position + count among enabled providers, so the watch's
|
||||
# Provider screen shows "1/3" and hides the switch hint when alone.
|
||||
_ids = _enabled_ids()
|
||||
cached["pc"] = len(_ids)
|
||||
cached["pi"] = (_ids.index(active_pv) + 1) if active_pv in _ids else 1
|
||||
last_claude_poll = now
|
||||
|
||||
# Now Playing (Phase 5) — best-effort Windows media session, read every
|
||||
@@ -984,6 +1061,7 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
||||
await _wait_first(session.refresh_requested, stop_event,
|
||||
timeout=NOWPLAYING_INTERVAL)
|
||||
finally:
|
||||
_active_session = None # no live session for the panel to push into
|
||||
# Clean GATT disconnect on the way out — this is what tells the peripheral
|
||||
# the link is gone. WinRT can surface a raw OSError (not BleakError) here,
|
||||
# so swallow both; the link tears down regardless once we exit.
|
||||
|
||||
Reference in New Issue
Block a user