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>
This commit is contained in:
wenil
2026-07-09 20:20:44 +03:00
co-authored by Claude Opus 4.8
parent 578bc04248
commit 1c64386996
10 changed files with 977 additions and 11 deletions
+5
View File
@@ -30,3 +30,8 @@ __pycache__/
# Local Home Assistant config (holds a secret token - never commit) # Local Home Assistant config (holds a secret token - never commit)
daemon/ha_config.json daemon/ha_config.json
# Session scratch — BLE daemon run logs + one-off on-device QA screenshots
/m3_daemon*.log
/dimmer_qa.png
/build-exe.log
+3 -3
View File
@@ -1,4 +1,4 @@
# build-exe.ps1 build the standalone Clawdmeter.exe (PyInstaller). # build-exe.ps1 - build the standalone Clawdmeter.exe (PyInstaller).
# #
# Produces dist\Clawdmeter.exe: a single self-contained tray + daemon executable # Produces dist\Clawdmeter.exe: a single self-contained tray + daemon executable
# that runs on ANY Windows 11 machine with no Python install and no pip. Build it # that runs on ANY Windows 11 machine with no Python install and no pip. Build it
@@ -26,7 +26,7 @@ Log "=== Clawdmeter exe build ==="
if (-not (Test-Path $PythonExe)) { if (-not (Test-Path $PythonExe)) {
Log "Creating virtual environment at .venv ..." Log "Creating virtual environment at .venv ..."
& python -m venv $VenvDir & python -m venv $VenvDir
if ($LASTEXITCODE -ne 0) { throw "venv creation failed (exit $LASTEXITCODE) is Python on PATH?" } if ($LASTEXITCODE -ne 0) { throw "venv creation failed (exit $LASTEXITCODE) - is Python on PATH?" }
} }
# 2. Runtime dependencies + PyInstaller (the only build-time extra) # 2. Runtime dependencies + PyInstaller (the only build-time extra)
@@ -45,4 +45,4 @@ $ExePath = Join-Path $RepoRoot "dist\Clawdmeter.exe"
if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" } if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" }
$sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1) $sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1)
Log "Build complete: $ExePath ($sizeMB MB)" Log "Build complete: $ExePath ($sizeMB MB)"
Log "Distribute this exe via a Gitea release it is intentionally not committed to git." Log "Distribute this exe via a Gitea release - it is intentionally not committed to git."
+21 -2
View File
@@ -21,18 +21,37 @@
from PyInstaller.utils.hooks import collect_all from PyInstaller.utils.hooks import collect_all
datas = [('firmware/src/logo.h', 'firmware/src')] # tray icon parsed at runtime datas = [
('firmware/src/logo.h', 'firmware/src'), # tray icon parsed at runtime
('daemon/web', 'daemon/web'), # Phase 7 control-panel UI (server._web_dir)
]
binaries = [] binaries = []
hiddenimports = [ hiddenimports = [
# Imported lazily inside tray_windows.main(), so name them explicitly. # Imported lazily inside tray_windows.main(), so name them explicitly.
'daemon.claude_usage_daemon_windows', 'daemon.claude_usage_daemon_windows',
'daemon.autostart_windows', 'daemon.autostart_windows',
'daemon.icon_assets', 'daemon.icon_assets',
# Phase 7 control panel (lazy string-imports inside tray_windows / server).
'daemon.config',
'daemon.server',
'daemon.panel',
'daemon.ha_client',
# The exact winrt media modules read_now_playing() pulls in. # The exact winrt media modules read_now_playing() pulls in.
'winrt.windows.media', 'winrt.windows.media',
'winrt.windows.media.control', 'winrt.windows.media.control',
# pywebview's Windows backend + pythonnet bridge load these dynamically.
'webview.platforms.edgechromium',
'clr',
] ]
for _pkg in ('winrt', 'bleak', 'pystray', 'PIL'): # Phase 7 adds the FastAPI control panel + the pywebview WebView2 window. uvicorn
# and pywebview both import their submodules (loop/protocol pickers; the
# edgechromium backend + bundled WebView2 DLLs) dynamically, which PyInstaller's
# static analysis misses — collect_all pulls submodules, binaries and data files.
# clr_loader/pythonnet ship the .NET runtime-config JSON pywebview's WinForms host
# needs. If a package is absent the build fails loudly (better than a silent
# blank window at runtime).
for _pkg in ('winrt', 'bleak', 'pystray', 'PIL',
'fastapi', 'uvicorn', 'webview', 'clr_loader', 'pythonnet'):
_d, _b, _h = collect_all(_pkg) _d, _b, _h = collect_all(_pkg)
datas += _d datas += _d
binaries += _b binaries += _b
+195 -6
View File
@@ -32,6 +32,14 @@ DEVICE_ADDRESS = os.environ.get("CLAWDMETER_ADDRESS")
SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001" SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001"
RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002" RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002"
REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004" REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004"
CMD_CHAR_UUID = "4c41555a-4465-7669-6365-000000000005" # watch → host commands (Phase 6 HA)
# Phase 7 M2 — dynamic watch buttons. The daemon pushes up to this many button
# labels (each truncated) in the RX payload's "btns" array on the ~60s heartbeat;
# the watch renders a grid and reports only the pressed index. Bounds keep the
# merged BLE payload comfortably under the firmware's 512-byte RX buffer.
WATCH_MAX_BUTTONS = 6
WATCH_LABEL_MAX = 16
POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds) POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds)
NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The
@@ -296,9 +304,58 @@ async def scan_for_device():
class Session: class Session:
def __init__(self, client: BleakClient) -> None: def __init__(self, client: BleakClient, tray_state=None) -> None:
self.client = client self.client = client
self.tray_state = tray_state
self.refresh_requested = asyncio.Event() self.refresh_requested = asyncio.Event()
self.dim_requested = asyncio.Event() # watch opened the Dimmer screen → push a fresh light snapshot
self.ha = None # HAClient once a valid HA config is loaded
self.ha_entities: list = [] # controllable entity_ids from the config
self._loop = None # event loop captured for thread-safe BLE dispatch
self.low_bat_pct = 15 # warn at/below this watch battery %
self._battery_warned = False
self._last_logged_bat = None
self.buttons = [] # config buttons; list index == watch button index
def reload_buttons(self) -> None:
"""Re-read the config's button list so panel edits propagate to the watch
(called each poll). The list index is the index the watch reports back."""
try:
try:
from daemon.config import load_config
except ImportError:
from config import load_config
self.buttons = load_config().get("buttons") or []
except Exception as e:
log(f"Button reload failed: {e!r}")
def button_labels(self) -> list:
"""Compact label list for the RX 'btns' payload (count + length capped so
the merged BLE write stays under the firmware's 512-byte buffer)."""
out = []
for b in self.buttons[:WATCH_MAX_BUTTONS]:
lbl = str(b.get("label") or b.get("entity") or "Button").strip()
out.append(lbl[:WATCH_LABEL_MAX])
return out
async def dim_snapshot(self) -> dict | None:
"""Compact live state of the tilt-dimmer's light for the RX 'dim' object,
so the watch dial seeds from reality. The dimmer targets the first HA
entity (same default the bri/ct commands fall back to). Returns None when
HA is off or the read fails — the watch then just shows its last value."""
if not self.ha or not self.ha_entities:
return None
snap = await self.ha.light_snapshot(self.ha_entities[0])
if not snap:
return None
out = {"on": 1 if snap["on"] else 0,
"mink": int(snap["min_kelvin"]),
"maxk": int(snap["max_kelvin"])}
if snap["brightness_pct"] is not None:
out["bri"] = int(snap["brightness_pct"])
if snap["color_temp_kelvin"] is not None:
out["ct"] = int(snap["color_temp_kelvin"])
return out
def _on_refresh(self, _char, _data: bytearray) -> None: def _on_refresh(self, _char, _data: bytearray) -> None:
log("Refresh requested by device") log("Refresh requested by device")
@@ -316,6 +373,112 @@ class Session:
except (BleakError, ValueError, OSError) as e: except (BleakError, ValueError, OSError) as e:
log(f"Refresh subscription unavailable: {e}") log(f"Refresh subscription unavailable: {e}")
async def setup_command_subscription(self) -> None:
# Watch → host channel (…0005): battery telemetry (always) plus HA commands
# (when HA is configured). We subscribe regardless of HA so the low-battery
# warning works even with Home Assistant unset. Imports are lazy so a
# missing config/dep can never break daemon startup, and so they resolve
# after `log` is defined (ha_client imports it).
self._loop = asyncio.get_running_loop() # thread-safe dispatch from the BLE callback
try:
try:
from daemon.ha_client import HAClient
from daemon.config import load_config, ha_settings
except ImportError:
from ha_client import HAClient
from config import load_config, ha_settings
cfg = load_config()
self.low_bat_pct = int(cfg.get("settings", {}).get("low_battery_pct", 15))
self.buttons = cfg.get("buttons") or []
ha = ha_settings(cfg)
if ha:
self.ha = HAClient(ha["url"], ha["token"])
self.ha_entities = ha["entities"]
except Exception as e:
log(f"Config/HA init failed ({e!r}); HA control disabled")
try:
await self.client.start_notify(CMD_CHAR_UUID, self._on_command)
log(f"Command channel ready (HA={'on' if self.ha else 'off'}, "
f"low-batt={self.low_bat_pct}%, entities={self.ha_entities})")
except (BleakError, ValueError, OSError) as e:
log(f"Command subscription unavailable: {e}")
def _on_command(self, _char, data: bytearray) -> None:
try:
payload = json.loads(bytes(data).decode("utf-8", "replace"))
except (ValueError, UnicodeDecodeError):
log(f"Watch msg: bad payload {bytes(data)!r}")
return
# Battery telemetry ({"bat":pct,"mv":..,"chg":0/1}) is handled inline (quick,
# no await). HA commands ({"cmd":...}) are dispatched onto the loop because
# bleak may deliver this callback on a non-loop thread.
if "bat" in payload:
self._handle_battery(payload)
return
# Dimmer screen opened (M3): ask the poll loop to push a fresh light
# snapshot on its next tick (~3s). asyncio.Event isn't thread-safe and
# this callback may run off-loop, so flip it via the loop.
if payload.get("cmd") == "dimreq":
loop = self._loop
if loop is not None:
loop.call_soon_threadsafe(self.dim_requested.set)
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":
i = payload.get("i")
if not isinstance(i, int) or not (0 <= i < len(self.buttons)):
log(f"Watch button {i}: out of range (have {len(self.buttons)})")
return
b = self.buttons[i]
payload = {"cmd": b.get("action") or "toggle", "e": b.get("entity")}
if b.get("value") is not None:
payload["v"] = b["value"]
log(f"Watch button {i} -> {payload}")
loop = self._loop
if loop is None:
return
asyncio.run_coroutine_threadsafe(self._dispatch_command(payload), loop)
def _handle_battery(self, payload: dict) -> None:
pct = payload.get("bat")
charging = bool(payload.get("chg", 0))
if not isinstance(pct, int):
return
if pct != self._last_logged_bat:
self._last_logged_bat = pct
log(f"Watch battery: {pct}%{' (charging)' if charging else ''}")
if self.tray_state is not None:
self.tray_state.battery_pct = pct
# Warn once on the way down; re-arm when charging or comfortably recovered.
if charging or pct > self.low_bat_pct + 10:
self._battery_warned = False
if not charging and pct <= self.low_bat_pct and not self._battery_warned:
self._battery_warned = True
log(f"Watch battery low: {pct}% (<= {self.low_bat_pct}%)")
if self.tray_state is not None:
self.tray_state.toasts.put(
("Clawdmeter", f"Watch battery low - {pct}%. Time to charge it."))
async def _dispatch_command(self, payload: dict) -> None:
if not self.ha:
return
cmd = payload.get("cmd")
entity = payload.get("e") or (self.ha_entities[0] if self.ha_entities else None)
if not entity:
log("HA cmd: no entity to target")
return
if cmd == "toggle":
ok = await self.ha.toggle(entity)
elif cmd == "bri": # step 3: brightness %, joystick
ok = await self.ha.set_brightness(entity, float(payload.get("v", 0)))
elif cmd == "ct": # step 3: color temperature, kelvin
ok = await self.ha.set_color_temp(entity, float(payload.get("v", 0)))
else:
log(f"HA cmd: unknown cmd {cmd!r}")
return
log(f"HA cmd {cmd} -> {'ok' if ok else 'FAIL'}")
async def write_payload(self, payload: dict) -> bool: async def write_payload(self, payload: dict) -> bool:
# ensure_ascii=False keeps non-Latin titles as compact UTF-8 (~2 bytes/char) # ensure_ascii=False keeps non-Latin titles as compact UTF-8 (~2 bytes/char)
# instead of \uXXXX (6 bytes), which roughly thirds the size of a Cyrillic # instead of \uXXXX (6 bytes), which roughly thirds the size of a Cyrillic
@@ -661,8 +824,9 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
return False return False
log("Connected") log("Connected")
session = Session(client) session = Session(client, tray_state)
await session.setup_refresh_subscription() await session.setup_refresh_subscription()
await session.setup_command_subscription()
# Two cadences share one connection: the Anthropic usage / rate-limit poll runs # Two cadences share one connection: the Anthropic usage / rate-limit poll runs
# every POLL_INTERVAL (60s) while the Windows media session is read every # every POLL_INTERVAL (60s) while the Windows media session is read every
@@ -680,10 +844,14 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
now = time.time() now = time.time()
claude_due = (session.refresh_requested.is_set() claude_due = (session.refresh_requested.is_set()
or (now - last_claude_poll) >= POLL_INTERVAL) or (now - last_claude_poll) >= POLL_INTERVAL)
dim_due = session.dim_requested.is_set() # watch opened the Dimmer screen
if dim_due:
session.dim_requested.clear()
auth_problem = False auth_problem = False
if claude_due: if claude_due:
session.refresh_requested.clear() session.refresh_requested.clear()
session.reload_buttons() # pick up desktop-panel edits to the button set
# Local token usage (Session screen) needs no network — compute it # Local token usage (Session screen) needs no network — compute it
# every cycle so the watch keeps updating even with no/expired token. # every cycle so the watch keeps updating even with no/expired token.
@@ -744,13 +912,29 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
log(f"Now-playing skipped: {e!r}") log(f"Now-playing skipped: {e!r}")
np = {"np": 0} np = {"np": 0}
# Light snapshot for the tilt-dimmer dial — fetched only when the watch
# opens the Dimmer screen (dim_due), to seed the dial from reality. No
# per-heartbeat fetch: during a session the watch is authoritative (it
# streams absolute values), and it re-requests on every re-open. Best-
# effort — a slow/dead HA just omits the field, never stalls the loop.
dim_snap = None
if dim_due:
try:
dim_snap = await session.dim_snapshot()
except Exception as e:
log(f"Dim snapshot skipped: {e!r}")
# Write when the usage data was just refreshed (this is also the ~60s # Write when the usage data was just refreshed (this is also the ~60s
# heartbeat) or when the track / playback state changed since the last # heartbeat), when the track / playback state changed since the last
# write — skip otherwise so the link, and the field log, stay quiet # write, or when the watch asked for a dimmer snapshot — skip otherwise
# between changes instead of repeating an identical payload every 3s. # so the link, and the field log, stay quiet between changes.
if claude_due or np != last_np_sent: if claude_due or dim_due or np != last_np_sent:
payload = dict(cached) payload = dict(cached)
payload.update(np) payload.update(np)
if claude_due:
payload["btns"] = session.button_labels() # ~60s heartbeat only
if dim_snap is not None:
payload["dim"] = dim_snap
if await session.write_payload(payload): if await session.write_payload(payload):
used_successfully = True used_successfully = True
consecutive_failures = 0 # D-03: reset on success consecutive_failures = 0 # D-03: reset on success
@@ -790,6 +974,11 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
await client.disconnect() await client.disconnect()
except (BleakError, OSError): except (BleakError, OSError):
pass pass
if session.ha:
try:
await session.ha.aclose()
except Exception:
pass
log("Device disconnected" if not stop_event.is_set() else "Stopping") log("Device disconnected" if not stop_event.is_set() else "Stopping")
return used_successfully return used_successfully
+116
View File
@@ -0,0 +1,116 @@
"""Unified Clawdmeter configuration (Phase 7).
Single source of truth at ``%LOCALAPPDATA%\\Clawdmeter\\config.json``, shared by
the tray daemon and the FastAPI control panel. Replaces the Phase-6
``ha_config.json`` (auto-migrated on first load).
Shape::
{
"version": 1,
"ha": {"url": str, "token": str, "entities": [str, ...]},
"buttons": [{"id": str, "label": str, "icon": str,
"action": "toggle"|"bri"|"ct", "entity": str, "value": int|None}],
"settings": {"device_address": str, "autostart": bool}
}
The token is a secret: it lives only in this file (outside the repo, gitignored)
and is NEVER logged (only its length) — same discipline as ha_client.py. The
control panel masks it in API responses.
"""
import json
import os
from pathlib import Path
CONFIG_VERSION = 1
DEFAULT_PORT = 8723 # FastAPI control panel — bound to 127.0.0.1 only
VALID_ACTIONS = ("toggle", "bri", "ct")
def _dir() -> Path:
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
return base / "Clawdmeter"
def config_path() -> Path:
if override := os.environ.get("CLAWDMETER_CONFIG"):
return Path(override)
return _dir() / "config.json"
def _legacy_ha_path() -> Path:
return _dir() / "ha_config.json"
def default_config() -> dict:
return {
"version": CONFIG_VERSION,
"ha": {"url": "", "token": "", "entities": []},
"buttons": [],
"settings": {"device_address": "", "autostart": False, "low_battery_pct": 15},
}
def _migrate_legacy(cfg: dict) -> dict:
"""Import the Phase-6 ha_config.json into the unified config when the unified
HA section is still empty. Non-destructive: the old file is left in place."""
legacy = _legacy_ha_path()
if cfg["ha"]["url"] or not legacy.exists():
return cfg
try:
# utf-8-sig: PowerShell's Out-File writes a BOM that json.loads chokes on.
old = json.loads(legacy.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError):
return cfg
cfg["ha"]["url"] = (old.get("url") or "").strip().rstrip("/")
cfg["ha"]["token"] = (old.get("token") or "").strip()
cfg["ha"]["entities"] = list(old.get("entities") or [])
return cfg
def load_config() -> dict:
"""Load the unified config, filling defaults for any missing section so new
keys added in later versions always resolve. Never raises."""
cfg = default_config()
try:
loaded = json.loads(config_path().read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError):
loaded = None
if isinstance(loaded, dict):
for section in ("ha", "settings"):
if isinstance(loaded.get(section), dict):
cfg[section].update(loaded[section])
if isinstance(loaded.get("buttons"), list):
cfg["buttons"] = loaded["buttons"]
if not cfg["ha"]["url"] and not cfg["ha"]["token"]:
cfg = _migrate_legacy(cfg)
# Normalize the HA section the same way ha_client expects it.
cfg["ha"]["url"] = (cfg["ha"].get("url") or "").strip().rstrip("/")
cfg["ha"]["token"] = (cfg["ha"].get("token") or "").strip()
cfg["ha"]["entities"] = list(cfg["ha"].get("entities") or [])
return cfg
def save_config(cfg: dict) -> None:
"""Persist atomically (temp file + os.replace) so a crash mid-write can't
leave a truncated config."""
path = config_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
def ha_settings(cfg: dict | None = None) -> dict | None:
"""Return {url, token, entities} when HA is configured (url + real token),
else None — the shape the daemon needs to build an HAClient. Logging of the
token is the caller's responsibility (ha_client logs only the length)."""
if cfg is None:
cfg = load_config()
ha = cfg.get("ha", {})
url = (ha.get("url") or "").strip().rstrip("/")
token = (ha.get("token") or "").strip()
if not url or not token or token.startswith("PASTE_"):
return None
return {"url": url, "token": token, "entities": list(ha.get("entities") or [])}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""WebView2 settings window for Clawdmeter (Phase 7, M1).
Opens a native Windows WebView2 window (via pywebview) onto the local control
panel the tray process already serves on 127.0.0.1. Launched as a SEPARATE
PROCESS from the tray — ``Clawdmeter.exe --panel`` when frozen, ``python -m
daemon.panel`` in source — because pywebview and pystray each need to own the
main thread and cannot coexist in one process (see tray_windows._on_settings).
The port comes from CLAWDMETER_PANEL_PORT (set by the tray when it spawns us)
and falls back to the config default, so the window always points at the server
the tray actually started.
"""
from __future__ import annotations
import os
import sys
import time
import urllib.request
# Make the `daemon` package importable when run as a bare script or frozen exe,
# mirroring tray_windows.py's bootstrap (logon autostart starts us with cwd =
# System32, and the frozen exe loads the package from the bundle root).
if getattr(sys, "frozen", False):
_REPO_ROOT = sys._MEIPASS # type: ignore[attr-defined]
else:
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
_VENV_SITE = os.path.join(_REPO_ROOT, ".venv", "Lib", "site-packages")
if os.path.isdir(_VENV_SITE):
import site
site.addsitedir(_VENV_SITE)
try:
from daemon import config as cfgmod
except ImportError: # running as a plain script (cwd on path)
import config as cfgmod
WINDOW_TITLE = "Clawdmeter"
WINDOW_W = 860
WINDOW_H = 720
WINDOW_MIN = (640, 560)
BRAND_BG = "#131211" # paint the chrome brand-dark so there's no white flash
def _port() -> int:
raw = os.environ.get("CLAWDMETER_PANEL_PORT")
if raw:
try:
return int(raw)
except ValueError:
pass
return cfgmod.DEFAULT_PORT
def _wait_for_server(url: str, timeout: float = 6.0) -> bool:
"""Poll the server's status endpoint until it answers or the timeout elapses.
The tray starts the HTTP server in a thread a moment before it can spawn us,
so a freshly-clicked Settings might briefly beat the socket. Polling avoids a
blank window in that race; a miss just means we open anyway and the UI's own
fetch retries.
"""
deadline = time.time() + timeout
probe = url.rstrip("/") + "/api/status"
while time.time() < deadline:
try:
with urllib.request.urlopen(probe, timeout=1.0) as r:
if r.status == 200:
return True
except Exception:
time.sleep(0.25)
return False
def run() -> None:
"""Open the control-panel window and block until the user closes it."""
import webview # imported here so the tray never pays for it unless --panel
url = f"http://127.0.0.1:{_port()}/"
_wait_for_server(url)
webview.create_window(
WINDOW_TITLE, url,
width=WINDOW_W, height=WINDOW_H,
min_size=WINDOW_MIN,
background_color=BRAND_BG,
)
# gui defaults to auto-detect; on Windows 11 that resolves to EdgeChromium
# (WebView2), which ships with the OS — the modern engine the brand CSS needs.
# start() blocks on the native GUI loop until the window closes.
webview.start()
if __name__ == "__main__":
run()
+7
View File
@@ -9,3 +9,10 @@ Pillow
# media-control projection. pip resolves them to the same winrt-runtime as bleak. # media-control projection. pip resolves them to the same winrt-runtime as bleak.
winrt-Windows.Media winrt-Windows.Media
winrt-Windows.Media.Control winrt-Windows.Media.Control
# Phase 7 control panel: a local FastAPI server (bound to 127.0.0.1) serves the
# brand UI, and pywebview hosts it in a native Edge WebView2 window. pywebview
# pulls pythonnet (clr) on Windows for the EdgeChromium backend. httpx (above)
# is reused for the HA test/entities calls.
fastapi
uvicorn
pywebview
+166
View File
@@ -0,0 +1,166 @@
"""Local control-panel HTTP API for the Clawdmeter app (Phase 7, M1).
A FastAPI app bound to 127.0.0.1 only. Serves the brand-styled web UI (``web/``)
and a small REST API over the unified config (``config.py``). Runs in a daemon
thread alongside the tray and the BLE daemon — one process, one exe.
Security: bound to loopback; the HA token is masked in GET responses and never
logged (mirrors ha_client / config).
"""
from __future__ import annotations
import copy
import os
import sys
import threading
from pathlib import Path
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
try:
from daemon import config as cfgmod
except ImportError: # running as a plain script (cwd on path)
import config as cfgmod
MASK = "********" # what the UI sees instead of the real token; echo it back to keep it
def _web_dir() -> Path:
"""The static UI directory: next to this file in source, under the PyInstaller
bundle root when frozen (added via clawdmeter.spec datas)."""
if getattr(sys, "frozen", False):
return Path(sys._MEIPASS) / "daemon" / "web" # type: ignore[attr-defined]
return Path(__file__).parent / "web"
def _masked(cfg: dict) -> dict:
c = copy.deepcopy(cfg)
if c.get("ha", {}).get("token"):
c["ha"]["token"] = MASK
return c
app = FastAPI(title="Clawdmeter")
@app.get("/api/config")
def get_config() -> dict:
return _masked(cfgmod.load_config())
class ConfigIn(BaseModel):
ha: dict | None = None
buttons: list | None = None
settings: dict | None = None
@app.put("/api/config")
def put_config(incoming: ConfigIn) -> dict:
cfg = cfgmod.load_config()
data = incoming.model_dump(exclude_none=True)
if "ha" in data:
ha = dict(data["ha"])
# Mask echoed back unchanged => keep the stored token (UI never holds it).
if ha.get("token") == MASK:
ha["token"] = cfg["ha"]["token"]
cfg["ha"].update(ha)
if "settings" in data:
cfg["settings"].update(data["settings"])
if "buttons" in data:
cfg["buttons"] = data["buttons"]
cfgmod.save_config(cfg)
return _masked(cfg)
def _resolve_token(token: str) -> str:
return cfgmod.load_config()["ha"]["token"] if token == MASK else token
@app.post("/api/ha/test")
async def ha_test(body: dict) -> dict:
url = (body.get("url") or "").strip().rstrip("/")
token = _resolve_token((body.get("token") or "").strip())
if not url or not token:
raise HTTPException(status_code=400, detail="url and token are required")
try:
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(f"{url}/api/",
headers={"Authorization": f"Bearer {token}"})
except httpx.HTTPError as e:
return {"ok": False, "error": str(e)}
if resp.status_code == 200:
try:
msg = resp.json().get("message", "API running")
except ValueError:
msg = "API running"
return {"ok": True, "message": msg}
return {"ok": False, "error": f"HTTP {resp.status_code}"}
@app.get("/api/ha/entities")
async def ha_entities() -> dict:
ha = cfgmod.ha_settings()
if not ha:
return {"entities": [], "error": "Home Assistant not configured"}
try:
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(f"{ha['url']}/api/states",
headers={"Authorization": f"Bearer {ha['token']}"})
except httpx.HTTPError as e:
return {"entities": [], "error": str(e)}
if resp.status_code != 200:
return {"entities": [], "error": f"HTTP {resp.status_code}"}
try:
states = resp.json()
except ValueError:
return {"entities": [], "error": "bad response"}
lights = [s["entity_id"] for s in states
if isinstance(s, dict) and str(s.get("entity_id", "")).startswith("light.")]
return {"entities": sorted(lights)}
_status_provider = None # set by the tray to expose live BLE/daemon state
def set_status_provider(fn) -> None:
"""The tray injects a callable returning the live status dict (connected,
battery, state). Kept out of import-time so server.py runs standalone."""
global _status_provider
_status_provider = fn
@app.get("/api/status")
def get_status() -> dict:
if _status_provider is not None:
try:
return _status_provider()
except Exception:
pass
return {"connected": False, "battery": None, "state": "unknown"}
# Static UI mounted LAST so the /api/* routes above take precedence.
_wd = _web_dir()
if _wd.exists():
app.mount("/", StaticFiles(directory=str(_wd), html=True), name="web")
def serve_in_thread(port: int | None = None) -> threading.Thread:
"""Start uvicorn on 127.0.0.1 in a daemon thread; return the thread."""
import uvicorn
p = port or cfgmod.DEFAULT_PORT
server = uvicorn.Server(uvicorn.Config(
app, host="127.0.0.1", port=p, log_level="warning"))
t = threading.Thread(target=server.run, daemon=True, name="clawd-http")
t.start()
return t
if __name__ == "__main__":
# Dev: run the server in the foreground with autoreload-free uvicorn.
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=cfgmod.DEFAULT_PORT, log_level="info")
+95
View File
@@ -18,6 +18,8 @@ Run: python -m pytest daemon/tests/test_windows_tray.py -x -q
""" """
import os import os
import queue
import subprocess
import sys import sys
import threading import threading
import time import time
@@ -67,6 +69,8 @@ class TrayState:
self.state: str = "scanning" # "connected" | "scanning" | "error" self.state: str = "scanning" # "connected" | "scanning" | "error"
self.reason: str = "" # error reason string (D-04) self.reason: str = "" # error reason string (D-04)
self.last_sync: float | None = None # time.time() of last successful write self.last_sync: float | None = None # time.time() of last successful write
self.battery_pct: int | None = None # latest watch battery %, from the …0005 channel
self.toasts: "queue.Queue" = queue.Queue() # (title, message) toasts for the tray to show
# Populated by daemon main() at startup: # Populated by daemon main() at startup:
self.loop = None # asyncio running loop (for call_soon_threadsafe) self.loop = None # asyncio running loop (for call_soon_threadsafe)
@@ -162,6 +166,34 @@ def _acquire_single_instance():
return handle return handle
# ---------------------------------------------------------------------------
# control-panel glue (Phase 7): live status feed + settings-window subprocess
# ---------------------------------------------------------------------------
def _status_dict(ts: TrayState) -> dict:
"""Live status for the control panel's GET /api/status. Shape matches what
web/index.html reads (connected / state / battery / last_sync); a pure read
of TrayState scalars, safe to call from the server's request thread."""
return {
"connected": ts.state == "connected",
"state": ts.state,
"reason": ts.reason,
"battery": ts.battery_pct,
"last_sync": ts.last_sync,
}
def _panel_argv() -> list:
"""Command that launches the settings window as a SEPARATE process. Frozen:
re-invoke this same exe with --panel. Source: run panel.py by ABSOLUTE path —
not ``-m daemon.panel``, which would break under autostart (cwd = System32).
panel.py rebuilds its own sys.path from __file__, so cwd doesn't matter. Kept
a separate process because pywebview wants the main thread, which pystray owns."""
if getattr(sys, "frozen", False):
return [sys.executable, "--panel"]
return [sys.executable, os.path.join(_REPO_ROOT, "daemon", "panel.py")]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# main() — tray entry (pystray on main thread, daemon loop in bg thread) # main() — tray entry (pystray on main thread, daemon loop in bg thread)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -173,6 +205,14 @@ def main() -> None:
so the module can be imported on a GTK-less Linux dev box for unit tests so the module can be imported on a GTK-less Linux dev box for unit tests
of the pure helpers (TrayState, header_text) without pystray failing. of the pure helpers (TrayState, header_text) without pystray failing.
""" """
# --panel: we ARE the settings window, launched as a child of the tray. Open
# it and exit WITHOUT touching the single-instance mutex or the BLE daemon —
# pywebview owns this process's main thread; the tray owns the other one.
if "--panel" in sys.argv:
from daemon.panel import run as run_panel
run_panel()
return
# Single-instance guard FIRST — before icons, the daemon thread, or any BLE # Single-instance guard FIRST — before icons, the daemon thread, or any BLE
# work. If another tray already owns the session mutex (e.g. ARSO restored a # work. If another tray already owns the session mutex (e.g. ARSO restored a
# console instance and the headless autostart also fired), exit silently. # console instance and the headless autostart also fired), exit silently.
@@ -227,6 +267,25 @@ def main() -> None:
daemon_thread = threading.Thread(target=_run_daemon, daemon=True) daemon_thread = threading.Thread(target=_run_daemon, daemon=True)
daemon_thread.start() daemon_thread.start()
# --- control-panel HTTP server (one process, one exe) ---
# Serve the brand UI + REST API on 127.0.0.1 in a daemon thread, and feed it
# live BLE/daemon status. Best-effort: a server failure must never stop the
# tray itself from coming up (the watch sync is the primary job).
panel_port = None
try:
from daemon import server as panel_server
from daemon.config import DEFAULT_PORT
panel_port = DEFAULT_PORT
panel_server.set_status_provider(lambda: _status_dict(ts))
panel_server.serve_in_thread(panel_port)
daemon_log(f"Control panel: http://127.0.0.1:{panel_port}")
except Exception as e:
daemon_log(f"Control panel unavailable: {e!r}")
# Holds the settings-window child process so we don't stack windows and can
# tear it down on Quit. Mutated by _on_settings / _on_quit below.
_panel = {"proc": None}
# --- menu --- # --- menu ---
def _on_quit(icon_ref, _item) -> None: def _on_quit(icon_ref, _item) -> None:
# NEVER call ts.stop_event.set() directly from the tray thread; # NEVER call ts.stop_event.set() directly from the tray thread;
@@ -246,6 +305,13 @@ def main() -> None:
except RuntimeError: except RuntimeError:
pass # loop already closed (e.g. mid-restart) — quit_evt handles it pass # loop already closed (e.g. mid-restart) — quit_evt handles it
daemon_thread.join(timeout=6.0) daemon_thread.join(timeout=6.0)
# Close the settings window too, if the user left it open.
proc = _panel["proc"]
if proc is not None and proc.poll() is None:
try:
proc.terminate()
except Exception:
pass
icon_ref.stop() icon_ref.stop()
def _on_toggle(_icon_ref, _item) -> None: def _on_toggle(_icon_ref, _item) -> None:
@@ -258,9 +324,30 @@ def main() -> None:
autostart.enable(tray_script=os.path.abspath(__file__)) autostart.enable(tray_script=os.path.abspath(__file__))
icon.update_menu() icon.update_menu()
def _on_settings(_icon_ref, _item) -> None:
# Open the WebView2 settings window as a child process. If one is already
# alive, leave it — re-spawning would stack duplicate windows.
proc = _panel["proc"]
if proc is not None and proc.poll() is None:
return
env = dict(os.environ)
if panel_port:
env["CLAWDMETER_PANEL_PORT"] = str(panel_port)
kwargs = {}
if sys.platform == "win32":
# No phantom console window for the child (it's a GUI of its own).
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
try:
_panel["proc"] = subprocess.Popen(_panel_argv(), env=env, **kwargs)
except Exception as e:
daemon_log(f"Could not open settings window: {e!r}")
icon.menu = Menu( icon.menu = Menu(
# Non-clickable status header; text updates via update_menu() on state change. # Non-clickable status header; text updates via update_menu() on state change.
MenuItem(lambda _item: header_text(ts), None, enabled=False), MenuItem(lambda _item: header_text(ts), None, enabled=False),
# Settings = the WebView2 control panel. default=True opens it on a plain
# left-click of the tray icon (right-click still shows the full menu).
MenuItem("Settings", _on_settings, default=True),
# Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6). # Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6).
MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()), MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()),
MenuItem("Quit", _on_quit), MenuItem("Quit", _on_quit),
@@ -290,6 +377,14 @@ def main() -> None:
prev_state["state"] = current prev_state["state"] = current
prev_state["last_sync"] = last_sync prev_state["last_sync"] = last_sync
_icon.update_menu() _icon.update_menu()
# Drain daemon-queued toasts (e.g. low watch battery) — runs every
# tick regardless of state change.
try:
while True:
_title, _msg = ts.toasts.get_nowait()
_icon.notify(_msg, _title)
except queue.Empty:
pass
time.sleep(1.0) time.sleep(1.0)
# Blocks the main thread until icon.stop() is called from _on_quit. # Blocks the main thread until icon.stop() is called from _on_quit.
+272
View File
@@ -0,0 +1,272 @@
<!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>