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:
|
if isinstance(pid, str) and loop is not None:
|
||||||
loop.call_soon_threadsafe(self._set_active_provider, pid)
|
loop.call_soon_threadsafe(self._set_active_provider, pid)
|
||||||
return
|
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
|
# Phase 7 M2: a watch button press carries only its index — map it to the
|
||||||
# configured action/entity/value here (the watch stays dumb).
|
# configured action/entity/value here (the watch stays dumb).
|
||||||
if payload.get("cmd") == "btn":
|
if payload.get("cmd") == "btn":
|
||||||
@@ -469,6 +476,21 @@ class Session:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"Provider switch failed: {e!r}")
|
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:
|
def _handle_battery(self, payload: dict) -> None:
|
||||||
pct = payload.get("bat")
|
pct = payload.get("bat")
|
||||||
charging = bool(payload.get("chg", 0))
|
charging = bool(payload.get("chg", 0))
|
||||||
@@ -826,6 +848,53 @@ def _active_provider():
|
|||||||
return pid, get_provider(pid)
|
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:
|
async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool:
|
||||||
"""Connect to device and poll until disconnected or stopped.
|
"""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")
|
log("Connected")
|
||||||
session = Session(client, tray_state)
|
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_refresh_subscription()
|
||||||
await session.setup_command_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
|
auth_problem = status.auth_problem
|
||||||
cached = status.to_payload()
|
cached = status.to_payload()
|
||||||
cached["pv"] = active_pv # which brand theme the watch wears
|
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
|
try: # brand accent (0xRRGGBB) for the theme
|
||||||
cached["ac"] = int(provider.accent, 16)
|
cached["ac"] = int(provider.accent, 16)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
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
|
last_claude_poll = now
|
||||||
|
|
||||||
# Now Playing (Phase 5) — best-effort Windows media session, read every
|
# 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,
|
await _wait_first(session.refresh_requested, stop_event,
|
||||||
timeout=NOWPLAYING_INTERVAL)
|
timeout=NOWPLAYING_INTERVAL)
|
||||||
finally:
|
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
|
# 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,
|
# the link is gone. WinRT can surface a raw OSError (not BleakError) here,
|
||||||
# so swallow both; the link tears down regardless once we exit.
|
# so swallow both; the link tears down regardless once we exit.
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ def _masked(cfg: dict) -> dict:
|
|||||||
c = copy.deepcopy(cfg)
|
c = copy.deepcopy(cfg)
|
||||||
if c.get("ha", {}).get("token"):
|
if c.get("ha", {}).get("token"):
|
||||||
c["ha"]["token"] = MASK
|
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
|
return c
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +60,9 @@ class ConfigIn(BaseModel):
|
|||||||
ha: dict | None = None
|
ha: dict | None = None
|
||||||
buttons: list | None = None
|
buttons: list | None = None
|
||||||
settings: dict | 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")
|
@app.put("/api/config")
|
||||||
@@ -71,10 +79,61 @@ def put_config(incoming: ConfigIn) -> dict:
|
|||||||
cfg["settings"].update(data["settings"])
|
cfg["settings"].update(data["settings"])
|
||||||
if "buttons" in data:
|
if "buttons" in data:
|
||||||
cfg["buttons"] = data["buttons"]
|
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)
|
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)
|
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:
|
def _resolve_token(token: str) -> str:
|
||||||
return cfgmod.load_config()["ha"]["token"] if token == MASK else token
|
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
|
_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:
|
def set_status_provider(fn) -> None:
|
||||||
@@ -132,6 +192,14 @@ def set_status_provider(fn) -> None:
|
|||||||
_status_provider = fn
|
_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")
|
@app.get("/api/status")
|
||||||
def get_status() -> dict:
|
def get_status() -> dict:
|
||||||
if _status_provider is not None:
|
if _status_provider is not None:
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -226,7 +226,8 @@ def main() -> None:
|
|||||||
from pystray import Menu, MenuItem
|
from pystray import Menu, MenuItem
|
||||||
|
|
||||||
import daemon.autostart_windows as autostart
|
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
|
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).
|
# 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
|
from daemon.config import DEFAULT_PORT
|
||||||
panel_port = DEFAULT_PORT
|
panel_port = DEFAULT_PORT
|
||||||
panel_server.set_status_provider(lambda: _status_dict(ts))
|
panel_server.set_status_provider(lambda: _status_dict(ts))
|
||||||
|
panel_server.set_command_sink(request_provider_switch)
|
||||||
panel_server.serve_in_thread(panel_port)
|
panel_server.serve_in_thread(panel_port)
|
||||||
daemon_log(f"Control panel: http://127.0.0.1:{panel_port}")
|
daemon_log(f"Control panel: http://127.0.0.1:{panel_port}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -51,6 +51,10 @@
|
|||||||
.stat .k{font-size:12px;color:var(--dim)}
|
.stat .k{font-size:12px;color:var(--dim)}
|
||||||
.stat .v{font-size:22px;font-weight:500;margin-top:4px}
|
.stat .v{font-size:22px;font-weight:500;margin-top:4px}
|
||||||
.muted{color:var(--dim);font-size:12.5px}
|
.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{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}
|
#toast.show{opacity:1}
|
||||||
</style>
|
</style>
|
||||||
@@ -60,6 +64,7 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<div class="brand"><span class="dot"></span>Clawdmeter</div>
|
<div class="brand"><span class="dot"></span>Clawdmeter</div>
|
||||||
<button class="tabbtn active" data-tab="status"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>Status</button>
|
<button class="tabbtn active" data-tab="status"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>Status</button>
|
||||||
|
<button class="tabbtn" data-tab="providers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>Providers</button>
|
||||||
<button class="tabbtn" data-tab="ha"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 1 4 10 4 4 0 0 0-1 3H9a4 4 0 0 0-1-3 6 6 0 0 1 4-10z"/></svg>Home Assistant</button>
|
<button class="tabbtn" data-tab="ha"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 1 4 10 4 4 0 0 0-1 3H9a4 4 0 0 0-1-3 6 6 0 0 1 4-10z"/></svg>Home Assistant</button>
|
||||||
<button class="tabbtn" data-tab="buttons"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>Buttons</button>
|
<button class="tabbtn" data-tab="buttons"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>Buttons</button>
|
||||||
<button class="tabbtn" data-tab="settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/><circle cx="9" cy="6" r="2" fill="var(--bg)"/><circle cx="15" cy="12" r="2" fill="var(--bg)"/><circle cx="8" cy="18" r="2" fill="var(--bg)"/></svg>Settings</button>
|
<button class="tabbtn" data-tab="settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/><circle cx="9" cy="6" r="2" fill="var(--bg)"/><circle cx="15" cy="12" r="2" fill="var(--bg)"/><circle cx="8" cy="18" r="2" fill="var(--bg)"/></svg>Settings</button>
|
||||||
@@ -79,6 +84,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- PROVIDERS -->
|
||||||
|
<section class="tab" data-tab="providers">
|
||||||
|
<h1>Providers</h1>
|
||||||
|
<div class="sub">Which usage source the watch shows — and its brand colour</div>
|
||||||
|
<div id="prov-list"></div>
|
||||||
|
<div class="actions"><button class="primary" id="prov-save">Save</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- HOME ASSISTANT -->
|
<!-- HOME ASSISTANT -->
|
||||||
<section class="tab" data-tab="ha">
|
<section class="tab" data-tab="ha">
|
||||||
<h1>Home Assistant</h1>
|
<h1>Home Assistant</h1>
|
||||||
@@ -146,6 +159,7 @@ document.querySelectorAll(".tabbtn").forEach(b=>b.onclick=()=>{
|
|||||||
document.querySelectorAll(".tabbtn").forEach(x=>x.classList.toggle("active",x===b));
|
document.querySelectorAll(".tabbtn").forEach(x=>x.classList.toggle("active",x===b));
|
||||||
const id=b.dataset.tab;
|
const id=b.dataset.tab;
|
||||||
document.querySelectorAll(".tab").forEach(s=>s.classList.toggle("active",s.dataset.tab===id));
|
document.querySelectorAll(".tab").forEach(s=>s.classList.toggle("active",s.dataset.tab===id));
|
||||||
|
if(id==="providers") loadProviders().catch(e=>toast("Load failed: "+e.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- load ----
|
// ---- load ----
|
||||||
@@ -251,6 +265,64 @@ $("#set-save").onclick = async ()=>{
|
|||||||
}catch(e){ toast("Error: "+e.message); }
|
}catch(e){ toast("Error: "+e.message); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Providers ----
|
||||||
|
let providers = []; // [{id,label,accent,enabled,needs_key,base_url,has_key,...}]
|
||||||
|
let activeProv = "anthropic";
|
||||||
|
async function loadProviders(){
|
||||||
|
const r = await api("/api/providers");
|
||||||
|
providers = r.providers; activeProv = r.active;
|
||||||
|
// Seed the editable secret from has_key: a stored key shows (and re-saves) as the
|
||||||
|
// mask, so leaving it untouched preserves it; typing over it sends the new key.
|
||||||
|
providers.forEach(p=>{ if(p.needs_key) p.api_key = p.has_key ? MASK : ""; });
|
||||||
|
renderProviders();
|
||||||
|
}
|
||||||
|
function esc(s){return (s||"").replace(/"/g,""");}
|
||||||
|
function renderProviders(){
|
||||||
|
const list=$("#prov-list"); list.innerHTML="";
|
||||||
|
providers.forEach(p=>{
|
||||||
|
const card=document.createElement("div"); card.className="card";
|
||||||
|
const keyBlock = p.needs_key ? `
|
||||||
|
<div data-keys="${p.id}" style="${p.enabled?"":"display:none"};margin-top:12px">
|
||||||
|
<label>Base URL</label>
|
||||||
|
<input type="text" data-pf="base_url" data-pid="${p.id}" value="${esc(p.base_url)}" placeholder="https://api.z.ai/api/anthropic" autocomplete="off">
|
||||||
|
<label>API key</label>
|
||||||
|
<input type="password" data-pf="api_key" data-pid="${p.id}" value="${esc(p.api_key)}" placeholder="Paste key" autocomplete="off">
|
||||||
|
</div>` : "";
|
||||||
|
const actCls = p.enabled?"inline":"inline off";
|
||||||
|
card.innerHTML=`
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div class="row" style="gap:10px"><span class="swatch" style="background:#${p.accent}"></span><b style="font-weight:500">${p.label}</b></div>
|
||||||
|
<div class="row" style="gap:18px">
|
||||||
|
<label class="inline"><input type="checkbox" data-pen="${p.id}" ${p.enabled?"checked":""}> Enabled</label>
|
||||||
|
<label class="${actCls}"><input type="radio" name="activeprov" data-pact="${p.id}" ${activeProv===p.id?"checked":""} ${p.enabled?"":"disabled"}> Show on watch</label>
|
||||||
|
</div>
|
||||||
|
</div>${keyBlock}`;
|
||||||
|
list.appendChild(card);
|
||||||
|
});
|
||||||
|
list.querySelectorAll("[data-pen]").forEach(el=>el.onchange=()=>{
|
||||||
|
const p=providers.find(x=>x.id===el.dataset.pen); p.enabled=el.checked;
|
||||||
|
if(!el.checked && activeProv===p.id){ // disabling the shown one → hand off
|
||||||
|
const alt=providers.find(x=>x.enabled); activeProv = alt?alt.id:"anthropic";
|
||||||
|
}
|
||||||
|
if(el.checked && !providers.some(x=>x.enabled&&x.id===activeProv)) activeProv=p.id;
|
||||||
|
renderProviders();
|
||||||
|
});
|
||||||
|
list.querySelectorAll("[data-pact]").forEach(el=>el.onchange=()=>{activeProv=el.dataset.pact;});
|
||||||
|
list.querySelectorAll("[data-pf]").forEach(el=>el.oninput=()=>{
|
||||||
|
providers.find(x=>x.id===el.dataset.pid)[el.dataset.pf]=el.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$("#prov-save").onclick = async ()=>{
|
||||||
|
const payload={providers:{}, active_provider:activeProv};
|
||||||
|
providers.forEach(p=>{
|
||||||
|
const pc={enabled:!!p.enabled};
|
||||||
|
if(p.needs_key){ pc.base_url=(p.base_url||"").trim(); pc.api_key=p.api_key||""; }
|
||||||
|
payload.providers[p.id]=pc;
|
||||||
|
});
|
||||||
|
try{ await api("/api/config","PUT",payload); toast("Providers saved"); await loadProviders(); }
|
||||||
|
catch(e){ toast("Error: "+e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
// ---- Status poll ----
|
// ---- Status poll ----
|
||||||
async function pollStatus(){
|
async function pollStatus(){
|
||||||
try{
|
try{
|
||||||
|
|||||||
Reference in New Issue
Block a user