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,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")
|
||||
Reference in New Issue
Block a user