python-homely 0.1.0__tar.gz → 0.1.1__tar.gz
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.
- {python_homely-0.1.0 → python_homely-0.1.1}/PKG-INFO +1 -1
- {python_homely-0.1.0 → python_homely-0.1.1}/pyproject.toml +1 -1
- {python_homely-0.1.0 → python_homely-0.1.1}/src/homely/__init__.py +1 -1
- python_homely-0.1.1/src/homely/client.py +349 -0
- {python_homely-0.1.0 → python_homely-0.1.1}/src/homely/websocket.py +24 -10
- {python_homely-0.1.0 → python_homely-0.1.1}/src/python_homely.egg-info/PKG-INFO +1 -1
- {python_homely-0.1.0 → python_homely-0.1.1}/tests/test_sdk.py +87 -6
- python_homely-0.1.0/src/homely/client.py +0 -209
- {python_homely-0.1.0 → python_homely-0.1.1}/LICENSE +0 -0
- {python_homely-0.1.0 → python_homely-0.1.1}/README.md +0 -0
- {python_homely-0.1.0 → python_homely-0.1.1}/setup.cfg +0 -0
- {python_homely-0.1.0 → python_homely-0.1.1}/src/homely/exceptions.py +0 -0
- {python_homely-0.1.0 → python_homely-0.1.1}/src/homely/models.py +0 -0
- {python_homely-0.1.0 → python_homely-0.1.1}/src/python_homely.egg-info/SOURCES.txt +0 -0
- {python_homely-0.1.0 → python_homely-0.1.1}/src/python_homely.egg-info/dependency_links.txt +0 -0
- {python_homely-0.1.0 → python_homely-0.1.1}/src/python_homely.egg-info/requires.txt +0 -0
- {python_homely-0.1.0 → python_homely-0.1.1}/src/python_homely.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-homely"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -0,0 +1,349 @@
|
|
|
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 _log_identifier(value: str | int | None) -> str | None:
|
|
23
|
+
"""Return a shortened identifier suitable for debug logs."""
|
|
24
|
+
if value is None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
text = str(value)
|
|
28
|
+
if len(text) <= 8:
|
|
29
|
+
return text
|
|
30
|
+
return f"{text[:8]}..."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def auth_header_value(token: str | None) -> str:
|
|
34
|
+
"""Return normalized Authorization header value."""
|
|
35
|
+
normalized = (token or "").strip()
|
|
36
|
+
if normalized.lower().startswith("bearer "):
|
|
37
|
+
return normalized
|
|
38
|
+
return f"Bearer {normalized}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _response_preview(payload: Any) -> str:
|
|
42
|
+
"""Return a short safe preview of a response payload for exceptions."""
|
|
43
|
+
text = repr(payload)
|
|
44
|
+
if len(text) <= 200:
|
|
45
|
+
return text
|
|
46
|
+
return f"{text[:200]}..."
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HomelyClient:
|
|
50
|
+
"""Small reusable async client for the Homely cloud API."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
session: aiohttp.ClientSession,
|
|
55
|
+
*,
|
|
56
|
+
base_url: str = BASE_URL,
|
|
57
|
+
timeout: aiohttp.ClientTimeout = REQUEST_TIMEOUT,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Initialize the client with a caller-managed aiohttp session."""
|
|
60
|
+
self._session = session
|
|
61
|
+
self._base_url = base_url
|
|
62
|
+
self._timeout = timeout
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def base_url(self) -> str:
|
|
66
|
+
"""Return the configured API base URL."""
|
|
67
|
+
return self._base_url
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def timeout(self) -> aiohttp.ClientTimeout:
|
|
71
|
+
"""Return the configured request timeout."""
|
|
72
|
+
return self._timeout
|
|
73
|
+
|
|
74
|
+
async def authenticate(
|
|
75
|
+
self,
|
|
76
|
+
username: str,
|
|
77
|
+
password: str,
|
|
78
|
+
) -> TokenResponse:
|
|
79
|
+
"""Authenticate and return a typed token response.
|
|
80
|
+
|
|
81
|
+
Raises a typed SDK exception on failure.
|
|
82
|
+
"""
|
|
83
|
+
response, status = await self._fetch_token_payload(username, password)
|
|
84
|
+
if response is not None:
|
|
85
|
+
try:
|
|
86
|
+
return TokenResponse.from_dict(response)
|
|
87
|
+
except (KeyError, TypeError, ValueError) as err:
|
|
88
|
+
raise HomelyResponseError(
|
|
89
|
+
"Homely authentication response missing required fields",
|
|
90
|
+
status=status,
|
|
91
|
+
body=_response_preview(response),
|
|
92
|
+
) from err
|
|
93
|
+
if status in (400, 401, 403):
|
|
94
|
+
raise HomelyAuthError("Invalid Homely username or password")
|
|
95
|
+
if status in (200, 201):
|
|
96
|
+
raise HomelyResponseError(
|
|
97
|
+
"Homely authentication response could not be parsed",
|
|
98
|
+
status=status,
|
|
99
|
+
)
|
|
100
|
+
raise HomelyConnectionError("Could not connect to Homely")
|
|
101
|
+
|
|
102
|
+
async def _fetch_token_payload(
|
|
103
|
+
self,
|
|
104
|
+
username: str,
|
|
105
|
+
password: str,
|
|
106
|
+
) -> tuple[dict[str, Any] | None, int | None]:
|
|
107
|
+
"""Fetch access token payload and include HTTP status when available."""
|
|
108
|
+
url = f"{self._base_url}oauth/token"
|
|
109
|
+
payload = {
|
|
110
|
+
"username": username,
|
|
111
|
+
"password": password,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
async with self._session.post(url, json=payload, timeout=self._timeout) as response:
|
|
116
|
+
if response.status in (200, 201):
|
|
117
|
+
try:
|
|
118
|
+
parsed = await response.json()
|
|
119
|
+
except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
|
|
120
|
+
_LOGGER.debug(
|
|
121
|
+
"Token fetch returned invalid JSON status=%s: %s",
|
|
122
|
+
response.status,
|
|
123
|
+
err,
|
|
124
|
+
)
|
|
125
|
+
return None, response.status
|
|
126
|
+
if not isinstance(parsed, dict):
|
|
127
|
+
_LOGGER.debug(
|
|
128
|
+
"Token fetch returned unexpected payload type status=%s payload_type=%s",
|
|
129
|
+
response.status,
|
|
130
|
+
type(parsed).__name__,
|
|
131
|
+
)
|
|
132
|
+
return None, response.status
|
|
133
|
+
_LOGGER.debug("Token fetch successful")
|
|
134
|
+
return parsed, response.status
|
|
135
|
+
|
|
136
|
+
_LOGGER.debug("Token fetch failed with status=%s", response.status)
|
|
137
|
+
return None, response.status
|
|
138
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
139
|
+
_LOGGER.debug("Token fetch network error: %s", err)
|
|
140
|
+
return None, None
|
|
141
|
+
|
|
142
|
+
async def fetch_token_with_reason(
|
|
143
|
+
self,
|
|
144
|
+
username: str,
|
|
145
|
+
password: str,
|
|
146
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
147
|
+
"""Fetch access token and return optional reason key on failure."""
|
|
148
|
+
response, status = await self._fetch_token_payload(username, password)
|
|
149
|
+
if response is not None:
|
|
150
|
+
return response, None
|
|
151
|
+
if status in (400, 401, 403):
|
|
152
|
+
return None, "invalid_auth"
|
|
153
|
+
return None, "cannot_connect"
|
|
154
|
+
|
|
155
|
+
async def fetch_token(
|
|
156
|
+
self,
|
|
157
|
+
username: str,
|
|
158
|
+
password: str,
|
|
159
|
+
) -> dict[str, Any] | None:
|
|
160
|
+
"""Fetch access token from API."""
|
|
161
|
+
response, _reason = await self.fetch_token_with_reason(username, password)
|
|
162
|
+
return response
|
|
163
|
+
|
|
164
|
+
async def _fetch_refresh_token_payload(
|
|
165
|
+
self,
|
|
166
|
+
refresh_token: str,
|
|
167
|
+
) -> tuple[dict[str, Any] | None, int | None]:
|
|
168
|
+
"""Refresh access token payload and include HTTP status when available."""
|
|
169
|
+
url = f"{self._base_url}oauth/refresh-token"
|
|
170
|
+
payload = {
|
|
171
|
+
"refresh_token": refresh_token,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
async with self._session.post(url, json=payload, timeout=self._timeout) as response:
|
|
176
|
+
if response.status in (200, 201):
|
|
177
|
+
try:
|
|
178
|
+
parsed = await response.json()
|
|
179
|
+
except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
|
|
180
|
+
_LOGGER.debug(
|
|
181
|
+
"Token refresh returned invalid JSON status=%s: %s",
|
|
182
|
+
response.status,
|
|
183
|
+
err,
|
|
184
|
+
)
|
|
185
|
+
return None, response.status
|
|
186
|
+
if not isinstance(parsed, dict):
|
|
187
|
+
_LOGGER.debug(
|
|
188
|
+
"Token refresh returned unexpected payload type status=%s payload_type=%s",
|
|
189
|
+
response.status,
|
|
190
|
+
type(parsed).__name__,
|
|
191
|
+
)
|
|
192
|
+
return None, response.status
|
|
193
|
+
_LOGGER.debug("Token refresh successful")
|
|
194
|
+
return parsed, response.status
|
|
195
|
+
_LOGGER.debug("Token refresh failed with status=%s", response.status)
|
|
196
|
+
return None, response.status
|
|
197
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
198
|
+
_LOGGER.debug("Token refresh network error: %s", err)
|
|
199
|
+
return None, None
|
|
200
|
+
|
|
201
|
+
async def fetch_refresh_token(self, refresh_token: str) -> dict[str, Any] | None:
|
|
202
|
+
"""Refresh access token using refresh token."""
|
|
203
|
+
response, _status = await self._fetch_refresh_token_payload(refresh_token)
|
|
204
|
+
return response
|
|
205
|
+
|
|
206
|
+
async def refresh_access_token(self, refresh_token: str) -> TokenResponse:
|
|
207
|
+
"""Refresh access token and return a typed token response."""
|
|
208
|
+
response, status = await self._fetch_refresh_token_payload(refresh_token)
|
|
209
|
+
if response is not None:
|
|
210
|
+
try:
|
|
211
|
+
return TokenResponse.from_dict(response)
|
|
212
|
+
except (KeyError, TypeError, ValueError) as err:
|
|
213
|
+
raise HomelyResponseError(
|
|
214
|
+
"Homely refresh response missing required fields",
|
|
215
|
+
status=status,
|
|
216
|
+
body=_response_preview(response),
|
|
217
|
+
) from err
|
|
218
|
+
if status in (400, 401, 403):
|
|
219
|
+
raise HomelyAuthError("Homely rejected the supplied refresh token")
|
|
220
|
+
if status in (200, 201):
|
|
221
|
+
raise HomelyResponseError(
|
|
222
|
+
"Homely refresh response could not be parsed",
|
|
223
|
+
status=status,
|
|
224
|
+
)
|
|
225
|
+
raise HomelyConnectionError("Could not refresh Homely access token")
|
|
226
|
+
|
|
227
|
+
async def _get_locations_payload(
|
|
228
|
+
self,
|
|
229
|
+
token: str,
|
|
230
|
+
) -> tuple[list[dict[str, Any]] | None, int | None]:
|
|
231
|
+
"""Get locations payload and include HTTP status when available."""
|
|
232
|
+
url = f"{self._base_url}locations"
|
|
233
|
+
headers = {"Authorization": auth_header_value(token)}
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
|
|
237
|
+
if response.status == 200:
|
|
238
|
+
try:
|
|
239
|
+
parsed = await response.json()
|
|
240
|
+
except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
|
|
241
|
+
_LOGGER.debug(
|
|
242
|
+
"Locations fetch returned invalid JSON status=%s: %s",
|
|
243
|
+
response.status,
|
|
244
|
+
err,
|
|
245
|
+
)
|
|
246
|
+
return None, response.status
|
|
247
|
+
if not isinstance(parsed, list):
|
|
248
|
+
_LOGGER.debug(
|
|
249
|
+
"Locations fetch returned unexpected payload type status=%s payload_type=%s",
|
|
250
|
+
response.status,
|
|
251
|
+
type(parsed).__name__,
|
|
252
|
+
)
|
|
253
|
+
return None, response.status
|
|
254
|
+
_LOGGER.debug("Locations fetch successful")
|
|
255
|
+
return parsed, response.status
|
|
256
|
+
_LOGGER.debug("Locations fetch failed with status=%s", response.status)
|
|
257
|
+
return None, response.status
|
|
258
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
259
|
+
_LOGGER.debug("Locations fetch network error: %s", err)
|
|
260
|
+
return None, None
|
|
261
|
+
|
|
262
|
+
async def get_locations(self, token: str) -> list[dict[str, Any]] | None:
|
|
263
|
+
"""Get locations from API."""
|
|
264
|
+
locations, _status = await self._get_locations_payload(token)
|
|
265
|
+
return locations
|
|
266
|
+
|
|
267
|
+
async def get_locations_or_raise(self, token: str) -> list[dict[str, Any]]:
|
|
268
|
+
"""Get locations from API or raise a typed exception."""
|
|
269
|
+
locations, status = await self._get_locations_payload(token)
|
|
270
|
+
if locations is not None:
|
|
271
|
+
return locations
|
|
272
|
+
if status in (401, 403):
|
|
273
|
+
raise HomelyAuthError("Homely rejected the supplied access token")
|
|
274
|
+
if status == 200:
|
|
275
|
+
raise HomelyResponseError(
|
|
276
|
+
"Homely locations response could not be parsed",
|
|
277
|
+
status=status,
|
|
278
|
+
)
|
|
279
|
+
raise HomelyConnectionError("Could not fetch Homely locations")
|
|
280
|
+
|
|
281
|
+
async def get_home_data(
|
|
282
|
+
self,
|
|
283
|
+
token: str,
|
|
284
|
+
location_id: str | int,
|
|
285
|
+
) -> dict[str, Any] | None:
|
|
286
|
+
"""Get location data from API."""
|
|
287
|
+
data, _status = await self.get_home_data_with_status(token, location_id)
|
|
288
|
+
return data
|
|
289
|
+
|
|
290
|
+
async def get_home_data_with_status(
|
|
291
|
+
self,
|
|
292
|
+
token: str,
|
|
293
|
+
location_id: str | int,
|
|
294
|
+
) -> tuple[dict[str, Any] | None, int | None]:
|
|
295
|
+
"""Get location data from API and include HTTP status when available."""
|
|
296
|
+
url = f"{self._base_url}home/{location_id}"
|
|
297
|
+
headers = {"Authorization": auth_header_value(token)}
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
|
|
301
|
+
if response.status == 200:
|
|
302
|
+
try:
|
|
303
|
+
parsed = await response.json()
|
|
304
|
+
except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
|
|
305
|
+
_LOGGER.debug(
|
|
306
|
+
"Location data fetch returned invalid JSON status=%s location_id=%s: %s",
|
|
307
|
+
response.status,
|
|
308
|
+
_log_identifier(location_id),
|
|
309
|
+
err,
|
|
310
|
+
)
|
|
311
|
+
return None, response.status
|
|
312
|
+
if not isinstance(parsed, dict):
|
|
313
|
+
_LOGGER.debug(
|
|
314
|
+
"Location data fetch returned unexpected payload type status=%s location_id=%s payload_type=%s",
|
|
315
|
+
response.status,
|
|
316
|
+
_log_identifier(location_id),
|
|
317
|
+
type(parsed).__name__,
|
|
318
|
+
)
|
|
319
|
+
return None, response.status
|
|
320
|
+
return parsed, response.status
|
|
321
|
+
_LOGGER.debug(
|
|
322
|
+
"Location data fetch failed with status=%s location_id=%s",
|
|
323
|
+
response.status,
|
|
324
|
+
_log_identifier(location_id),
|
|
325
|
+
)
|
|
326
|
+
return None, response.status
|
|
327
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
328
|
+
_LOGGER.debug(
|
|
329
|
+
"Location data fetch network error location_id=%s: %s",
|
|
330
|
+
_log_identifier(location_id),
|
|
331
|
+
err,
|
|
332
|
+
)
|
|
333
|
+
return None, None
|
|
334
|
+
|
|
335
|
+
async def get_home_data_or_raise(
|
|
336
|
+
self,
|
|
337
|
+
token: str,
|
|
338
|
+
location_id: str | int,
|
|
339
|
+
) -> dict[str, Any]:
|
|
340
|
+
"""Get location data from API or raise a typed exception."""
|
|
341
|
+
data, status = await self.get_home_data_with_status(token, location_id)
|
|
342
|
+
if data is not None:
|
|
343
|
+
return data
|
|
344
|
+
if status in (401, 403):
|
|
345
|
+
raise HomelyAuthError("Homely rejected the supplied access token")
|
|
346
|
+
raise HomelyResponseError(
|
|
347
|
+
"Could not fetch Homely location data",
|
|
348
|
+
status=status,
|
|
349
|
+
)
|
|
@@ -14,6 +14,17 @@ from .exceptions import HomelyWebSocketError
|
|
|
14
14
|
_LOGGER = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _log_identifier(value: str | int | None) -> str | None:
|
|
18
|
+
"""Return a shortened identifier suitable for logs."""
|
|
19
|
+
if value is None:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
text = str(value)
|
|
23
|
+
if len(text) <= 8:
|
|
24
|
+
return text
|
|
25
|
+
return f"{text[:8]}..."
|
|
26
|
+
|
|
27
|
+
|
|
17
28
|
class HomelyWebSocket:
|
|
18
29
|
"""WebSocket client for Homely using Socket.IO."""
|
|
19
30
|
|
|
@@ -45,9 +56,12 @@ class HomelyWebSocket:
|
|
|
45
56
|
|
|
46
57
|
def _ctx(self, device_id: str | None = None) -> str:
|
|
47
58
|
"""Build consistent log context."""
|
|
48
|
-
base =
|
|
59
|
+
base = (
|
|
60
|
+
f"context_id={_log_identifier(self.context_id)} "
|
|
61
|
+
f"location_id={_log_identifier(self.location_id)}"
|
|
62
|
+
)
|
|
49
63
|
if device_id:
|
|
50
|
-
return f"{base} device_id={device_id}"
|
|
64
|
+
return f"{base} device_id={_log_identifier(device_id)}"
|
|
51
65
|
return base
|
|
52
66
|
|
|
53
67
|
@property
|
|
@@ -82,14 +96,14 @@ class HomelyWebSocket:
|
|
|
82
96
|
|
|
83
97
|
if status_changed:
|
|
84
98
|
if status == "Connected":
|
|
85
|
-
_LOGGER.
|
|
99
|
+
_LOGGER.debug("WebSocket connected %s", self._ctx())
|
|
86
100
|
elif status == "Disconnected":
|
|
87
101
|
if reason and self._should_warn_disconnect(reason):
|
|
88
|
-
_LOGGER.
|
|
102
|
+
_LOGGER.info("WebSocket disconnected %s (%s)", self._ctx(), reason)
|
|
89
103
|
elif reason:
|
|
90
104
|
_LOGGER.debug("WebSocket disconnected %s (%s)", self._ctx(), reason)
|
|
91
105
|
else:
|
|
92
|
-
_LOGGER.
|
|
106
|
+
_LOGGER.info("WebSocket disconnected %s", self._ctx())
|
|
93
107
|
else:
|
|
94
108
|
if reason:
|
|
95
109
|
_LOGGER.debug(
|
|
@@ -198,14 +212,14 @@ class HomelyWebSocket:
|
|
|
198
212
|
|
|
199
213
|
self._reconnect_task = loop.create_task(self._reconnect_loop())
|
|
200
214
|
if reason:
|
|
201
|
-
_LOGGER.
|
|
215
|
+
_LOGGER.debug(
|
|
202
216
|
"Started reconnect loop %s (%s). interval=%ss, retries=infinite",
|
|
203
217
|
self._ctx(),
|
|
204
218
|
reason,
|
|
205
219
|
self._reconnect_interval,
|
|
206
220
|
)
|
|
207
221
|
else:
|
|
208
|
-
_LOGGER.
|
|
222
|
+
_LOGGER.debug(
|
|
209
223
|
"Started reconnect loop %s. interval=%ss, retries=infinite",
|
|
210
224
|
self._ctx(),
|
|
211
225
|
self._reconnect_interval,
|
|
@@ -228,11 +242,11 @@ class HomelyWebSocket:
|
|
|
228
242
|
_LOGGER.debug("WebSocket reconnect attempt %s started %s", attempt, self._ctx())
|
|
229
243
|
success = await self.connect(from_reconnect_loop=True)
|
|
230
244
|
if success:
|
|
231
|
-
_LOGGER.
|
|
245
|
+
_LOGGER.debug("WebSocket reconnect attempt %s succeeded %s", attempt, self._ctx())
|
|
232
246
|
return
|
|
233
247
|
|
|
234
|
-
if attempt
|
|
235
|
-
_LOGGER.
|
|
248
|
+
if attempt % self._reconnect_warn_every == 0:
|
|
249
|
+
_LOGGER.info(
|
|
236
250
|
"WebSocket reconnect attempt %s failed %s. Retrying in %s seconds",
|
|
237
251
|
attempt,
|
|
238
252
|
self._ctx(),
|
|
@@ -19,10 +19,18 @@ from homely import (
|
|
|
19
19
|
class _FakeResponse:
|
|
20
20
|
"""Simple async HTTP response stub."""
|
|
21
21
|
|
|
22
|
-
def __init__(
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
status: int,
|
|
26
|
+
json_data=None,
|
|
27
|
+
text_data: str = "",
|
|
28
|
+
json_exc: Exception | None = None,
|
|
29
|
+
) -> None:
|
|
23
30
|
self.status = status
|
|
24
31
|
self._json_data = json_data
|
|
25
32
|
self._text_data = text_data
|
|
33
|
+
self._json_exc = json_exc
|
|
26
34
|
|
|
27
35
|
async def __aenter__(self):
|
|
28
36
|
return self
|
|
@@ -31,6 +39,8 @@ class _FakeResponse:
|
|
|
31
39
|
return False
|
|
32
40
|
|
|
33
41
|
async def json(self):
|
|
42
|
+
if self._json_exc is not None:
|
|
43
|
+
raise self._json_exc
|
|
34
44
|
return self._json_data
|
|
35
45
|
|
|
36
46
|
async def text(self):
|
|
@@ -77,7 +87,7 @@ class _FakeAsyncCallable:
|
|
|
77
87
|
async def test_sdk_exports_public_symbols():
|
|
78
88
|
"""The SDK should expose a clean public surface."""
|
|
79
89
|
assert auth_header_value("token") == "Bearer token"
|
|
80
|
-
assert __version__ == "0.1.
|
|
90
|
+
assert __version__ == "0.1.1"
|
|
81
91
|
|
|
82
92
|
|
|
83
93
|
async def test_authenticate_returns_typed_token():
|
|
@@ -133,6 +143,18 @@ async def test_refresh_access_token_raises_connection_error_on_timeout():
|
|
|
133
143
|
raise AssertionError("Expected HomelyConnectionError")
|
|
134
144
|
|
|
135
145
|
|
|
146
|
+
async def test_refresh_access_token_raises_auth_error_on_expired_refresh_token():
|
|
147
|
+
"""Refresh auth failures should surface as HomelyAuthError."""
|
|
148
|
+
client = HomelyClient(_FakeSession(post_response=_FakeResponse(status=400)))
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
await client.refresh_access_token("refresh-token")
|
|
152
|
+
except HomelyAuthError:
|
|
153
|
+
pass
|
|
154
|
+
else:
|
|
155
|
+
raise AssertionError("Expected HomelyAuthError")
|
|
156
|
+
|
|
157
|
+
|
|
136
158
|
async def test_get_locations_or_raise_raises_connection_error():
|
|
137
159
|
"""Location lookup failures should raise HomelyConnectionError."""
|
|
138
160
|
client = HomelyClient(_FakeSession(get_exc=aiohttp.ClientError("boom")))
|
|
@@ -145,6 +167,18 @@ async def test_get_locations_or_raise_raises_connection_error():
|
|
|
145
167
|
raise AssertionError("Expected HomelyConnectionError")
|
|
146
168
|
|
|
147
169
|
|
|
170
|
+
async def test_get_locations_or_raise_raises_auth_error_on_unauthorized():
|
|
171
|
+
"""Unauthorized location lookups should raise HomelyAuthError."""
|
|
172
|
+
client = HomelyClient(_FakeSession(get_response=_FakeResponse(status=401)))
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
await client.get_locations_or_raise("token")
|
|
176
|
+
except HomelyAuthError:
|
|
177
|
+
pass
|
|
178
|
+
else:
|
|
179
|
+
raise AssertionError("Expected HomelyAuthError")
|
|
180
|
+
|
|
181
|
+
|
|
148
182
|
async def test_get_home_data_or_raise_raises_response_error():
|
|
149
183
|
"""Unexpected data fetch failures should carry response metadata."""
|
|
150
184
|
client = HomelyClient(
|
|
@@ -161,20 +195,67 @@ async def test_get_home_data_or_raise_raises_response_error():
|
|
|
161
195
|
raise AssertionError("Expected HomelyResponseError")
|
|
162
196
|
|
|
163
197
|
|
|
198
|
+
async def test_authenticate_raises_response_error_on_malformed_success_payload():
|
|
199
|
+
"""Malformed successful auth responses should raise HomelyResponseError."""
|
|
200
|
+
client = HomelyClient(
|
|
201
|
+
_FakeSession(post_response=_FakeResponse(status=200, json_data={"refresh_token": "refresh"}))
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
await client.authenticate("user", "pass")
|
|
206
|
+
except HomelyResponseError as err:
|
|
207
|
+
assert err.status == 200
|
|
208
|
+
else:
|
|
209
|
+
raise AssertionError("Expected HomelyResponseError")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def test_get_locations_or_raise_raises_response_error_on_malformed_success_payload():
|
|
213
|
+
"""Malformed successful location responses should raise HomelyResponseError."""
|
|
214
|
+
client = HomelyClient(_FakeSession(get_response=_FakeResponse(status=200, json_data={"bad": "payload"})))
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
await client.get_locations_or_raise("token")
|
|
218
|
+
except HomelyResponseError as err:
|
|
219
|
+
assert err.status == 200
|
|
220
|
+
else:
|
|
221
|
+
raise AssertionError("Expected HomelyResponseError")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def test_get_home_data_or_raise_raises_response_error_on_invalid_json():
|
|
225
|
+
"""Malformed successful location payloads should raise HomelyResponseError."""
|
|
226
|
+
client = HomelyClient(
|
|
227
|
+
_FakeSession(
|
|
228
|
+
get_response=_FakeResponse(status=200, json_exc=ValueError("bad json"))
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
await client.get_home_data_or_raise("token", "loc-1")
|
|
234
|
+
except HomelyResponseError as err:
|
|
235
|
+
assert err.status == 200
|
|
236
|
+
else:
|
|
237
|
+
raise AssertionError("Expected HomelyResponseError")
|
|
238
|
+
|
|
239
|
+
|
|
164
240
|
async def test_websocket_public_aliases_cover_package_api():
|
|
165
241
|
"""The websocket client should expose package-friendly aliases."""
|
|
166
242
|
ws = HomelyWebSocket(
|
|
167
|
-
location_id="
|
|
243
|
+
location_id="11111111-2222-3333-4444-555555555555",
|
|
168
244
|
token="token",
|
|
169
245
|
on_data_update=lambda _data: None,
|
|
170
|
-
context_id="
|
|
246
|
+
context_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
|
171
247
|
)
|
|
172
248
|
|
|
173
|
-
assert ws.context_id == "
|
|
174
|
-
assert ws.entry_id == "
|
|
249
|
+
assert ws.context_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
250
|
+
assert ws.entry_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
175
251
|
|
|
176
252
|
ws.set_token("new-token")
|
|
177
253
|
assert ws.token == "new-token"
|
|
254
|
+
assert ws._ctx("99999999-8888-7777-6666-555555555555") == (
|
|
255
|
+
"context_id=aaaaaaaa... "
|
|
256
|
+
"location_id=11111111... "
|
|
257
|
+
"device_id=99999999..."
|
|
258
|
+
)
|
|
178
259
|
|
|
179
260
|
|
|
180
261
|
async def test_websocket_connect_or_raise_uses_typed_exception():
|
|
@@ -1,209 +0,0 @@
|
|
|
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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|