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>
345 lines
19 KiB
HTML
345 lines
19 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Clawdmeter</title>
|
||
<style>
|
||
:root{
|
||
--bg:#131211; --panel:#1f1f1e; --panel2:#232220; --text:#faf9f5; --dim:#b0aea5;
|
||
--accent:#d97757; --accent-text:#4a1b0c; --green:#788c5d; --red:#c0392b;
|
||
--border:rgba(255,255,255,.09);
|
||
}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;height:100%}
|
||
body{background:var(--bg);color:var(--text);font-family:"Segoe UI",system-ui,sans-serif;font-size:14px}
|
||
.app{display:flex;height:100vh}
|
||
.nav{width:188px;flex-shrink:0;background:#161514;border-right:1px solid var(--border);padding:14px 10px;display:flex;flex-direction:column;gap:3px}
|
||
.brand{display:flex;align-items:center;gap:9px;padding:4px 10px 14px;font-weight:500}
|
||
.brand .dot{width:11px;height:11px;border-radius:50%;background:var(--accent)}
|
||
.nav button.tabbtn{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;border:none;background:transparent;color:var(--dim);font-size:14px;cursor:pointer;text-align:left;width:100%}
|
||
.nav button.tabbtn:hover{background:#1c1b1a}
|
||
.nav button.tabbtn.active{background:var(--panel2);color:var(--text)}
|
||
.nav button.tabbtn.active svg{color:var(--accent)}
|
||
.nav svg{width:18px;height:18px;flex-shrink:0}
|
||
.navstatus{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px;font-size:12px;color:var(--dim)}
|
||
.led{width:8px;height:8px;border-radius:50%;background:#555;flex-shrink:0}
|
||
.led.on{background:var(--green)}
|
||
.content{flex:1;overflow-y:auto;padding:22px 26px}
|
||
h1{font-size:18px;font-weight:500;margin:0}
|
||
.sub{font-size:13px;color:var(--dim);margin:3px 0 18px}
|
||
label{font-size:12px;color:var(--dim);display:block;margin:14px 0 6px}
|
||
input[type=text],input[type=password],input[type=number],select{width:100%;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--text);font-size:14px;outline:none}
|
||
input:focus,select:focus{border-color:var(--accent)}
|
||
button.primary{background:var(--accent);color:var(--accent-text);border:none;border-radius:8px;padding:9px 18px;font-size:14px;font-weight:500;cursor:pointer}
|
||
button.ghost{background:transparent;border:1px solid var(--border);color:var(--dim);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer}
|
||
button.ghost:hover{color:var(--text);border-color:rgba(255,255,255,.2)}
|
||
.row{display:flex;gap:10px;align-items:center}
|
||
.pill{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:4px 11px;font-size:12px;margin-top:8px}
|
||
.pill.ok{background:rgba(120,140,93,.16);color:#9bb074}
|
||
.pill.err{background:rgba(192,57,43,.16);color:#e3897f}
|
||
.chips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px}
|
||
.chip{display:inline-flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px}
|
||
.chip b{cursor:pointer;color:#7d7b74;font-weight:400}
|
||
.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:10px}
|
||
.grid{display:grid;grid-template-columns:1.4fr 1fr 90px auto;gap:10px;align-items:end}
|
||
.tab{display:none}
|
||
.tab.active{display:block}
|
||
.actions{margin-top:22px;display:flex;justify-content:flex-end;gap:10px}
|
||
.statgrid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px}
|
||
.stat{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px}
|
||
.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>
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<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>
|
||
<div class="navstatus"><span class="led" id="navled"></span><span id="navstate">Connecting…</span></div>
|
||
</nav>
|
||
|
||
<main class="content">
|
||
<!-- STATUS -->
|
||
<section class="tab active" data-tab="status">
|
||
<h1>Status</h1>
|
||
<div class="sub">Watch connection and live readings</div>
|
||
<div class="statgrid">
|
||
<div class="stat"><div class="k">Watch</div><div class="v" id="st-conn">—</div></div>
|
||
<div class="stat"><div class="k">Battery</div><div class="v" id="st-batt">—</div></div>
|
||
<div class="stat"><div class="k">Daemon</div><div class="v" id="st-state">—</div></div>
|
||
<div class="stat"><div class="k">Last update</div><div class="v" id="st-sync">—</div></div>
|
||
</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>
|
||
<div class="sub">Connection & devices</div>
|
||
<label>Server URL</label>
|
||
<input type="text" id="ha-url" placeholder="https://homeassistant.local:8123" autocomplete="off">
|
||
<label>Long-lived access token</label>
|
||
<div class="row">
|
||
<input type="password" id="ha-token" placeholder="Paste token" autocomplete="off">
|
||
<button class="ghost" id="ha-test">Test</button>
|
||
</div>
|
||
<div id="ha-testresult"></div>
|
||
<label>Controlled entities</label>
|
||
<div class="chips" id="ha-chips"></div>
|
||
<div class="row" style="margin-top:10px">
|
||
<select id="ha-picker"><option value="">Load devices to add…</option></select>
|
||
<button class="ghost" id="ha-load">Load devices</button>
|
||
<button class="ghost" id="ha-add">Add</button>
|
||
</div>
|
||
<div class="actions"><button class="primary" id="ha-save">Save</button></div>
|
||
</section>
|
||
|
||
<!-- BUTTONS -->
|
||
<section class="tab" data-tab="buttons">
|
||
<h1>Buttons</h1>
|
||
<div class="sub">Actions you can fire (and, later, show on the watch)</div>
|
||
<div id="btn-list"></div>
|
||
<button class="ghost" id="btn-add">+ Add button</button>
|
||
<div class="actions"><button class="primary" id="btn-save">Save</button></div>
|
||
</section>
|
||
|
||
<!-- SETTINGS -->
|
||
<section class="tab" data-tab="settings">
|
||
<h1>Settings</h1>
|
||
<div class="sub">App behaviour</div>
|
||
<label>Low-battery warning at (%)</label>
|
||
<input type="number" id="set-lowbatt" min="1" max="100" step="1">
|
||
<label>Watch BLE address <span class="muted">(optional — for bonded, non-advertising watches)</span></label>
|
||
<input type="text" id="set-addr" placeholder="44:1B:F6:85:1E:51" autocomplete="off">
|
||
<div class="actions"><button class="primary" id="set-save">Save</button></div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
<div id="toast"></div>
|
||
|
||
<script>
|
||
const MASK = "********";
|
||
let cfg = null;
|
||
let entities = []; // current controlled entities (chips)
|
||
let lights = []; // available light.* from HA
|
||
let buttons = []; // current button defs
|
||
|
||
const $ = s => document.querySelector(s);
|
||
async function api(path, method="GET", body=null){
|
||
const opt = {method, headers:{}};
|
||
if(body){opt.headers["Content-Type"]="application/json"; opt.body=JSON.stringify(body);}
|
||
const r = await fetch(path, opt);
|
||
if(!r.ok) throw new Error("HTTP "+r.status);
|
||
return r.json();
|
||
}
|
||
function toast(msg){const t=$("#toast");t.textContent=msg;t.classList.add("show");clearTimeout(t._h);t._h=setTimeout(()=>t.classList.remove("show"),1800);}
|
||
|
||
// ---- tabs ----
|
||
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 ----
|
||
async function loadConfig(){
|
||
cfg = await api("/api/config");
|
||
$("#ha-url").value = cfg.ha.url || "";
|
||
$("#ha-token").value = cfg.ha.token || ""; // server sends MASK when a token is set
|
||
entities = (cfg.ha.entities||[]).slice();
|
||
buttons = (cfg.buttons||[]).map(b=>Object.assign({}, b));
|
||
$("#set-lowbatt").value = cfg.settings.low_battery_pct ?? 15;
|
||
$("#set-addr").value = cfg.settings.device_address || "";
|
||
renderChips(); renderButtons();
|
||
}
|
||
|
||
// ---- HA ----
|
||
function renderChips(){
|
||
const c=$("#ha-chips"); c.innerHTML="";
|
||
if(!entities.length){c.innerHTML='<span class="muted">No devices yet — add some below.</span>';}
|
||
entities.forEach(e=>{
|
||
const s=document.createElement("span"); s.className="chip";
|
||
s.innerHTML = e+' <b title="Remove">✕</b>';
|
||
s.querySelector("b").onclick=()=>{entities=entities.filter(x=>x!==e);renderChips();renderButtons();};
|
||
c.appendChild(s);
|
||
});
|
||
}
|
||
$("#ha-test").onclick = async ()=>{
|
||
const box=$("#ha-testresult"); box.innerHTML='<span class="muted">Testing…</span>';
|
||
try{
|
||
const r = await api("/api/ha/test","POST",{url:$("#ha-url").value.trim(), token:$("#ha-token").value});
|
||
box.innerHTML = r.ok ? '<span class="pill ok">✓ '+(r.message||"Connected")+'</span>'
|
||
: '<span class="pill err">✕ '+(r.error||"Failed")+'</span>';
|
||
}catch(e){box.innerHTML='<span class="pill err">✕ '+e.message+'</span>';}
|
||
};
|
||
$("#ha-load").onclick = async ()=>{
|
||
// Persist url+token first so the server can query HA with them.
|
||
await saveHA(true);
|
||
const r = await api("/api/ha/entities");
|
||
lights = r.entities||[];
|
||
const sel=$("#ha-picker"); sel.innerHTML="";
|
||
if(!lights.length){sel.innerHTML='<option value="">'+(r.error||"No lights found")+'</option>';return;}
|
||
sel.appendChild(new Option("Select a device…",""));
|
||
lights.forEach(l=>sel.appendChild(new Option(l,l)));
|
||
toast(lights.length+" devices loaded");
|
||
};
|
||
$("#ha-add").onclick = ()=>{
|
||
const v=$("#ha-picker").value; if(!v) return;
|
||
if(!entities.includes(v)){entities.push(v);renderChips();renderButtons();}
|
||
};
|
||
async function saveHA(silent){
|
||
const ha={url:$("#ha-url").value.trim(), token:$("#ha-token").value, entities};
|
||
cfg = await api("/api/config","PUT",{ha});
|
||
$("#ha-token").value = cfg.ha.token; // re-mask
|
||
if(!silent) toast("Home Assistant saved");
|
||
}
|
||
$("#ha-save").onclick = ()=>saveHA(false).catch(e=>toast("Error: "+e.message));
|
||
|
||
// ---- Buttons ----
|
||
const ACTIONS=[["toggle","Toggle on/off"],["bri","Set brightness %"],["ct","Set color temp K"]];
|
||
function renderButtons(){
|
||
const list=$("#btn-list"); list.innerHTML="";
|
||
if(!buttons.length){list.insertAdjacentHTML("beforeend",'<div class="muted" style="margin-bottom:10px">No buttons yet.</div>');}
|
||
buttons.forEach((b,i)=>{
|
||
const card=document.createElement("div"); card.className="card";
|
||
const entOpts = entities.map(e=>`<option value="${e}" ${e===b.entity?"selected":""}>${e}</option>`).join("");
|
||
const actOpts = ACTIONS.map(([v,l])=>`<option value="${v}" ${v===b.action?"selected":""}>${l}</option>`).join("");
|
||
const showVal = b.action==="bri"||b.action==="ct";
|
||
card.innerHTML=`
|
||
<div class="grid">
|
||
<div><label>Label</label><input type="text" data-i="${i}" data-f="label" value="${(b.label||"").replace(/"/g,'"')}"></div>
|
||
<div><label>Device</label><select data-i="${i}" data-f="entity">${entOpts||'<option value="">— add a device first —</option>'}</select></div>
|
||
<div><label>Action</label><select data-i="${i}" data-f="action">${actOpts}</select></div>
|
||
<div><button class="ghost" data-del="${i}">Remove</button></div>
|
||
</div>
|
||
<div data-val="${i}" style="${showVal?"":"display:none"};margin-top:8px">
|
||
<label>${b.action==="ct"?"Kelvin (2000–6500)":"Brightness %"}</label>
|
||
<input type="number" data-i="${i}" data-f="value" value="${b.value??(b.action==="ct"?3000:60)}" style="max-width:160px">
|
||
</div>`;
|
||
list.appendChild(card);
|
||
});
|
||
list.querySelectorAll("[data-f]").forEach(el=>el.oninput=()=>{
|
||
const i=+el.dataset.i, f=el.dataset.f;
|
||
buttons[i][f] = f==="value" ? +el.value : el.value;
|
||
if(f==="action"){ buttons[i].icon=el.value; renderButtons(); }
|
||
});
|
||
list.querySelectorAll("[data-del]").forEach(el=>el.onclick=()=>{buttons.splice(+el.dataset.del,1);renderButtons();});
|
||
}
|
||
$("#btn-add").onclick = ()=>{
|
||
buttons.push({id:"b"+Date.now().toString(36), label:"Light", icon:"toggle",
|
||
action:"toggle", entity:entities[0]||"", value:null});
|
||
renderButtons();
|
||
};
|
||
$("#btn-save").onclick = async ()=>{
|
||
try{ cfg = await api("/api/config","PUT",{buttons}); toast("Buttons saved"); }
|
||
catch(e){ toast("Error: "+e.message); }
|
||
};
|
||
|
||
// ---- Settings ----
|
||
$("#set-save").onclick = async ()=>{
|
||
try{
|
||
cfg = await api("/api/config","PUT",{settings:{
|
||
low_battery_pct:+$("#set-lowbatt").value, device_address:$("#set-addr").value.trim()}});
|
||
toast("Settings saved");
|
||
}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 ----
|
||
async function pollStatus(){
|
||
try{
|
||
const s = await api("/api/status");
|
||
const conn = s.connected;
|
||
$("#navled").classList.toggle("on",conn);
|
||
$("#navstate").textContent = conn?"Watch connected":(s.state==="unknown"?"Daemon off":"Scanning…");
|
||
$("#st-conn").textContent = conn?"Connected":"Disconnected";
|
||
$("#st-batt").textContent = (s.battery==null)?"—":s.battery+"%";
|
||
$("#st-state").textContent = s.state||"—";
|
||
$("#st-sync").textContent = s.last_sync? new Date(s.last_sync*1000).toLocaleTimeString():"—";
|
||
}catch(e){ $("#navstate").textContent="Panel only"; }
|
||
}
|
||
|
||
loadConfig().catch(e=>toast("Load failed: "+e.message));
|
||
pollStatus(); setInterval(pollStatus,3000);
|
||
</script>
|
||
</body>
|
||
</html>
|