This commit is contained in:
Javis committed 2026-10-04 15:31:44 +02:00
1 parent 0246cbb6ba
commit 0ca4b4bf7a
89 files changed
+7042 -2480

No files matched your search

+95 -21
View File
@@ -2,10 +2,12 @@
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
@@ -18,6 +20,24 @@ 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."""
@@ -35,10 +55,14 @@ class HomeyClient:
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()
@@ -76,13 +100,13 @@ class HomeyClient:
# ---- REST: devices / zones / flows -----------------------------------
async def get_system_info(self) -> dict[str, Any]:
return await self._get("/api/manager/system/info")
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/{device_id}")
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")
@@ -91,7 +115,7 @@ class HomeyClient:
self, device_id: str, capability_id: str, value: Any
) -> Any:
return await self._put(
f"/api/manager/devices/device/{device_id}/capability/{capability_id}",
f"/api/manager/devices/device/{_seg(device_id)}/capability/{_seg(capability_id)}",
json={"value": value},
)
@@ -99,7 +123,7 @@ class HomeyClient:
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/{flow_id}/trigger")
return await self._post(f"/api/manager/flow/flow/{_seg(flow_id)}/trigger")
# ---- REST: logic variables -------------------------------------------
@@ -110,7 +134,7 @@ class HomeyClient:
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/{variable_id}",
f"/api/manager/logic/variable/{_seg(variable_id)}",
json={"value": value},
)
@@ -138,7 +162,14 @@ class HomeyClient:
return items[-limit:]
async def start_realtime(self) -> None:
"""Best-effort Socket.IO connection. Failures are logged, not raised."""
"""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:
@@ -150,31 +181,74 @@ class HomeyClient:
sio = socketio.AsyncClient(reconnection=True, logger=False, engineio_logger=False)
self._sio = sio
def _record(kind: str) -> Any:
async def _handler(data: Any = None) -> None:
self._events.append(
{"ts": time.time(), "kind": kind, "data": data}
)
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())
return _handler
async def _on_disconnect(*_args: Any) -> None:
self._namespace = None
for kind in (
"device.create",
"device.update",
"device.delete",
"capability",
):
sio.on(kind, _record(kind))
sio.on("connect", _on_connect)
sio.on("disconnect", _on_disconnect)
try:
await sio.connect(
self.settings.base_url,
auth={"token": self.settings.token},
transports=["websocket"],
wait_timeout=self.settings.timeout,
)
self._sio_connected = True
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)