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
+78
View File
@@ -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.
+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:
+82
View File
@@ -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"
+73
View File
@@ -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"]
+3 -1
View File
@@ -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:
+72
View File
@@ -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}
</style>
@@ -60,6 +64,7 @@
<nav class="nav">
<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" 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="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>
@@ -79,6 +84,14 @@
</div>
</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 -->
<section class="tab" data-tab="ha">
<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));
const id=b.dataset.tab;
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 ----
@@ -251,6 +265,64 @@ $("#set-save").onclick = async ()=>{
}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,"&quot;");}
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 ----
async function pollStatus(){
try{