255 lines
9.5 KiB
Python
255 lines
9.5 KiB
Python
"""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:<id>``. 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)
|