diff --git a/daemon/claude_usage_daemon_windows.py b/daemon/claude_usage_daemon_windows.py index 0736169..145a1ee 100644 --- a/daemon/claude_usage_daemon_windows.py +++ b/daemon/claude_usage_daemon_windows.py @@ -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. diff --git a/daemon/server.py b/daemon/server.py index 1f285ea..1ae1ef9 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -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: diff --git a/daemon/tests/test_provider_switch.py b/daemon/tests/test_provider_switch.py new file mode 100644 index 0000000..4d2b007 --- /dev/null +++ b/daemon/tests/test_provider_switch.py @@ -0,0 +1,82 @@ +"""v3 M1c — provider switching (on-watch cycle + control-panel offline hook). + +The daemon owns the enabled set/order, so the watch's "switch" tap just asks it +to advance (provnext -> _cycle_provider) and the panel's active_provider edit is +mirrored into the live loop (request_provider_switch). Both funnel through the +same _set_active_provider path exercised here without a real BLE loop. +""" + +import json +from unittest.mock import MagicMock + +from daemon import config + + +def _cfg(tmp_path, monkeypatch, data: dict): + 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_enabled_ids_defaults_to_anthropic(tmp_path, monkeypatch): + from daemon import claude_usage_daemon_windows as d + _cfg(tmp_path, monkeypatch, {"version": 2}) + assert d._enabled_ids() == ["anthropic"] + + +def test_cycle_advances_and_wraps(tmp_path, monkeypatch): + from daemon import claude_usage_daemon_windows as d + _cfg(tmp_path, monkeypatch, { + "version": 2, + "display_order": ["anthropic", "openai", "zai"], + "providers": {"anthropic": {"enabled": True}, + "openai": {"enabled": True}, + "zai": {"enabled": True}}, + "active_provider": "anthropic"}) + sess = d.Session(MagicMock()) + sess._cycle_provider() + assert config.active_provider_id() == "openai" + sess._cycle_provider() + assert config.active_provider_id() == "zai" + sess._cycle_provider() # wraps back to the start + assert config.active_provider_id() == "anthropic" + + +def test_cycle_skips_disabled(tmp_path, monkeypatch): + from daemon import claude_usage_daemon_windows as d + _cfg(tmp_path, monkeypatch, { + "version": 2, + "display_order": ["anthropic", "openai", "zai"], + "providers": {"anthropic": {"enabled": True}, + "openai": {"enabled": False}, + "zai": {"enabled": True}}, + "active_provider": "anthropic"}) + sess = d.Session(MagicMock()) + sess._cycle_provider() + assert config.active_provider_id() == "zai" # openai skipped (disabled) + + +def test_cycle_single_enabled_is_noop(tmp_path, monkeypatch): + from daemon import claude_usage_daemon_windows as d + _cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"}) + sess = d.Session(MagicMock()) + sess._cycle_provider() + assert config.active_provider_id() == "anthropic" + + +def test_request_switch_offline_persists(tmp_path, monkeypatch): + from daemon import claude_usage_daemon_windows as d + _cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"}) + monkeypatch.setattr(d, "_active_session", None) + d.request_provider_switch("zai") + assert config.active_provider_id() == "zai" + + +def test_request_switch_ignores_bad_id(tmp_path, monkeypatch): + from daemon import claude_usage_daemon_windows as d + _cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"}) + monkeypatch.setattr(d, "_active_session", None) + d.request_provider_switch("bogus") + assert config.active_provider_id() == "anthropic" diff --git a/daemon/tests/test_server_providers.py b/daemon/tests/test_server_providers.py new file mode 100644 index 0000000..85a6cc6 --- /dev/null +++ b/daemon/tests/test_server_providers.py @@ -0,0 +1,73 @@ +"""v3 M1c — control-panel provider API: secret masking + live-switch sink. + +Provider api_keys (z.ai) get the same never-leave-the-process treatment as the +HA token, and flipping active_provider notifies the injected command sink so a +connected watch switches without waiting for the ~60s poll. +""" + +import json + +import pytest +from fastapi.testclient import TestClient + +from daemon import config, server + + +@pytest.fixture +def client(tmp_path, monkeypatch): + cfg = { + "version": 2, + "providers": { + "anthropic": {"enabled": True}, + "openai": {"enabled": False}, + "zai": {"enabled": False, "base_url": "https://api.z.ai", "api_key": "secret-key"}, + }, + "active_provider": "anthropic", + "display_order": ["anthropic", "openai", "zai"], + } + p = tmp_path / "config.json" + p.write_text(json.dumps(cfg), encoding="utf-8") + monkeypatch.setenv("CLAWDMETER_CONFIG", str(p)) + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + monkeypatch.setattr(server, "_command_sink", None) + return TestClient(server.app) + + +def test_zai_api_key_masked_in_get(client): + r = client.get("/api/config").json() + assert r["providers"]["zai"]["api_key"] == server.MASK + assert r["providers"]["zai"]["base_url"] == "https://api.z.ai" # non-secret shown + + +def test_masked_api_key_preserved_on_put(client): + # Echo the mask back unchanged => keep the stored secret; other fields apply. + client.put("/api/config", + json={"providers": {"zai": {"api_key": server.MASK, "enabled": True}}}) + saved = config.load_config()["providers"]["zai"] + assert saved["api_key"] == "secret-key" + assert saved["enabled"] is True + + +def test_real_api_key_stored_on_put(client): + client.put("/api/config", json={"providers": {"zai": {"api_key": "new-key"}}}) + assert config.load_config()["providers"]["zai"]["api_key"] == "new-key" + + +def test_active_provider_switch_calls_sink(client, monkeypatch): + calls = [] + monkeypatch.setattr(server, "_command_sink", lambda pid: calls.append(pid)) + client.put("/api/config", json={"active_provider": "openai"}) + assert calls == ["openai"] + assert config.active_provider_id() == "openai" + + +def test_no_switch_when_active_unchanged(client, monkeypatch): + calls = [] + monkeypatch.setattr(server, "_command_sink", lambda pid: calls.append(pid)) + client.put("/api/config", json={"active_provider": "anthropic"}) # already active + assert calls == [] + + +def test_unknown_provider_id_ignored_on_put(client): + client.put("/api/config", json={"providers": {"bogus": {"enabled": True}}}) + assert "bogus" not in config.load_config()["providers"] diff --git a/daemon/tray_windows.py b/daemon/tray_windows.py index 861de7d..22c3cd4 100644 --- a/daemon/tray_windows.py +++ b/daemon/tray_windows.py @@ -226,7 +226,8 @@ def main() -> None: from pystray import Menu, MenuItem import daemon.autostart_windows as autostart - from daemon.claude_usage_daemon_windows import main as daemon_main, log as daemon_log + from daemon.claude_usage_daemon_windows import ( + main as daemon_main, log as daemon_log, request_provider_switch) from daemon.icon_assets import load_logo_rgba, build_state_icons # Build per-state icons once at startup; swap icon.icon per tick (never recomposite). @@ -277,6 +278,7 @@ def main() -> None: from daemon.config import DEFAULT_PORT panel_port = DEFAULT_PORT panel_server.set_status_provider(lambda: _status_dict(ts)) + panel_server.set_command_sink(request_provider_switch) panel_server.serve_in_thread(panel_port) daemon_log(f"Control panel: http://127.0.0.1:{panel_port}") except Exception as e: diff --git a/daemon/web/index.html b/daemon/web/index.html index bfd1f60..cb3b326 100644 --- a/daemon/web/index.html +++ b/daemon/web/index.html @@ -51,6 +51,10 @@ .stat .k{font-size:12px;color:var(--dim)} .stat .v{font-size:22px;font-weight:500;margin-top:4px} .muted{color:var(--dim);font-size:12.5px} + .swatch{width:13px;height:13px;border-radius:50%;flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.12)} + label.inline{display:inline-flex;align-items:center;gap:7px;margin:0;color:var(--text);font-size:13px;cursor:pointer} + label.inline input{accent-color:var(--accent);width:15px;height:15px;cursor:pointer} + label.inline.off{color:var(--dim);cursor:default} #toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--panel2);border:1px solid var(--border);color:var(--text);padding:10px 16px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s} #toast.show{opacity:1} @@ -60,6 +64,7 @@