Panel: new Providers tab (data-driven from GET /api/providers — labels/accents from the daemon registry, enable/active/creds from config). z.ai base_url + key, api_key masked and preserved on save like the HA token. Daemon: request_provider_switch() lets the panel push an active-provider change into the live BLE session (same _set_active_provider path as the watch) so it takes effect now, not on the next 60s poll; _active_session exposes the session to the panel thread. On-watch cycle groundwork: provnext -> _cycle_provider (daemon owns the enabled set/order), and pnm/pi/pc pushed in the payload so the watch's Provider screen can show the name + "1/3". server.py: ConfigIn gains providers/active_provider/display_order; secrets masked in _masked; active_provider change notifies the injected _command_sink. tray wires set_command_sink(request_provider_switch). Tests: test_server_providers (masking + sink), test_provider_switch (cycle + offline persist). 106 passed, 2 Linux-only failures (baseline). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
8.9 KiB
Python
240 lines
8.9 KiB
Python
"""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
|
|
# Provider secrets (e.g. z.ai api_key) get the same treatment as the HA token:
|
|
# never leave this process. The UI echoes the mask back to keep the stored one.
|
|
for pconf in (c.get("providers") or {}).values():
|
|
if isinstance(pconf, dict) and pconf.get("api_key"):
|
|
pconf["api_key"] = 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
|
|
providers: dict | None = None # v3: per-provider {enabled, base_url, api_key}
|
|
active_provider: str | None = None # which provider the watch displays
|
|
display_order: list | None = None # on-watch cycle order
|
|
|
|
|
|
@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"]
|
|
# v3 providers — merge per known id; a masked api_key echoed back keeps the
|
|
# stored secret (same discipline as the HA token).
|
|
if isinstance(data.get("providers"), dict):
|
|
for pid, pconf in data["providers"].items():
|
|
if pid not in cfg["providers"] or not isinstance(pconf, dict):
|
|
continue
|
|
pconf = dict(pconf)
|
|
if pconf.get("api_key") == MASK:
|
|
pconf["api_key"] = cfg["providers"][pid].get("api_key", "")
|
|
cfg["providers"][pid].update(pconf)
|
|
if isinstance(data.get("display_order"), list):
|
|
cfg["display_order"] = data["display_order"]
|
|
switched = None
|
|
if isinstance(data.get("active_provider"), str):
|
|
if data["active_provider"] != cfg.get("active_provider"):
|
|
switched = data["active_provider"]
|
|
cfg["active_provider"] = data["active_provider"]
|
|
cfgmod.save_config(cfg)
|
|
# Push the switch to a connected watch now (same path as the on-watch button)
|
|
# instead of waiting for the ~60s poll to notice the config change. Best-effort.
|
|
if switched is not None and _command_sink is not None:
|
|
try:
|
|
_command_sink(switched)
|
|
except Exception:
|
|
pass
|
|
return _masked(cfg)
|
|
|
|
|
|
@app.get("/api/providers")
|
|
def get_providers() -> dict:
|
|
"""Provider list for the panel's Providers tab: brand label + accent come from
|
|
the daemon registry, enabled/creds state from config. Data-driven so adding a
|
|
provider (a daemon class + a config default) needs zero web-UI edits. Secrets
|
|
are never sent — only whether a key is stored (has_key)."""
|
|
cfg = cfgmod.load_config()
|
|
try:
|
|
from daemon.providers import get_provider
|
|
except ImportError:
|
|
from providers import get_provider
|
|
out = []
|
|
for pid in cfgmod.PROVIDER_IDS:
|
|
p = get_provider(pid)
|
|
pconf = cfg["providers"].get(pid, {})
|
|
out.append({
|
|
"id": pid,
|
|
"label": p.label,
|
|
"accent": p.accent,
|
|
"enabled": bool(pconf.get("enabled")),
|
|
"needs_key": "api_key" in pconf, # z.ai-style base_url + key creds
|
|
"base_url": pconf.get("base_url", ""),
|
|
"has_key": bool(pconf.get("api_key")),
|
|
})
|
|
return {"providers": out, "active": cfg["active_provider"], "order": cfg["display_order"]}
|
|
|
|
|
|
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
|
|
_command_sink = None # set by the tray: called with a provider id to live-switch the watch
|
|
|
|
|
|
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
|
|
|
|
|
|
def set_command_sink(fn) -> None:
|
|
"""The tray injects a callable(provider_id) that pushes an active-provider
|
|
switch to the live BLE session. Optional — a panel edit still persists to
|
|
config (and the daemon picks it up on its next poll) if this is unset."""
|
|
global _command_sink
|
|
_command_sink = 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
|
|
# log_config=None disables uvicorn's default dictConfig. In the frozen,
|
|
# windowed exe there is no console, so sys.stderr is None and uvicorn's
|
|
# default logging setup dies with "Unable to configure formatter 'default'",
|
|
# taking the whole control-panel server down. We don't need uvicorn's logs
|
|
# (the daemon has its own file logger), so skip its logging config entirely.
|
|
server = uvicorn.Server(uvicorn.Config(
|
|
app, host="127.0.0.1", port=p, log_level="warning", log_config=None))
|
|
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")
|