Files
clawdmeter/daemon/web/index.html
T
wenilandClaude Opus 4.8 1c64386996 v2 host: portable desktop app (tray + FastAPI + WebView2 panel), unified config, HA control
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>
2026-07-09 20:20:44 +03:00

273 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &amp; 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,'&quot;')}"></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 (20006500)":"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>