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
+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{