Fold the Windows tray/daemon into one self-contained Clawdmeter.exe with a settings UI, and wire the host side of the watch features: - config.py: single %LOCALAPPDATA%\Clawdmeter\config.json (ha/buttons/settings), atomic writes, auto-migration from the old ha_config.json. - server.py: local FastAPI (127.0.0.1:8723) — GET/PUT /api/config (token masked), POST /api/ha/test, GET /api/ha/entities, GET /api/status. - web/index.html: brand-styled settings panel (Status/HA/Buttons/Settings tabs). - panel.py: pywebview/WebView2 window, launched as its own process (pywebview and pystray both want the main thread); tray "Settings" opens it via --panel. - daemon: HA command dispatch (toggle/bri/ct), dynamic button labels + index→ action mapping, watch-battery low warning toast, and the dimmer "dim" snapshot (dimreq → light_snapshot of the first entity) so the watch dial seeds from HA. - clawdmeter.spec / requirements: bundle fastapi+uvicorn+pywebview+webview backend. - build-exe.ps1: ASCII-only (Windows PowerShell 5.1 mangles em-dashes under cp1251). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
273 lines
14 KiB
HTML
273 lines
14 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}
|
||
#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="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>
|
||
|
||
<!-- 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));
|
||
});
|
||
|
||
// ---- 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); }
|
||
};
|
||
|
||
// ---- 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>
|