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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user