diff --git a/.gitignore b/.gitignore index 7493a6e..37a6ac0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ __pycache__/ # Local Home Assistant config (holds a secret token - never commit) 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 diff --git a/build-exe.ps1 b/build-exe.ps1 index 7cf58f2..172107a 100644 --- a/build-exe.ps1 +++ b/build-exe.ps1 @@ -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 # 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)) { Log "Creating virtual environment at .venv ..." & 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) @@ -45,4 +45,4 @@ $ExePath = Join-Path $RepoRoot "dist\Clawdmeter.exe" if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" } $sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1) 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." diff --git a/clawdmeter.spec b/clawdmeter.spec index 6f315a7..8da0e40 100644 --- a/clawdmeter.spec +++ b/clawdmeter.spec @@ -21,18 +21,37 @@ 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 = [] hiddenimports = [ # Imported lazily inside tray_windows.main(), so name them explicitly. 'daemon.claude_usage_daemon_windows', 'daemon.autostart_windows', '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. 'winrt.windows.media', '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) datas += _d binaries += _b diff --git a/daemon/claude_usage_daemon_windows.py b/daemon/claude_usage_daemon_windows.py index 5a4ac49..3c189fa 100644 --- a/daemon/claude_usage_daemon_windows.py +++ b/daemon/claude_usage_daemon_windows.py @@ -32,6 +32,14 @@ DEVICE_ADDRESS = os.environ.get("CLAWDMETER_ADDRESS") SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001" RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002" 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) NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The @@ -296,9 +304,58 @@ async def scan_for_device(): class Session: - def __init__(self, client: BleakClient) -> None: + def __init__(self, client: BleakClient, tray_state=None) -> None: self.client = client + self.tray_state = tray_state 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: log("Refresh requested by device") @@ -316,6 +373,112 @@ class Session: except (BleakError, ValueError, OSError) as 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: # 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 @@ -661,8 +824,9 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> return False log("Connected") - session = Session(client) + session = Session(client, tray_state) await session.setup_refresh_subscription() + await session.setup_command_subscription() # Two cadences share one connection: the Anthropic usage / rate-limit poll runs # 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() claude_due = (session.refresh_requested.is_set() 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 if claude_due: 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 # 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}") 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 - # heartbeat) or when the track / playback state changed since the last - # write — skip otherwise so the link, and the field log, stay quiet - # between changes instead of repeating an identical payload every 3s. - if claude_due or np != last_np_sent: + # heartbeat), when the track / playback state changed since the last + # write, or when the watch asked for a dimmer snapshot — skip otherwise + # so the link, and the field log, stay quiet between changes. + if claude_due or dim_due or np != last_np_sent: payload = dict(cached) 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): used_successfully = True 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() except (BleakError, OSError): pass + if session.ha: + try: + await session.ha.aclose() + except Exception: + pass log("Device disconnected" if not stop_event.is_set() else "Stopping") return used_successfully diff --git a/daemon/config.py b/daemon/config.py new file mode 100644 index 0000000..a4e8ed5 --- /dev/null +++ b/daemon/config.py @@ -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 [])} diff --git a/daemon/panel.py b/daemon/panel.py new file mode 100644 index 0000000..117b708 --- /dev/null +++ b/daemon/panel.py @@ -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() diff --git a/daemon/requirements-windows.txt b/daemon/requirements-windows.txt index 9d65b56..978cba9 100644 --- a/daemon/requirements-windows.txt +++ b/daemon/requirements-windows.txt @@ -9,3 +9,10 @@ Pillow # media-control projection. pip resolves them to the same winrt-runtime as bleak. winrt-Windows.Media 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 diff --git a/daemon/server.py b/daemon/server.py new file mode 100644 index 0000000..667a2fe --- /dev/null +++ b/daemon/server.py @@ -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") diff --git a/daemon/tray_windows.py b/daemon/tray_windows.py index d2900d7..861de7d 100644 --- a/daemon/tray_windows.py +++ b/daemon/tray_windows.py @@ -18,6 +18,8 @@ Run: python -m pytest daemon/tests/test_windows_tray.py -x -q """ import os +import queue +import subprocess import sys import threading import time @@ -67,6 +69,8 @@ class TrayState: self.state: str = "scanning" # "connected" | "scanning" | "error" self.reason: str = "" # error reason string (D-04) 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: self.loop = None # asyncio running loop (for call_soon_threadsafe) @@ -162,6 +166,34 @@ def _acquire_single_instance(): 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) # --------------------------------------------------------------------------- @@ -173,6 +205,14 @@ def main() -> None: 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. """ + # --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 # work. If another tray already owns the session mutex (e.g. ARSO restored a # 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.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 --- def _on_quit(icon_ref, _item) -> None: # NEVER call ts.stop_event.set() directly from the tray thread; @@ -246,6 +305,13 @@ def main() -> None: except RuntimeError: pass # loop already closed (e.g. mid-restart) — quit_evt handles it 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() def _on_toggle(_icon_ref, _item) -> None: @@ -258,9 +324,30 @@ def main() -> None: autostart.enable(tray_script=os.path.abspath(__file__)) 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( # Non-clickable status header; text updates via update_menu() on state change. 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). MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()), MenuItem("Quit", _on_quit), @@ -290,6 +377,14 @@ def main() -> None: prev_state["state"] = current prev_state["last_sync"] = last_sync _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) # Blocks the main thread until icon.stop() is called from _on_quit. diff --git a/daemon/web/index.html b/daemon/web/index.html new file mode 100644 index 0000000..bfd1f60 --- /dev/null +++ b/daemon/web/index.html @@ -0,0 +1,272 @@ + + + + + +Clawdmeter + + + +
+ + +
+ +
+

Status

+
Watch connection and live readings
+
+
Watch
+
Battery
+
Daemon
+
Last update
+
+
+ + +
+

Home Assistant

+
Connection & devices
+ + + +
+ + +
+
+ +
+
+ + + +
+
+
+ + +
+

Buttons

+
Actions you can fire (and, later, show on the watch)
+
+ +
+
+ + +
+

Settings

+
App behaviour
+ + + + +
+
+
+
+
+ + + +