python-homely 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
homely/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Reusable Homely client package extracted from the integration."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .client import (
6
+ BASE_URL,
7
+ REQUEST_TIMEOUT,
8
+ HomelyClient,
9
+ auth_header_value,
10
+ )
11
+ from .exceptions import (
12
+ HomelyAuthError,
13
+ HomelyConnectionError,
14
+ HomelyError,
15
+ HomelyResponseError,
16
+ HomelyWebSocketError,
17
+ )
18
+ from .models import TokenResponse
19
+ from .websocket import HomelyWebSocket
20
+
21
+ __all__ = [
22
+ "__version__",
23
+ "BASE_URL",
24
+ "REQUEST_TIMEOUT",
25
+ "HomelyClient",
26
+ "HomelyWebSocket",
27
+ "HomelyError",
28
+ "HomelyConnectionError",
29
+ "HomelyAuthError",
30
+ "HomelyResponseError",
31
+ "HomelyWebSocketError",
32
+ "TokenResponse",
33
+ "auth_header_value",
34
+ ]
homely/client.py ADDED
@@ -0,0 +1,209 @@
1
+ """Async Homely API client."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Any
6
+
7
+ import aiohttp
8
+
9
+ from .exceptions import (
10
+ HomelyAuthError,
11
+ HomelyConnectionError,
12
+ HomelyResponseError,
13
+ )
14
+ from .models import TokenResponse
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+ BASE_URL = "https://sdk.iotiliti.cloud/homely/"
19
+ REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=20)
20
+
21
+
22
+ def auth_header_value(token: str | None) -> str:
23
+ """Return normalized Authorization header value."""
24
+ normalized = (token or "").strip()
25
+ if normalized.lower().startswith("bearer "):
26
+ return normalized
27
+ return f"Bearer {normalized}"
28
+
29
+
30
+ class HomelyClient:
31
+ """Small reusable async client for the Homely cloud API."""
32
+
33
+ def __init__(
34
+ self,
35
+ session: aiohttp.ClientSession,
36
+ *,
37
+ base_url: str = BASE_URL,
38
+ timeout: aiohttp.ClientTimeout = REQUEST_TIMEOUT,
39
+ ) -> None:
40
+ """Initialize the client with a caller-managed aiohttp session."""
41
+ self._session = session
42
+ self._base_url = base_url
43
+ self._timeout = timeout
44
+
45
+ @property
46
+ def base_url(self) -> str:
47
+ """Return the configured API base URL."""
48
+ return self._base_url
49
+
50
+ @property
51
+ def timeout(self) -> aiohttp.ClientTimeout:
52
+ """Return the configured request timeout."""
53
+ return self._timeout
54
+
55
+ async def authenticate(
56
+ self,
57
+ username: str,
58
+ password: str,
59
+ ) -> TokenResponse:
60
+ """Authenticate and return a typed token response.
61
+
62
+ Raises a typed SDK exception on failure.
63
+ """
64
+ response, reason = await self.fetch_token_with_reason(username, password)
65
+ if response:
66
+ return TokenResponse.from_dict(response)
67
+ if reason == "invalid_auth":
68
+ raise HomelyAuthError("Invalid Homely username or password")
69
+ raise HomelyConnectionError("Could not connect to Homely")
70
+
71
+ async def fetch_token_with_reason(
72
+ self,
73
+ username: str,
74
+ password: str,
75
+ ) -> tuple[dict[str, Any] | None, str | None]:
76
+ """Fetch access token and return optional reason key on failure."""
77
+ url = f"{self._base_url}oauth/token"
78
+ payload = {
79
+ "username": username,
80
+ "password": password,
81
+ }
82
+
83
+ try:
84
+ async with self._session.post(url, json=payload, timeout=self._timeout) as response:
85
+ if response.status in (200, 201):
86
+ _LOGGER.debug("Token fetch successful")
87
+ return await response.json(), None
88
+
89
+ if response.status in (400, 401, 403):
90
+ _LOGGER.debug("Token fetch rejected with status=%s", response.status)
91
+ return None, "invalid_auth"
92
+
93
+ _LOGGER.warning("Token fetch failed with status=%s", response.status)
94
+ return None, "cannot_connect"
95
+ except (aiohttp.ClientError, TimeoutError) as err:
96
+ _LOGGER.warning("Token fetch network error: %s", err)
97
+ return None, "cannot_connect"
98
+
99
+ async def fetch_token(
100
+ self,
101
+ username: str,
102
+ password: str,
103
+ ) -> dict[str, Any] | None:
104
+ """Fetch access token from API."""
105
+ response, _reason = await self.fetch_token_with_reason(username, password)
106
+ return response
107
+
108
+ async def fetch_refresh_token(self, refresh_token: str) -> dict[str, Any] | None:
109
+ """Refresh access token using refresh token."""
110
+ url = f"{self._base_url}oauth/refresh-token"
111
+ payload = {
112
+ "refresh_token": refresh_token,
113
+ }
114
+
115
+ try:
116
+ async with self._session.post(url, json=payload, timeout=self._timeout) as response:
117
+ if response.status in (200, 201):
118
+ _LOGGER.debug("Token refresh successful")
119
+ return await response.json()
120
+ _LOGGER.debug("Token refresh failed with status=%s", response.status)
121
+ return None
122
+ except (aiohttp.ClientError, TimeoutError) as err:
123
+ _LOGGER.debug("Token refresh network error: %s", err)
124
+ return None
125
+
126
+ async def refresh_access_token(self, refresh_token: str) -> TokenResponse:
127
+ """Refresh access token and return a typed token response."""
128
+ response = await self.fetch_refresh_token(refresh_token)
129
+ if response:
130
+ return TokenResponse.from_dict(response)
131
+ raise HomelyConnectionError("Could not refresh Homely access token")
132
+
133
+ async def get_locations(self, token: str) -> list[dict[str, Any]] | None:
134
+ """Get locations from API."""
135
+ url = f"{self._base_url}locations"
136
+ headers = {"Authorization": auth_header_value(token)}
137
+
138
+ try:
139
+ async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
140
+ if response.status == 200:
141
+ _LOGGER.debug("Locations fetch successful")
142
+ return await response.json()
143
+ _LOGGER.debug("Locations fetch failed with status=%s", response.status)
144
+ return None
145
+ except (aiohttp.ClientError, TimeoutError) as err:
146
+ _LOGGER.debug("Locations fetch network error: %s", err)
147
+ return None
148
+
149
+ async def get_locations_or_raise(self, token: str) -> list[dict[str, Any]]:
150
+ """Get locations from API or raise a typed exception."""
151
+ locations = await self.get_locations(token)
152
+ if locations is not None:
153
+ return locations
154
+ raise HomelyConnectionError("Could not fetch Homely locations")
155
+
156
+ async def get_home_data(
157
+ self,
158
+ token: str,
159
+ location_id: str | int,
160
+ ) -> dict[str, Any] | None:
161
+ """Get location data from API."""
162
+ data, _status = await self.get_home_data_with_status(token, location_id)
163
+ return data
164
+
165
+ async def get_home_data_with_status(
166
+ self,
167
+ token: str,
168
+ location_id: str | int,
169
+ ) -> tuple[dict[str, Any] | None, int | None]:
170
+ """Get location data from API and include HTTP status when available."""
171
+ url = f"{self._base_url}home/{location_id}"
172
+ headers = {"Authorization": auth_header_value(token)}
173
+
174
+ try:
175
+ async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
176
+ if response.status == 200:
177
+ return await response.json(), response.status
178
+ body = await response.text()
179
+ body_preview = body.replace("\n", " ")[:200]
180
+ _LOGGER.debug(
181
+ "Location data fetch failed with status=%s location_id=%s body_preview=%r",
182
+ response.status,
183
+ location_id,
184
+ body_preview,
185
+ )
186
+ return None, response.status
187
+ except (aiohttp.ClientError, TimeoutError) as err:
188
+ _LOGGER.debug(
189
+ "Location data fetch network error location_id=%s: %s",
190
+ location_id,
191
+ err,
192
+ )
193
+ return None, None
194
+
195
+ async def get_home_data_or_raise(
196
+ self,
197
+ token: str,
198
+ location_id: str | int,
199
+ ) -> dict[str, Any]:
200
+ """Get location data from API or raise a typed exception."""
201
+ data, status = await self.get_home_data_with_status(token, location_id)
202
+ if data is not None:
203
+ return data
204
+ if status in (401, 403):
205
+ raise HomelyAuthError("Homely rejected the supplied access token")
206
+ raise HomelyResponseError(
207
+ "Could not fetch Homely location data",
208
+ status=status,
209
+ )
homely/exceptions.py ADDED
@@ -0,0 +1,34 @@
1
+ """Exceptions exposed by the Homely SDK."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class HomelyError(Exception):
6
+ """Base exception for Homely SDK failures."""
7
+
8
+
9
+ class HomelyConnectionError(HomelyError):
10
+ """Raised when the Homely service cannot be reached."""
11
+
12
+
13
+ class HomelyAuthError(HomelyError):
14
+ """Raised when Homely rejects authentication or authorization."""
15
+
16
+
17
+ class HomelyResponseError(HomelyError):
18
+ """Raised when Homely returns an unexpected response."""
19
+
20
+ def __init__(
21
+ self,
22
+ message: str,
23
+ *,
24
+ status: int | None = None,
25
+ body: str | None = None,
26
+ ) -> None:
27
+ """Initialize the exception with optional response details."""
28
+ super().__init__(message)
29
+ self.status = status
30
+ self.body = body
31
+
32
+
33
+ class HomelyWebSocketError(HomelyError):
34
+ """Raised when the Homely websocket cannot be established."""
homely/models.py ADDED
@@ -0,0 +1,31 @@
1
+ """Public data models for the Homely SDK."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class TokenResponse:
10
+ """Typed token response returned by the Homely authentication endpoints."""
11
+
12
+ access_token: str
13
+ refresh_token: str | None = None
14
+ expires_in: int | None = None
15
+ raw: dict[str, Any] | None = None
16
+
17
+ @classmethod
18
+ def from_dict(cls, data: dict[str, Any]) -> TokenResponse:
19
+ """Build a typed token response from a raw API payload."""
20
+ expires_in = data.get("expires_in")
21
+ try:
22
+ parsed_expires_in = int(expires_in) if expires_in is not None else None
23
+ except (TypeError, ValueError):
24
+ parsed_expires_in = None
25
+
26
+ return cls(
27
+ access_token=str(data["access_token"]),
28
+ refresh_token=data.get("refresh_token"),
29
+ expires_in=parsed_expires_in,
30
+ raw=dict(data),
31
+ )
homely/websocket.py ADDED
@@ -0,0 +1,407 @@
1
+ """Reusable WebSocket client for Homely real-time updates."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+ from urllib.parse import urlencode
9
+
10
+ import aiohttp
11
+
12
+ from .exceptions import HomelyWebSocketError
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ class HomelyWebSocket:
18
+ """WebSocket client for Homely using Socket.IO."""
19
+
20
+ WEBSOCKET_URL = "https://sdk.iotiliti.cloud"
21
+
22
+ def __init__(
23
+ self,
24
+ location_id: str | int,
25
+ token: str,
26
+ on_data_update: Callable[[dict[str, Any]], None],
27
+ status_update_callback: Callable[[str, str | None], None] | None = None,
28
+ context_id: str | None = None,
29
+ entry_id: str | None = None,
30
+ ) -> None:
31
+ """Initialize WebSocket client."""
32
+ self.context_id = context_id or entry_id
33
+ self.entry_id = self.context_id
34
+ self.location_id = location_id
35
+ self.token = token
36
+ self.on_data_update = on_data_update
37
+ self.socket = None
38
+ self._is_closing = False
39
+ self._reconnect_task: asyncio.Task | None = None
40
+ self._reconnect_interval = 300
41
+ self._reconnect_warn_every = 12
42
+ self._status_update_callback = status_update_callback
43
+ self._status = "Not initialized"
44
+ self._status_reason: str | None = None
45
+
46
+ def _ctx(self, device_id: str | None = None) -> str:
47
+ """Build consistent log context."""
48
+ base = f"context_id={self.context_id} location_id={self.location_id}"
49
+ if device_id:
50
+ return f"{base} device_id={device_id}"
51
+ return base
52
+
53
+ @property
54
+ def websocket_url(self) -> str:
55
+ """WebSocket base URL."""
56
+ return self.WEBSOCKET_URL
57
+
58
+ @staticmethod
59
+ def _bearer_value(token: str | None) -> str:
60
+ """Return normalized bearer token value."""
61
+ normalized = (token or "").strip()
62
+ if normalized.lower().startswith("bearer "):
63
+ return normalized
64
+ return f"Bearer {normalized}"
65
+
66
+ @property
67
+ def status(self) -> str:
68
+ """Return current websocket status string."""
69
+ return self._status
70
+
71
+ @property
72
+ def status_reason(self) -> str | None:
73
+ """Return latest status reason if available."""
74
+ return self._status_reason
75
+
76
+ def _set_status(self, status: str, reason: str | None = None) -> None:
77
+ """Update internal status and notify callback."""
78
+ status_changed = status != self._status
79
+ reason_changed = reason != self._status_reason
80
+ self._status = status
81
+ self._status_reason = reason
82
+
83
+ if status_changed:
84
+ if status == "Connected":
85
+ _LOGGER.info("WebSocket connected %s", self._ctx())
86
+ elif status == "Disconnected":
87
+ if reason and self._should_warn_disconnect(reason):
88
+ _LOGGER.warning("WebSocket disconnected %s (%s)", self._ctx(), reason)
89
+ elif reason:
90
+ _LOGGER.debug("WebSocket disconnected %s (%s)", self._ctx(), reason)
91
+ else:
92
+ _LOGGER.warning("WebSocket disconnected %s", self._ctx())
93
+ else:
94
+ if reason:
95
+ _LOGGER.debug(
96
+ "WebSocket status changed %s: %s (%s)",
97
+ self._ctx(),
98
+ status,
99
+ reason,
100
+ )
101
+ else:
102
+ _LOGGER.debug("WebSocket status changed %s: %s", self._ctx(), status)
103
+ elif reason_changed and reason:
104
+ _LOGGER.debug("WebSocket status reason updated %s: %s", self._ctx(), reason)
105
+
106
+ if self._status_update_callback:
107
+ try:
108
+ self._status_update_callback(status, reason)
109
+ except Exception as err:
110
+ _LOGGER.debug("Status callback failed %s: %s", self._ctx(), err)
111
+
112
+ def _build_reason(self, data: Any) -> str | None:
113
+ """Build a readable reason string from event payload."""
114
+ if data is None:
115
+ return None
116
+ try:
117
+ reason = str(data)
118
+ except Exception:
119
+ reason = repr(data)
120
+ return reason or None
121
+
122
+ @staticmethod
123
+ def _should_warn_disconnect(reason: str | None) -> bool:
124
+ """Return whether a disconnect reason should be warning-level."""
125
+ if reason is None:
126
+ return True
127
+ if reason == "manual disconnect":
128
+ return False
129
+ transient_prefixes = (
130
+ "connect timeout",
131
+ "network error:",
132
+ "connect exception:",
133
+ "connect_error",
134
+ )
135
+ return not reason.startswith(transient_prefixes)
136
+
137
+ def _on_event(self, data: Any) -> None:
138
+ """Handle event payload from websocket."""
139
+ if not self.is_connected():
140
+ self._set_status("Connected", "event received")
141
+ elif self._status != "Connected":
142
+ self._set_status("Connected")
143
+
144
+ device_id = data.get("data", {}).get("deviceId") if isinstance(data, dict) else None
145
+ if isinstance(data, dict):
146
+ event_type = data.get("type") or data.get("event") or "unknown"
147
+ _LOGGER.debug(
148
+ "WebSocket event received %s event_type=%s",
149
+ self._ctx(device_id=device_id),
150
+ event_type,
151
+ )
152
+ else:
153
+ _LOGGER.debug(
154
+ "WebSocket event received %s non-dict payload",
155
+ self._ctx(device_id=device_id),
156
+ )
157
+ if isinstance(data, dict):
158
+ try:
159
+ self.on_data_update(data)
160
+ except Exception as err:
161
+ _LOGGER.error(
162
+ "Error in on_data_update callback %s: %s",
163
+ self._ctx(device_id=device_id),
164
+ err,
165
+ exc_info=True,
166
+ )
167
+
168
+ def _on_connect(self) -> None:
169
+ """Handle successful connection."""
170
+ self._stop_reconnect_loop()
171
+ self._set_status("Connected")
172
+
173
+ def _on_disconnect(self, reason: str | None = None) -> None:
174
+ """Handle disconnected connection."""
175
+ if self._is_closing:
176
+ self._set_status("Disconnected", "manual disconnect")
177
+ return
178
+ self._set_status("Disconnected", reason)
179
+ if not self._is_closing:
180
+ self._start_reconnect_loop("disconnect event")
181
+ _LOGGER.debug(
182
+ "Reconnect requested after disconnect %s interval=%ss",
183
+ self._ctx(),
184
+ self._reconnect_interval,
185
+ )
186
+
187
+ def _start_reconnect_loop(self, reason: str | None = None) -> None:
188
+ """Start reconnect loop if not already running."""
189
+ if self._is_closing:
190
+ return
191
+ if self._reconnect_task and not self._reconnect_task.done():
192
+ return
193
+
194
+ try:
195
+ loop = asyncio.get_running_loop()
196
+ except RuntimeError:
197
+ loop = asyncio.get_event_loop()
198
+
199
+ self._reconnect_task = loop.create_task(self._reconnect_loop())
200
+ if reason:
201
+ _LOGGER.info(
202
+ "Started reconnect loop %s (%s). interval=%ss, retries=infinite",
203
+ self._ctx(),
204
+ reason,
205
+ self._reconnect_interval,
206
+ )
207
+ else:
208
+ _LOGGER.info(
209
+ "Started reconnect loop %s. interval=%ss, retries=infinite",
210
+ self._ctx(),
211
+ self._reconnect_interval,
212
+ )
213
+
214
+ def _stop_reconnect_loop(self) -> None:
215
+ """Stop reconnect loop."""
216
+ if self._reconnect_task and not self._reconnect_task.done():
217
+ self._reconnect_task.cancel()
218
+ self._reconnect_task = None
219
+
220
+ async def _reconnect_loop(self) -> None:
221
+ """Try reconnect forever at fixed interval."""
222
+ attempt = 0
223
+ while not self._is_closing:
224
+ if self.is_connected():
225
+ return
226
+
227
+ attempt += 1
228
+ _LOGGER.debug("WebSocket reconnect attempt %s started %s", attempt, self._ctx())
229
+ success = await self.connect(from_reconnect_loop=True)
230
+ if success:
231
+ _LOGGER.info("WebSocket reconnect attempt %s succeeded %s", attempt, self._ctx())
232
+ return
233
+
234
+ if attempt == 1 or attempt % self._reconnect_warn_every == 0:
235
+ _LOGGER.warning(
236
+ "WebSocket reconnect attempt %s failed %s. Retrying in %s seconds",
237
+ attempt,
238
+ self._ctx(),
239
+ self._reconnect_interval,
240
+ )
241
+ else:
242
+ _LOGGER.debug(
243
+ "WebSocket reconnect attempt %s failed %s. Retrying in %s seconds",
244
+ attempt,
245
+ self._ctx(),
246
+ self._reconnect_interval,
247
+ )
248
+ await asyncio.sleep(self._reconnect_interval)
249
+
250
+ async def connect(self, from_reconnect_loop: bool = False) -> bool:
251
+ """Connect to websocket server."""
252
+ if self._is_closing:
253
+ _LOGGER.debug("Skipping websocket connect during shutdown %s", self._ctx())
254
+ return False
255
+
256
+ try:
257
+ import socketio
258
+ except ImportError:
259
+ _LOGGER.error("python-socketio is not installed. WebSocket disabled %s.", self._ctx())
260
+ self._set_status("Disconnected", "socketio missing")
261
+ return False
262
+
263
+ if self.is_connected():
264
+ self._set_status("Connected")
265
+ return True
266
+
267
+ if self.socket is not None:
268
+ try:
269
+ await asyncio.wait_for(self.socket.disconnect(), timeout=2)
270
+ except Exception:
271
+ pass
272
+ self.socket = None
273
+
274
+ self._set_status("Connecting")
275
+ try:
276
+ self.socket = socketio.AsyncClient(
277
+ reconnection=False,
278
+ logger=False,
279
+ engineio_logger=False,
280
+ )
281
+
282
+ @self.socket.event
283
+ async def connect():
284
+ self._on_connect()
285
+
286
+ @self.socket.event
287
+ async def disconnect(*args):
288
+ self._on_disconnect(self._build_reason(args[0] if args else None))
289
+
290
+ @self.socket.event
291
+ async def message(data):
292
+ self._on_event(data)
293
+
294
+ @self.socket.event
295
+ async def event(data):
296
+ self._on_event(data)
297
+
298
+ @self.socket.on("*")
299
+ async def catch_all(event, data):
300
+ if event not in ("connect", "disconnect", "message", "event", "connect_error"):
301
+ _LOGGER.debug("WebSocket event %s type=%s", self._ctx(), event)
302
+ self._on_event({"type": event, "payload": data})
303
+
304
+ @self.socket.event
305
+ async def connect_error(data):
306
+ raw_reason = self._build_reason(data)
307
+ reason = f"connect_error: {raw_reason}" if raw_reason else "connect_error"
308
+ _LOGGER.debug("WebSocket connect_error %s: %s", self._ctx(), reason)
309
+ self._on_disconnect(reason)
310
+
311
+ bearer_token = self._bearer_value(self.token)
312
+ query = urlencode(
313
+ {
314
+ "locationId": str(self.location_id),
315
+ "token": bearer_token,
316
+ }
317
+ )
318
+ url = f"{self.websocket_url}?{query}"
319
+ _LOGGER.debug("WebSocket connecting %s to %s", self._ctx(), self.websocket_url)
320
+ await asyncio.wait_for(
321
+ self.socket.connect(
322
+ url,
323
+ transports=["websocket", "polling"],
324
+ headers={"Authorization": bearer_token},
325
+ ),
326
+ timeout=10,
327
+ )
328
+ return True
329
+ except TimeoutError:
330
+ self.socket = None
331
+ self._set_status("Disconnected", "connect timeout")
332
+ except aiohttp.ClientError as err:
333
+ self.socket = None
334
+ self._set_status("Disconnected", f"network error: {err}")
335
+ except Exception as err:
336
+ self.socket = None
337
+ self._set_status("Disconnected", f"connect exception: {err}")
338
+ _LOGGER.error(
339
+ "WebSocket connect failed %s: %s (%s)",
340
+ self._ctx(),
341
+ err,
342
+ type(err).__name__,
343
+ )
344
+
345
+ if not from_reconnect_loop:
346
+ self._start_reconnect_loop("connect failed")
347
+ return False
348
+
349
+ async def connect_or_raise(self) -> None:
350
+ """Connect to the websocket server or raise a typed exception."""
351
+ if not await self.connect():
352
+ raise HomelyWebSocketError(
353
+ f"Could not connect Homely websocket: {self.status_reason or self.status}"
354
+ )
355
+
356
+ async def disconnect(self) -> None:
357
+ """Disconnect websocket and stop reconnecting."""
358
+ self._is_closing = True
359
+ self._stop_reconnect_loop()
360
+ try:
361
+ if self.socket is not None:
362
+ try:
363
+ await asyncio.wait_for(self.socket.disconnect(), timeout=5)
364
+ except Exception:
365
+ pass
366
+ finally:
367
+ self.socket = None
368
+ finally:
369
+ self._set_status("Disconnected", "manual disconnect")
370
+ self._is_closing = False
371
+
372
+ async def close(self) -> None:
373
+ """Alias for disconnect, matching common client-library conventions."""
374
+ await self.disconnect()
375
+
376
+ async def reconnect_with_token(self, token: str) -> None:
377
+ """Update token and request reconnect if currently disconnected."""
378
+ self.update_token(token, reconnect_if_disconnected=True)
379
+
380
+ def is_connected(self) -> bool:
381
+ """Return True if socket client reports connected."""
382
+ try:
383
+ return self.socket is not None and bool(self.socket.connected)
384
+ except Exception:
385
+ return False
386
+
387
+ def update_token(self, token: str, reconnect_if_disconnected: bool = False) -> None:
388
+ """Update token used by next connect/reconnect attempt."""
389
+ if not token:
390
+ return
391
+ if token != self.token:
392
+ self.token = token
393
+ _LOGGER.debug("WebSocket token updated %s", self._ctx())
394
+ if reconnect_if_disconnected and not self.is_connected() and not self._is_closing:
395
+ self._start_reconnect_loop("token changed while disconnected")
396
+
397
+ def set_token(self, token: str, reconnect_if_disconnected: bool = False) -> None:
398
+ """Alias for update_token, matching common client-library conventions."""
399
+ self.update_token(token, reconnect_if_disconnected=reconnect_if_disconnected)
400
+
401
+ def request_reconnect(self, reason: str = "manual request") -> None:
402
+ """Start reconnect loop if disconnected."""
403
+ if self._is_closing:
404
+ return
405
+ if self.is_connected():
406
+ return
407
+ self._start_reconnect_loop(reason)
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-homely
3
+ Version: 0.1.0
4
+ Summary: Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere.
5
+ Author: Ludvik Blichfeldt Rød
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ludvikroed/python-homely
8
+ Project-URL: Documentation, https://github.com/ludvikroed/python-homely#readme
9
+ Project-URL: Source, https://github.com/ludvikroed/python-homely
10
+ Project-URL: Issues, https://github.com/ludvikroed/python-homely/issues
11
+ Project-URL: Changelog, https://github.com/ludvikroed/python-homely/blob/main/CHANGELOG.md
12
+ Keywords: homely,api,websocket,asyncio,home-automation
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Home Automation
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.12
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: aiohttp<4.0.0,>=3.9.0
25
+ Requires-Dist: python-socketio<6.0.0,>=5.11.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.2.2; extra == "dev"
28
+ Requires-Dist: pytest>=8.3.0; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.8.0; extra == "dev"
31
+ Requires-Dist: twine>=6.0.0; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # Python-homely
35
+
36
+ Async Python client for the Homely cloud API and realtime websocket updates.
37
+
38
+ This package was created for the Homely Home Assistant integration, but it is framework-independent and can be used in any Python project that needs to talk to Homely.
39
+
40
+ ## Features
41
+
42
+ - Login and token refresh
43
+ - Location and home-data fetches
44
+ - Realtime websocket updates
45
+ - Typed exceptions
46
+ - Async API built on `aiohttp`
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ python3 -m pip install python-homely
52
+ ```
53
+
54
+ ## Quick Start
55
+
56
+ ```python
57
+ import aiohttp
58
+ from homely import HomelyClient
59
+
60
+
61
+ async def main() -> None:
62
+ async with aiohttp.ClientSession() as session:
63
+ client = HomelyClient(session)
64
+
65
+ token = await client.authenticate("user@example.com", "password")
66
+ locations = await client.get_locations_or_raise(token.access_token)
67
+ location_id = locations[0]["locationId"]
68
+
69
+ data = await client.get_home_data_or_raise(token.access_token, location_id)
70
+ print(data["name"])
71
+ ```
72
+
73
+ ## Websocket Example
74
+
75
+ ```python
76
+ import aiohttp
77
+ from homely import HomelyClient, HomelyWebSocket
78
+
79
+
80
+ async def on_update(event: dict) -> None:
81
+ print(event)
82
+
83
+
84
+ async def main() -> None:
85
+ async with aiohttp.ClientSession() as session:
86
+ client = HomelyClient(session)
87
+ token = await client.authenticate("user@example.com", "password")
88
+ locations = await client.get_locations_or_raise(token.access_token)
89
+ location_id = locations[0]["locationId"]
90
+
91
+ websocket = HomelyWebSocket(
92
+ location_id=location_id,
93
+ token=token.access_token,
94
+ on_data_update=on_update,
95
+ context_id="example",
96
+ )
97
+
98
+ await websocket.connect_or_raise()
99
+ ```
100
+
101
+ ## Main API
102
+
103
+ - `authenticate(username, password) -> TokenResponse`
104
+ - `refresh_access_token(refresh_token) -> TokenResponse`
105
+ - `get_locations_or_raise(token) -> list[dict]`
106
+ - `get_home_data_or_raise(token, location_id) -> dict`
107
+ - `HomelyWebSocket(...).connect_or_raise()`
108
+
109
+ Main exports:
110
+
111
+ - `HomelyClient`
112
+ - `HomelyWebSocket`
113
+ - `TokenResponse`
114
+ - `HomelyConnectionError`
115
+ - `HomelyAuthError`
116
+ - `HomelyResponseError`
117
+ - `HomelyWebSocketError`
118
+
119
+ ## Exceptions
120
+
121
+ - `HomelyConnectionError`: network or service unavailable
122
+ - `HomelyAuthError`: invalid credentials or rejected token
123
+ - `HomelyResponseError`: unexpected response or HTTP failure
124
+ - `HomelyWebSocketError`: websocket could not be established
125
+
126
+ ## License
127
+
128
+ MIT. See [LICENSE](LICENSE).
129
+
130
+
131
+ ⭐ If you find this integration useful, please consider giving it a star on [GitHub](https://github.com/ludvikroed/python-homely)! ⭐
132
+
@@ -0,0 +1,10 @@
1
+ homely/__init__.py,sha256=9vuclgRmKvpI76PVl1CnTxnezHmSUlPRNuIUjbYY35Y,698
2
+ homely/client.py,sha256=0UQVZFjPMPGMad4kSsDuiiXV_LR4EkZ0J_9TfMEKoj4,7677
3
+ homely/exceptions.py,sha256=g1v7QR9uDydzDCH12FD-3pZN2MGPJu1lDgAnNdT0lCA,893
4
+ homely/models.py,sha256=NgNwYeOo4UiiyP29iFM0rN7k2HIkxZg4s5DivgfdYKM,968
5
+ homely/websocket.py,sha256=DQruxEKuY0K2pEPcz8ltdOZ3NTLZwKKP4eHvDYy73uQ,14978
6
+ python_homely-0.1.0.dist-info/licenses/LICENSE,sha256=nNVHKvQjryAonWsz5TbhzLd6M2kZaoNUvhxz92MgeAA,1079
7
+ python_homely-0.1.0.dist-info/METADATA,sha256=8LKHkP-iwB32Afhpc4uLU9nrSkmJwM78jThVW6E-ZYw,4062
8
+ python_homely-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ python_homely-0.1.0.dist-info/top_level.txt,sha256=auE-j6ghVMdT_jAw04jiXthgbuLpi-jYBU0fCkKvREQ,7
10
+ python_homely-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ludvik Blichfeldt Rød
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ homely