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