From a6d391e9d107e4807e07e853a2fd67364611f1d6 Mon Sep 17 00:00:00 2001 From: wenil Date: Sun, 21 Jun 2026 16:33:38 +0300 Subject: [PATCH] v2 Phase 6 (step 1): Home Assistant REST client + live self-test daemon/ha_client.py: HAClient (brightness, color_temp, on/off, state snapshot) over the HA REST API with smooth `transition`; token is read from a local %LOCALAPPDATA%\Clawdmeter\ha_config.json and is never logged. Verified end-to-end against a real Gledopto GL-C-006P tunable-white dimmer. ha_config.example.json is the committed template; the real config (with the token) lives outside the repo and is .gitignored as a safety net. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + daemon/ha_client.py | 220 ++++++++++++++++++++++++++++++++++ daemon/ha_config.example.json | 6 + 3 files changed, 229 insertions(+) create mode 100644 daemon/ha_client.py create mode 100644 daemon/ha_config.example.json diff --git a/.gitignore b/.gitignore index 4e287f5..7493a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ __pycache__/ /build/ /dist/ *_dbg.spec + +# Local Home Assistant config (holds a secret token - never commit) +daemon/ha_config.json diff --git a/daemon/ha_client.py b/daemon/ha_client.py new file mode 100644 index 0000000..7864d45 --- /dev/null +++ b/daemon/ha_client.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Home Assistant REST client for the Clawdmeter daemon (Phase 6). + +The watch stays "dumb": it sends a short command over BLE (which entity, which +parameter, what value) and this module turns that into a Home Assistant service +call over the local network. Everything HA-specific — the base URL, the +long-lived access token, the controllable entities — lives on the PC, never on +the device. + +Config lives OUTSIDE the repo at %LOCALAPPDATA%\\Clawdmeter\\ha_config.json so +the token is never committed. Copy daemon/ha_config.example.json there and fill +in `url` + `token`. The token is a secret: it is sent only as the HTTP +Authorization header and is NEVER written to any log (mirrors how the OAuth +secret is handled in the main daemon). + +Live self-test against your real light (after the config is filled in), run +from the repo root with the venv python: + .venv\\Scripts\\python.exe -m daemon.ha_client +""" + +import asyncio +import json +import os +from pathlib import Path + +import httpx + +# Reuse the daemon's logger so HA lines land in the same daemon.log. Importing +# the daemon module is safe (the tray already imports it; it has no import-time +# side effects beyond setting up the file logger). Fall back to print() when run +# in isolation before packaging. +try: + from daemon.claude_usage_daemon_windows import log +except Exception: # pragma: no cover - standalone/fallback + def log(msg: str) -> None: + print(msg, flush=True) + + +# GL-C-006P advertises feature 32 (transition), so every change can EASE instead +# of snapping. Keep it short: the joystick sends ~5x/sec, so a long transition +# would lag behind the wrist. 0.3s reads as "smooth" without feeling laggy. +DEFAULT_TRANSITION = 0.3 +DEFAULT_MIN_KELVIN = 2000 +DEFAULT_MAX_KELVIN = 6500 + + +def _config_path() -> Path: + if override := os.environ.get("CLAWDMETER_HA_CONFIG"): + return Path(override) + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + return base / "Clawdmeter" / "ha_config.json" + + +def load_ha_config() -> dict | None: + """Load {url, token, entities} from the local HA config file. + + Returns None (with a helpful log) when the file is missing or still holds the + placeholder token, so the daemon runs fine with HA simply disabled. + """ + path = _config_path() + try: + # utf-8-sig: PowerShell's `Out-File -Encoding utf8` writes a BOM, which + # would otherwise make json.loads choke on the first character. + raw = path.read_text(encoding="utf-8-sig") + except OSError: + log(f"HA: no config at {path} - HA control disabled " + f"(copy daemon/ha_config.example.json there)") + return None + try: + cfg = json.loads(raw) + except json.JSONDecodeError as e: + log(f"HA: config at {path} is not valid JSON ({e}) — HA control disabled") + return None + url = (cfg.get("url") or "").strip().rstrip("/") + token = (cfg.get("token") or "").strip() + entities = cfg.get("entities") or [] + if not url or not token or token.startswith("PASTE_"): + log("HA: config present but `url`/`token` not filled in - HA control disabled") + return None + # Confirm load WITHOUT ever logging the token itself. + log(f"HA: config loaded - url={url}, entities={entities}, token len={len(token)}") + return {"url": url, "token": token, "entities": list(entities)} + + +class HAClient: + """Thin async wrapper over the Home Assistant REST API. + + One long-lived httpx client; calls are local and quick. Methods return + True/False (or parsed state) and never raise on an HTTP/Zigbee failure — a + dead HA or a flaky mesh must not crash the daemon loop. + """ + + def __init__(self, url: str, token: str, *, timeout: float = 10.0) -> None: + self._url = url.rstrip("/") + self._http = httpx.AsyncClient( + timeout=timeout, + headers={"Authorization": f"Bearer {token}", + "Content-Type": "application/json"}, + ) + + async def aclose(self) -> None: + await self._http.aclose() + + async def _service(self, domain: str, service: str, data: dict) -> bool: + url = f"{self._url}/api/services/{domain}/{service}" + try: + resp = await self._http.post(url, json=data) + except httpx.HTTPError as e: + log(f"HA: {domain}.{service} failed: {e}") + return False + if resp.status_code >= 400: + # 401 (token) and 400 (bad entity) both land here. Log status + short + # body only — never the request headers, which carry the token. + log(f"HA: {domain}.{service} HTTP {resp.status_code}: {resp.text[:160]}") + return False + return True + + async def get_state(self, entity_id: str) -> dict | None: + url = f"{self._url}/api/states/{entity_id}" + try: + resp = await self._http.get(url) + except httpx.HTTPError as e: + log(f"HA: get_state({entity_id}) failed: {e}") + return None + if resp.status_code >= 400: + log(f"HA: get_state({entity_id}) HTTP {resp.status_code}") + return None + try: + return resp.json() + except ValueError: + return None + + # --- light helpers -------------------------------------------------------- + + async def turn_off(self, entity_id: str, transition: float = DEFAULT_TRANSITION) -> bool: + return await self._service("light", "turn_off", + {"entity_id": entity_id, "transition": transition}) + + async def toggle(self, entity_id: str) -> bool: + return await self._service("light", "toggle", {"entity_id": entity_id}) + + async def set_brightness(self, entity_id: str, pct: float, + transition: float = DEFAULT_TRANSITION) -> bool: + """Set brightness 0-100 %. 0 (or below) turns the light off.""" + pct = max(0, min(100, round(pct))) + if pct <= 0: + return await self.turn_off(entity_id, transition) + return await self._service("light", "turn_on", { + "entity_id": entity_id, "brightness_pct": pct, "transition": transition}) + + async def set_color_temp(self, entity_id: str, kelvin: float, + min_k: int = DEFAULT_MIN_KELVIN, + max_k: int = DEFAULT_MAX_KELVIN, + transition: float = DEFAULT_TRANSITION) -> bool: + kelvin = int(max(min_k, min(max_k, round(kelvin)))) + return await self._service("light", "turn_on", { + "entity_id": entity_id, "color_temp_kelvin": kelvin, "transition": transition}) + + async def light_snapshot(self, entity_id: str) -> dict | None: + """Read the light's current values so the watch dial starts from the real + state instead of 0. Returns normalized fields, or None on failure.""" + st = await self.get_state(entity_id) + if not st: + return None + attrs = st.get("attributes", {}) + bri_255 = attrs.get("brightness") # 0-255, or None when off + bri_pct = round(bri_255 / 255 * 100) if isinstance(bri_255, (int, float)) else None + return { + "on": st.get("state") == "on", + "brightness_pct": bri_pct, + "color_temp_kelvin": attrs.get("color_temp_kelvin"), + "min_kelvin": attrs.get("min_color_temp_kelvin", DEFAULT_MIN_KELVIN), + "max_kelvin": attrs.get("max_color_temp_kelvin", DEFAULT_MAX_KELVIN), + } + + +async def _self_test() -> int: + """Live smoke test: drive the first configured light so you can SEE it react, + then restore its original state.""" + cfg = load_ha_config() + if not cfg: + return 1 + if not cfg["entities"]: + log("HA self-test: no entities in config") + return 1 + eid = cfg["entities"][0] + ha = HAClient(cfg["url"], cfg["token"]) + try: + snap = await ha.light_snapshot(eid) + if snap is None: + log(f"HA self-test: could not read {eid} - check url / token / entity_id") + return 1 + log(f"HA self-test: {eid} now -> {snap}") + min_k, max_k = snap["min_kelvin"], snap["max_kelvin"] + sequence = [ + ("brightness 30%", lambda: ha.set_brightness(eid, 30)), + ("brightness 80%", lambda: ha.set_brightness(eid, 80)), + (f"warm {min_k}K", lambda: ha.set_color_temp(eid, min_k, min_k, max_k)), + (f"cool {max_k}K", lambda: ha.set_color_temp(eid, max_k, min_k, max_k)), + ] + for label, action in sequence: + ok = await action() + log(f"HA self-test: {label} -> {'ok' if ok else 'FAIL'}") + await asyncio.sleep(1.3) + # Restore the original state. + if snap["on"]: + if snap["brightness_pct"]: + await ha.set_brightness(eid, snap["brightness_pct"]) + if snap["color_temp_kelvin"]: + await ha.set_color_temp(eid, snap["color_temp_kelvin"], min_k, max_k) + else: + await ha.turn_off(eid) + log("HA self-test: done (original state restored)") + return 0 + finally: + await ha.aclose() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(_self_test())) diff --git a/daemon/ha_config.example.json b/daemon/ha_config.example.json new file mode 100644 index 0000000..dcb1e29 --- /dev/null +++ b/daemon/ha_config.example.json @@ -0,0 +1,6 @@ +{ + "_comment": "Home Assistant config for Clawdmeter Phase 6. COPY this file to %LOCALAPPDATA%\\Clawdmeter\\ha_config.json and fill in url + token. The real file lives OUTSIDE the repo so the token is never committed. token = a Home Assistant Long-Lived Access Token (HA -> your profile -> Security -> Create Token).", + "url": "http://homeassistant.local:8123", + "token": "PASTE_LONG_LIVED_ACCESS_TOKEN_HERE", + "entities": ["light.dimmer_v_spalne"] +}