"""Async client for the Homey Pro local Web API + Socket.IO realtime events.""" from __future__ import annotations import asyncio import logging import time from collections import deque from typing import Any from urllib.parse import quote import httpx try: import socketio # type: ignore except ImportError: # pragma: no cover socketio = None # type: ignore from .config import Settings log = logging.getLogger(__name__) # Cap per-device realtime subscriptions on very large installations. _MAX_DEVICE_SUBSCRIPTIONS = 500 def _seg(value: Any) -> str: """One URL path segment: IDs come from the LLM and must not add path parts.""" return quote(str(value), safe="") def event_entry(uri: str, event: str, data: Any) -> dict[str, Any]: """Compact buffer entry for one realtime event (device objects are large).""" entry: dict[str, Any] = {"ts": time.time(), "kind": event, "data": data} if uri.startswith("homey:device:"): entry["device_id"] = uri.split(":", 2)[2] if event.startswith("device.") and isinstance(data, dict): entry["data"] = {k: data.get(k) for k in ("id", "name", "zone", "available")} return entry class HomeyClient: """Thin async wrapper around Homey's local HTTP + Socket.IO APIs.""" def __init__(self, settings: Settings) -> None: self.settings = settings self._http = httpx.AsyncClient( base_url=settings.base_url, headers={ "Authorization": f"Bearer {settings.token}", "Accept": "application/json", }, timeout=settings.timeout, ) self._events: deque[dict[str, Any]] = deque(maxlen=settings.event_buffer) self._sio: Any | None = None self._sio_connected: bool = False self._namespace: str | None = None self._session_task: asyncio.Task | None = None # ---- lifecycle -------------------------------------------------------- async def aclose(self) -> None: if self._session_task is not None: self._session_task.cancel() try: if self._sio is not None and self._sio_connected: await self._sio.disconnect() except Exception: # pragma: no cover log.exception("Error disconnecting Socket.IO") await self._http.aclose() # ---- helpers ---------------------------------------------------------- async def _get(self, path: str) -> Any: r = await self._http.get(path) r.raise_for_status() return r.json() async def _post(self, path: str, json: Any | None = None) -> Any: r = await self._http.post(path, json=json) r.raise_for_status() if r.content: try: return r.json() except ValueError: return None return None async def _put(self, path: str, json: Any | None = None) -> Any: r = await self._http.put(path, json=json) r.raise_for_status() if r.content: try: return r.json() except ValueError: return None return None # ---- REST: devices / zones / flows ----------------------------------- async def get_system_info(self) -> dict[str, Any]: return await self._get("/api/manager/system/") async def list_devices(self) -> dict[str, Any]: return await self._get("/api/manager/devices/device") async def get_device(self, device_id: str) -> dict[str, Any]: return await self._get(f"/api/manager/devices/device/{_seg(device_id)}") async def list_zones(self) -> dict[str, Any]: return await self._get("/api/manager/zones/zone") async def set_capability( self, device_id: str, capability_id: str, value: Any ) -> Any: return await self._put( f"/api/manager/devices/device/{_seg(device_id)}/capability/{_seg(capability_id)}", json={"value": value}, ) async def list_flows(self) -> dict[str, Any]: return await self._get("/api/manager/flow/flow") async def trigger_flow(self, flow_id: str) -> Any: return await self._post(f"/api/manager/flow/flow/{_seg(flow_id)}/trigger") # ---- REST: logic variables ------------------------------------------- async def list_variables(self) -> dict[str, Any]: """Return all Homey Logic Variables (type, current value, etc.).""" return await self._get("/api/manager/logic/variable") async def set_variable(self, variable_id: str, value: Any) -> Any: """Set a Homey Logic Variable to *value*.""" return await self._put( f"/api/manager/logic/variable/{_seg(variable_id)}", json={"value": value}, ) # ---- REST: notifications --------------------------------------------- async def send_notification(self, title: str, excerpt: str = "") -> Any: """Push a notification to the Homey app on all registered phones.""" return await self._post( "/api/manager/notifications/notification", json={ "ownerUri": "homey:manager:notifications", "title": title, "excerpt": excerpt, }, ) # ---- realtime events -------------------------------------------------- def recent_events( self, limit: int = 50, kind: str | None = None ) -> list[dict[str, Any]]: items = list(self._events) if kind: items = [e for e in items if e.get("kind") == kind] return items[-limit:] async def start_realtime(self) -> None: """Best-effort Socket.IO connection. Failures are logged, not raised. Homey's protocol (as implemented by Athom's ``homey-api``): connect the root socket, ``handshakeClient`` with the token to obtain a namespace (``/api``), connect that namespace and ``subscribe`` to URIs such as ``homey:manager:devices`` / ``homey:device:``. Events then arrive with the URI as event name and ``(event, data)`` as arguments. """ if not self.settings.realtime: return if socketio is None: log.warning("python-socketio not installed; realtime disabled") return if self._sio_connected: return sio = socketio.AsyncClient(reconnection=True, logger=False, engineio_logger=False) self._sio = sio async def _on_connect() -> None: # Fires on the first connect and after every reconnect: the Homey # session (handshake + subscriptions) must be rebuilt each time. self._sio_connected = True if self._session_task is None or self._session_task.done(): self._session_task = asyncio.create_task(self._open_session()) async def _on_disconnect(*_args: Any) -> None: self._namespace = None sio.on("connect", _on_connect) sio.on("disconnect", _on_disconnect) try: await sio.connect( self.settings.base_url, transports=["websocket"], wait_timeout=self.settings.timeout, ) log.info("Socket.IO connected to %s", self.settings.base_url) except Exception as exc: # pragma: no cover log.warning("Socket.IO connection failed: %s", exc) self._sio_connected = False async def _open_session(self) -> None: sio = self._sio if sio is None: return try: info = await self.get_system_info() homey_id = info.get("cloudId") or info.get("id") ack = await sio.call( "handshakeClient", {"token": self.settings.token, "homeyId": homey_id}, timeout=self.settings.timeout, ) err, result = ack if isinstance(ack, (list, tuple)) and len(ack) == 2 else (None, ack) if err or not isinstance(result, dict) or not result.get("namespace"): raise RuntimeError(f"handshake rejected: {err or result!r}") namespace = str(result["namespace"]) async def _on_event(uri: str, *args: Any) -> None: event = str(args[0]) if args else uri data = args[1] if len(args) > 1 else None self._events.append(event_entry(uri, event, data)) sio.on("*", _on_event, namespace=namespace) # python-socketio has no public API to join a namespace after # connect(); send the CONNECT packet the way connect() does. from socketio import packet as sio_packet await sio._send_packet(sio.packet_class(sio_packet.CONNECT, namespace=namespace)) for _ in range(int(self.settings.timeout * 10)): if namespace in sio.namespaces: break await asyncio.sleep(0.1) else: raise RuntimeError(f"namespace {namespace} did not connect") self._namespace = namespace uris = ["homey:manager:devices"] devices = await self.list_devices() ids = list(devices)[:_MAX_DEVICE_SUBSCRIPTIONS] if isinstance(devices, dict) else [] uris += [f"homey:device:{device_id}" for device_id in ids] for uri in uris: await sio.call("subscribe", uri, namespace=namespace, timeout=self.settings.timeout) log.info("Homey realtime: subscribed to %d URIs on %s", len(uris), namespace) except asyncio.CancelledError: raise except Exception as exc: log.warning("Homey realtime session failed: %s", exc)