python-homely 0.1.0__py3-none-any.whl → 0.1.2__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 +3 -2
- homely/client.py +321 -57
- homely/exceptions.py +4 -0
- homely/models.py +35 -1
- homely/py.typed +1 -0
- homely/websocket.py +40 -25
- {python_homely-0.1.0.dist-info → python_homely-0.1.2.dist-info}/METADATA +33 -3
- python_homely-0.1.2.dist-info/RECORD +11 -0
- python_homely-0.1.0.dist-info/RECORD +0 -10
- {python_homely-0.1.0.dist-info → python_homely-0.1.2.dist-info}/WHEEL +0 -0
- {python_homely-0.1.0.dist-info → python_homely-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {python_homely-0.1.0.dist-info → python_homely-0.1.2.dist-info}/top_level.txt +0 -0
homely/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Reusable Homely client package extracted from the integration."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.1.
|
|
3
|
+
__version__ = "0.1.2"
|
|
4
4
|
|
|
5
5
|
from .client import (
|
|
6
6
|
BASE_URL,
|
|
@@ -15,7 +15,7 @@ from .exceptions import (
|
|
|
15
15
|
HomelyResponseError,
|
|
16
16
|
HomelyWebSocketError,
|
|
17
17
|
)
|
|
18
|
-
from .models import TokenResponse
|
|
18
|
+
from .models import TokenEndpointResult, TokenResponse
|
|
19
19
|
from .websocket import HomelyWebSocket
|
|
20
20
|
|
|
21
21
|
__all__ = [
|
|
@@ -29,6 +29,7 @@ __all__ = [
|
|
|
29
29
|
"HomelyAuthError",
|
|
30
30
|
"HomelyResponseError",
|
|
31
31
|
"HomelyWebSocketError",
|
|
32
|
+
"TokenEndpointResult",
|
|
32
33
|
"TokenResponse",
|
|
33
34
|
"auth_header_value",
|
|
34
35
|
]
|
homely/client.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import logging
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any, NoReturn
|
|
6
6
|
|
|
7
7
|
import aiohttp
|
|
8
8
|
|
|
@@ -11,7 +11,7 @@ from .exceptions import (
|
|
|
11
11
|
HomelyConnectionError,
|
|
12
12
|
HomelyResponseError,
|
|
13
13
|
)
|
|
14
|
-
from .models import TokenResponse
|
|
14
|
+
from .models import TokenEndpointResult, TokenFailureReason, TokenResponse
|
|
15
15
|
|
|
16
16
|
_LOGGER = logging.getLogger(__name__)
|
|
17
17
|
|
|
@@ -19,6 +19,17 @@ BASE_URL = "https://sdk.iotiliti.cloud/homely/"
|
|
|
19
19
|
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=20)
|
|
20
20
|
|
|
21
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
|
+
|
|
22
33
|
def auth_header_value(token: str | None) -> str:
|
|
23
34
|
"""Return normalized Authorization header value."""
|
|
24
35
|
normalized = (token or "").strip()
|
|
@@ -27,6 +38,44 @@ def auth_header_value(token: str | None) -> str:
|
|
|
27
38
|
return f"Bearer {normalized}"
|
|
28
39
|
|
|
29
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
|
+
def _text_preview(text: str | None) -> str | None:
|
|
50
|
+
"""Return a trimmed short preview of response text."""
|
|
51
|
+
if text is None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
preview = text.strip()
|
|
55
|
+
if not preview:
|
|
56
|
+
return None
|
|
57
|
+
if len(preview) <= 200:
|
|
58
|
+
return preview
|
|
59
|
+
return f"{preview[:200]}..."
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _error_detail(err: BaseException) -> str:
|
|
63
|
+
"""Return a compact detail string for logs and typed results."""
|
|
64
|
+
detail = str(err).strip()
|
|
65
|
+
if detail:
|
|
66
|
+
return detail
|
|
67
|
+
return err.__class__.__name__
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _coarse_token_reason(reason: TokenFailureReason | None) -> str | None:
|
|
71
|
+
"""Map detailed token failure reasons to the legacy coarse reason set."""
|
|
72
|
+
if reason == "invalid_auth":
|
|
73
|
+
return "invalid_auth"
|
|
74
|
+
if reason is None:
|
|
75
|
+
return None
|
|
76
|
+
return "cannot_connect"
|
|
77
|
+
|
|
78
|
+
|
|
30
79
|
class HomelyClient:
|
|
31
80
|
"""Small reusable async client for the Homely cloud API."""
|
|
32
81
|
|
|
@@ -61,40 +110,54 @@ class HomelyClient:
|
|
|
61
110
|
|
|
62
111
|
Raises a typed SDK exception on failure.
|
|
63
112
|
"""
|
|
64
|
-
|
|
65
|
-
if
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
113
|
+
result = await self.fetch_token_details(username, password)
|
|
114
|
+
if result.token is not None:
|
|
115
|
+
return result.token
|
|
116
|
+
self._raise_token_endpoint_failure(
|
|
117
|
+
result,
|
|
118
|
+
invalid_reason="invalid_auth",
|
|
119
|
+
invalid_message="Invalid Homely username or password",
|
|
120
|
+
connection_message="Could not connect to Homely",
|
|
121
|
+
malformed_message="Homely authentication response could not be parsed",
|
|
122
|
+
response_message="Homely authentication failed",
|
|
123
|
+
)
|
|
70
124
|
|
|
71
|
-
async def
|
|
125
|
+
async def _fetch_token_payload(
|
|
72
126
|
self,
|
|
73
127
|
username: str,
|
|
74
128
|
password: str,
|
|
75
|
-
) -> tuple[dict[str, Any] | None,
|
|
76
|
-
"""Fetch access token and
|
|
77
|
-
|
|
129
|
+
) -> tuple[dict[str, Any] | None, int | None]:
|
|
130
|
+
"""Fetch access token payload and include HTTP status when available."""
|
|
131
|
+
result = await self.fetch_token_details(username, password)
|
|
132
|
+
return result.raw, result.status
|
|
133
|
+
|
|
134
|
+
async def fetch_token_details(
|
|
135
|
+
self,
|
|
136
|
+
username: str,
|
|
137
|
+
password: str,
|
|
138
|
+
) -> TokenEndpointResult:
|
|
139
|
+
"""Fetch a token and return a detailed typed result."""
|
|
78
140
|
payload = {
|
|
79
141
|
"username": username,
|
|
80
142
|
"password": password,
|
|
81
143
|
}
|
|
144
|
+
return await self._post_token_endpoint(
|
|
145
|
+
endpoint="oauth/token",
|
|
146
|
+
payload=payload,
|
|
147
|
+
invalid_reason="invalid_auth",
|
|
148
|
+
action="Token fetch",
|
|
149
|
+
)
|
|
82
150
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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"
|
|
151
|
+
async def fetch_token_with_reason(
|
|
152
|
+
self,
|
|
153
|
+
username: str,
|
|
154
|
+
password: str,
|
|
155
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
156
|
+
"""Fetch access token and return optional reason key on failure."""
|
|
157
|
+
result = await self.fetch_token_details(username, password)
|
|
158
|
+
if result.raw is not None:
|
|
159
|
+
return result.raw, None
|
|
160
|
+
return None, _coarse_token_reason(result.reason)
|
|
98
161
|
|
|
99
162
|
async def fetch_token(
|
|
100
163
|
self,
|
|
@@ -102,55 +165,239 @@ class HomelyClient:
|
|
|
102
165
|
password: str,
|
|
103
166
|
) -> dict[str, Any] | None:
|
|
104
167
|
"""Fetch access token from API."""
|
|
105
|
-
|
|
106
|
-
return
|
|
168
|
+
result = await self.fetch_token_details(username, password)
|
|
169
|
+
return result.raw
|
|
107
170
|
|
|
108
|
-
async def
|
|
109
|
-
|
|
110
|
-
|
|
171
|
+
async def _fetch_refresh_token_payload(
|
|
172
|
+
self,
|
|
173
|
+
refresh_token: str,
|
|
174
|
+
) -> tuple[dict[str, Any] | None, int | None]:
|
|
175
|
+
"""Refresh access token payload and include HTTP status when available."""
|
|
176
|
+
result = await self.fetch_refresh_token_details(refresh_token)
|
|
177
|
+
return result.raw, result.status
|
|
178
|
+
|
|
179
|
+
async def fetch_refresh_token_details(
|
|
180
|
+
self,
|
|
181
|
+
refresh_token: str,
|
|
182
|
+
) -> TokenEndpointResult:
|
|
183
|
+
"""Refresh a token and return a detailed typed result."""
|
|
111
184
|
payload = {
|
|
112
185
|
"refresh_token": refresh_token,
|
|
113
186
|
}
|
|
187
|
+
return await self._post_token_endpoint(
|
|
188
|
+
endpoint="oauth/refresh-token",
|
|
189
|
+
payload=payload,
|
|
190
|
+
invalid_reason="invalid_refresh_token",
|
|
191
|
+
action="Token refresh",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
async def fetch_refresh_token(self, refresh_token: str) -> dict[str, Any] | None:
|
|
195
|
+
"""Refresh access token using refresh token."""
|
|
196
|
+
result = await self.fetch_refresh_token_details(refresh_token)
|
|
197
|
+
return result.raw
|
|
198
|
+
|
|
199
|
+
async def refresh_access_token(self, refresh_token: str) -> TokenResponse:
|
|
200
|
+
"""Refresh access token and return a typed token response."""
|
|
201
|
+
result = await self.fetch_refresh_token_details(refresh_token)
|
|
202
|
+
if result.token is not None:
|
|
203
|
+
return result.token
|
|
204
|
+
self._raise_token_endpoint_failure(
|
|
205
|
+
result,
|
|
206
|
+
invalid_reason="invalid_refresh_token",
|
|
207
|
+
invalid_message="Homely rejected the supplied refresh token",
|
|
208
|
+
connection_message="Could not refresh Homely access token",
|
|
209
|
+
malformed_message="Homely refresh response could not be parsed",
|
|
210
|
+
response_message="Homely refresh request failed",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async def _post_token_endpoint(
|
|
214
|
+
self,
|
|
215
|
+
*,
|
|
216
|
+
endpoint: str,
|
|
217
|
+
payload: dict[str, Any],
|
|
218
|
+
invalid_reason: TokenFailureReason,
|
|
219
|
+
action: str,
|
|
220
|
+
) -> TokenEndpointResult:
|
|
221
|
+
"""Post to a token endpoint and return a detailed typed result."""
|
|
222
|
+
url = f"{self._base_url}{endpoint}"
|
|
114
223
|
|
|
115
224
|
try:
|
|
116
225
|
async with self._session.post(url, json=payload, timeout=self._timeout) as response:
|
|
226
|
+
body_preview = await self._response_text_preview(response)
|
|
117
227
|
if response.status in (200, 201):
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
228
|
+
try:
|
|
229
|
+
parsed = await response.json()
|
|
230
|
+
except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
|
|
231
|
+
detail = _error_detail(err)
|
|
232
|
+
_LOGGER.debug(
|
|
233
|
+
"%s returned invalid JSON status=%s: %s",
|
|
234
|
+
action,
|
|
235
|
+
response.status,
|
|
236
|
+
detail,
|
|
237
|
+
)
|
|
238
|
+
return TokenEndpointResult(
|
|
239
|
+
reason="invalid_json",
|
|
240
|
+
status=response.status,
|
|
241
|
+
detail=detail,
|
|
242
|
+
body_preview=body_preview,
|
|
243
|
+
)
|
|
244
|
+
if not isinstance(parsed, dict):
|
|
245
|
+
detail = f"payload_type={type(parsed).__name__}"
|
|
246
|
+
_LOGGER.debug(
|
|
247
|
+
"%s returned unexpected payload type status=%s payload_type=%s",
|
|
248
|
+
action,
|
|
249
|
+
response.status,
|
|
250
|
+
type(parsed).__name__,
|
|
251
|
+
)
|
|
252
|
+
return TokenEndpointResult(
|
|
253
|
+
reason="invalid_payload",
|
|
254
|
+
status=response.status,
|
|
255
|
+
detail=detail,
|
|
256
|
+
body_preview=_response_preview(parsed),
|
|
257
|
+
)
|
|
258
|
+
if not parsed:
|
|
259
|
+
_LOGGER.debug(
|
|
260
|
+
"%s returned an empty payload status=%s",
|
|
261
|
+
action,
|
|
262
|
+
response.status,
|
|
263
|
+
)
|
|
264
|
+
return TokenEndpointResult(
|
|
265
|
+
reason="empty_response",
|
|
266
|
+
status=response.status,
|
|
267
|
+
body_preview=_response_preview(parsed),
|
|
268
|
+
)
|
|
269
|
+
try:
|
|
270
|
+
token = TokenResponse.from_dict(parsed)
|
|
271
|
+
except (KeyError, TypeError, ValueError) as err:
|
|
272
|
+
detail = _error_detail(err)
|
|
273
|
+
_LOGGER.debug(
|
|
274
|
+
"%s returned malformed payload status=%s: %s",
|
|
275
|
+
action,
|
|
276
|
+
response.status,
|
|
277
|
+
detail,
|
|
278
|
+
)
|
|
279
|
+
return TokenEndpointResult(
|
|
280
|
+
reason="invalid_payload",
|
|
281
|
+
status=response.status,
|
|
282
|
+
detail=detail,
|
|
283
|
+
body_preview=_response_preview(parsed),
|
|
284
|
+
)
|
|
285
|
+
_LOGGER.debug("%s successful", action)
|
|
286
|
+
return TokenEndpointResult(token=token, status=response.status)
|
|
287
|
+
|
|
288
|
+
if response.status in (400, 401, 403):
|
|
289
|
+
_LOGGER.debug("%s rejected with status=%s", action, response.status)
|
|
290
|
+
return TokenEndpointResult(
|
|
291
|
+
reason=invalid_reason,
|
|
292
|
+
status=response.status,
|
|
293
|
+
body_preview=body_preview,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
_LOGGER.debug("%s failed with status=%s", action, response.status)
|
|
297
|
+
return TokenEndpointResult(
|
|
298
|
+
reason="http_error",
|
|
299
|
+
status=response.status,
|
|
300
|
+
body_preview=body_preview,
|
|
301
|
+
)
|
|
302
|
+
except TimeoutError as err:
|
|
303
|
+
detail = _error_detail(err)
|
|
304
|
+
_LOGGER.debug("%s timeout: %s", action, detail)
|
|
305
|
+
return TokenEndpointResult(reason="timeout", detail=detail)
|
|
306
|
+
except aiohttp.ClientError as err:
|
|
307
|
+
detail = _error_detail(err)
|
|
308
|
+
_LOGGER.debug("%s network error: %s", action, detail)
|
|
309
|
+
return TokenEndpointResult(reason="network_error", detail=detail)
|
|
310
|
+
|
|
311
|
+
async def _response_text_preview(self, response: aiohttp.ClientResponse) -> str | None:
|
|
312
|
+
"""Read and shorten response text for diagnostics when available."""
|
|
313
|
+
try:
|
|
314
|
+
return _text_preview(await response.text())
|
|
315
|
+
except (TypeError, ValueError, UnicodeDecodeError) as err:
|
|
316
|
+
_LOGGER.debug("Could not read response text status=%s: %s", response.status, err)
|
|
124
317
|
return None
|
|
125
318
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
319
|
+
def _raise_token_endpoint_failure(
|
|
320
|
+
self,
|
|
321
|
+
result: TokenEndpointResult,
|
|
322
|
+
*,
|
|
323
|
+
invalid_reason: TokenFailureReason,
|
|
324
|
+
invalid_message: str,
|
|
325
|
+
connection_message: str,
|
|
326
|
+
malformed_message: str,
|
|
327
|
+
response_message: str,
|
|
328
|
+
) -> NoReturn:
|
|
329
|
+
"""Raise a typed SDK exception for a failed token endpoint result."""
|
|
330
|
+
if result.reason == invalid_reason:
|
|
331
|
+
raise HomelyAuthError(invalid_message)
|
|
332
|
+
if result.reason in ("timeout", "network_error"):
|
|
333
|
+
raise HomelyConnectionError(connection_message)
|
|
334
|
+
if result.reason in ("invalid_json", "invalid_payload", "empty_response"):
|
|
335
|
+
raise HomelyResponseError(
|
|
336
|
+
malformed_message,
|
|
337
|
+
status=result.status,
|
|
338
|
+
body=result.body_preview,
|
|
339
|
+
body_preview=result.body_preview,
|
|
340
|
+
)
|
|
341
|
+
raise HomelyResponseError(
|
|
342
|
+
response_message,
|
|
343
|
+
status=result.status,
|
|
344
|
+
body=result.body_preview,
|
|
345
|
+
body_preview=result.body_preview,
|
|
346
|
+
)
|
|
132
347
|
|
|
133
|
-
async def
|
|
134
|
-
|
|
348
|
+
async def _get_locations_payload(
|
|
349
|
+
self,
|
|
350
|
+
token: str,
|
|
351
|
+
) -> tuple[list[dict[str, Any]] | None, int | None]:
|
|
352
|
+
"""Get locations payload and include HTTP status when available."""
|
|
135
353
|
url = f"{self._base_url}locations"
|
|
136
354
|
headers = {"Authorization": auth_header_value(token)}
|
|
137
355
|
|
|
138
356
|
try:
|
|
139
357
|
async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
|
|
140
358
|
if response.status == 200:
|
|
359
|
+
try:
|
|
360
|
+
parsed = await response.json()
|
|
361
|
+
except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
|
|
362
|
+
_LOGGER.debug(
|
|
363
|
+
"Locations fetch returned invalid JSON status=%s: %s",
|
|
364
|
+
response.status,
|
|
365
|
+
err,
|
|
366
|
+
)
|
|
367
|
+
return None, response.status
|
|
368
|
+
if not isinstance(parsed, list):
|
|
369
|
+
_LOGGER.debug(
|
|
370
|
+
"Locations fetch returned unexpected payload type "
|
|
371
|
+
"status=%s payload_type=%s",
|
|
372
|
+
response.status,
|
|
373
|
+
type(parsed).__name__,
|
|
374
|
+
)
|
|
375
|
+
return None, response.status
|
|
141
376
|
_LOGGER.debug("Locations fetch successful")
|
|
142
|
-
return
|
|
377
|
+
return parsed, response.status
|
|
143
378
|
_LOGGER.debug("Locations fetch failed with status=%s", response.status)
|
|
144
|
-
return None
|
|
379
|
+
return None, response.status
|
|
145
380
|
except (aiohttp.ClientError, TimeoutError) as err:
|
|
146
381
|
_LOGGER.debug("Locations fetch network error: %s", err)
|
|
147
|
-
return None
|
|
382
|
+
return None, None
|
|
383
|
+
|
|
384
|
+
async def get_locations(self, token: str) -> list[dict[str, Any]] | None:
|
|
385
|
+
"""Get locations from API."""
|
|
386
|
+
locations, _status = await self._get_locations_payload(token)
|
|
387
|
+
return locations
|
|
148
388
|
|
|
149
389
|
async def get_locations_or_raise(self, token: str) -> list[dict[str, Any]]:
|
|
150
390
|
"""Get locations from API or raise a typed exception."""
|
|
151
|
-
locations = await self.
|
|
391
|
+
locations, status = await self._get_locations_payload(token)
|
|
152
392
|
if locations is not None:
|
|
153
393
|
return locations
|
|
394
|
+
if status in (401, 403):
|
|
395
|
+
raise HomelyAuthError("Homely rejected the supplied access token")
|
|
396
|
+
if status == 200:
|
|
397
|
+
raise HomelyResponseError(
|
|
398
|
+
"Homely locations response could not be parsed",
|
|
399
|
+
status=status,
|
|
400
|
+
)
|
|
154
401
|
raise HomelyConnectionError("Could not fetch Homely locations")
|
|
155
402
|
|
|
156
403
|
async def get_home_data(
|
|
@@ -174,20 +421,37 @@ class HomelyClient:
|
|
|
174
421
|
try:
|
|
175
422
|
async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
|
|
176
423
|
if response.status == 200:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
424
|
+
try:
|
|
425
|
+
parsed = await response.json()
|
|
426
|
+
except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
|
|
427
|
+
_LOGGER.debug(
|
|
428
|
+
"Location data fetch returned invalid JSON "
|
|
429
|
+
"status=%s location_id=%s: %s",
|
|
430
|
+
response.status,
|
|
431
|
+
_log_identifier(location_id),
|
|
432
|
+
err,
|
|
433
|
+
)
|
|
434
|
+
return None, response.status
|
|
435
|
+
if not isinstance(parsed, dict):
|
|
436
|
+
_LOGGER.debug(
|
|
437
|
+
"Location data fetch returned unexpected payload "
|
|
438
|
+
"type status=%s location_id=%s payload_type=%s",
|
|
439
|
+
response.status,
|
|
440
|
+
_log_identifier(location_id),
|
|
441
|
+
type(parsed).__name__,
|
|
442
|
+
)
|
|
443
|
+
return None, response.status
|
|
444
|
+
return parsed, response.status
|
|
180
445
|
_LOGGER.debug(
|
|
181
|
-
"Location data fetch failed with status=%s location_id=%s
|
|
446
|
+
"Location data fetch failed with status=%s location_id=%s",
|
|
182
447
|
response.status,
|
|
183
|
-
location_id,
|
|
184
|
-
body_preview,
|
|
448
|
+
_log_identifier(location_id),
|
|
185
449
|
)
|
|
186
450
|
return None, response.status
|
|
187
451
|
except (aiohttp.ClientError, TimeoutError) as err:
|
|
188
452
|
_LOGGER.debug(
|
|
189
453
|
"Location data fetch network error location_id=%s: %s",
|
|
190
|
-
location_id,
|
|
454
|
+
_log_identifier(location_id),
|
|
191
455
|
err,
|
|
192
456
|
)
|
|
193
457
|
return None, None
|
homely/exceptions.py
CHANGED
|
@@ -23,10 +23,14 @@ class HomelyResponseError(HomelyError):
|
|
|
23
23
|
*,
|
|
24
24
|
status: int | None = None,
|
|
25
25
|
body: str | None = None,
|
|
26
|
+
body_preview: str | None = None,
|
|
26
27
|
) -> None:
|
|
27
28
|
"""Initialize the exception with optional response details."""
|
|
28
29
|
super().__init__(message)
|
|
29
30
|
self.status = status
|
|
31
|
+
if body_preview is None:
|
|
32
|
+
body_preview = body
|
|
33
|
+
self.body_preview = body_preview
|
|
30
34
|
self.body = body
|
|
31
35
|
|
|
32
36
|
|
homely/models.py
CHANGED
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
TokenFailureReason = Literal[
|
|
8
|
+
"invalid_auth",
|
|
9
|
+
"invalid_refresh_token",
|
|
10
|
+
"http_error",
|
|
11
|
+
"network_error",
|
|
12
|
+
"timeout",
|
|
13
|
+
"invalid_json",
|
|
14
|
+
"invalid_payload",
|
|
15
|
+
"empty_response",
|
|
16
|
+
]
|
|
6
17
|
|
|
7
18
|
|
|
8
19
|
@dataclass(slots=True)
|
|
@@ -29,3 +40,26 @@ class TokenResponse:
|
|
|
29
40
|
expires_in=parsed_expires_in,
|
|
30
41
|
raw=dict(data),
|
|
31
42
|
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class TokenEndpointResult:
|
|
47
|
+
"""Detailed result returned by authentication and refresh helpers."""
|
|
48
|
+
|
|
49
|
+
token: TokenResponse | None = None
|
|
50
|
+
reason: TokenFailureReason | None = None
|
|
51
|
+
status: int | None = None
|
|
52
|
+
detail: str | None = None
|
|
53
|
+
body_preview: str | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def ok(self) -> bool:
|
|
57
|
+
"""Return whether the request produced a usable token response."""
|
|
58
|
+
return self.token is not None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def raw(self) -> dict[str, Any] | None:
|
|
62
|
+
"""Return the raw token payload when available."""
|
|
63
|
+
if self.token is None:
|
|
64
|
+
return None
|
|
65
|
+
return self.token.raw
|
homely/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
homely/websocket.py
CHANGED
|
@@ -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
|
|
|
@@ -34,9 +45,9 @@ class HomelyWebSocket:
|
|
|
34
45
|
self.location_id = location_id
|
|
35
46
|
self.token = token
|
|
36
47
|
self.on_data_update = on_data_update
|
|
37
|
-
self.socket = None
|
|
48
|
+
self.socket: Any | None = None
|
|
38
49
|
self._is_closing = False
|
|
39
|
-
self._reconnect_task: asyncio.Task | None = None
|
|
50
|
+
self._reconnect_task: asyncio.Task[None] | None = None
|
|
40
51
|
self._reconnect_interval = 300
|
|
41
52
|
self._reconnect_warn_every = 12
|
|
42
53
|
self._status_update_callback = status_update_callback
|
|
@@ -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(),
|
|
@@ -254,7 +268,7 @@ class HomelyWebSocket:
|
|
|
254
268
|
return False
|
|
255
269
|
|
|
256
270
|
try:
|
|
257
|
-
import socketio
|
|
271
|
+
import socketio # type: ignore[import-untyped]
|
|
258
272
|
except ImportError:
|
|
259
273
|
_LOGGER.error("python-socketio is not installed. WebSocket disabled %s.", self._ctx())
|
|
260
274
|
self._set_status("Disconnected", "socketio missing")
|
|
@@ -279,35 +293,36 @@ class HomelyWebSocket:
|
|
|
279
293
|
engineio_logger=False,
|
|
280
294
|
)
|
|
281
295
|
|
|
282
|
-
|
|
283
|
-
async def connect():
|
|
296
|
+
async def connect() -> None:
|
|
284
297
|
self._on_connect()
|
|
285
298
|
|
|
286
|
-
|
|
287
|
-
async def disconnect(*args):
|
|
299
|
+
async def disconnect(*args: Any) -> None:
|
|
288
300
|
self._on_disconnect(self._build_reason(args[0] if args else None))
|
|
289
301
|
|
|
290
|
-
|
|
291
|
-
async def message(data):
|
|
302
|
+
async def message(data: Any) -> None:
|
|
292
303
|
self._on_event(data)
|
|
293
304
|
|
|
294
|
-
|
|
295
|
-
async def event(data):
|
|
305
|
+
async def event(data: Any) -> None:
|
|
296
306
|
self._on_event(data)
|
|
297
307
|
|
|
298
|
-
|
|
299
|
-
async def catch_all(event, data):
|
|
308
|
+
async def catch_all(event: str, data: Any) -> None:
|
|
300
309
|
if event not in ("connect", "disconnect", "message", "event", "connect_error"):
|
|
301
310
|
_LOGGER.debug("WebSocket event %s type=%s", self._ctx(), event)
|
|
302
311
|
self._on_event({"type": event, "payload": data})
|
|
303
312
|
|
|
304
|
-
|
|
305
|
-
async def connect_error(data):
|
|
313
|
+
async def connect_error(data: Any) -> None:
|
|
306
314
|
raw_reason = self._build_reason(data)
|
|
307
315
|
reason = f"connect_error: {raw_reason}" if raw_reason else "connect_error"
|
|
308
316
|
_LOGGER.debug("WebSocket connect_error %s: %s", self._ctx(), reason)
|
|
309
317
|
self._on_disconnect(reason)
|
|
310
318
|
|
|
319
|
+
self.socket.on("connect", connect)
|
|
320
|
+
self.socket.on("disconnect", disconnect)
|
|
321
|
+
self.socket.on("message", message)
|
|
322
|
+
self.socket.on("event", event)
|
|
323
|
+
self.socket.on("*", catch_all)
|
|
324
|
+
self.socket.on("connect_error", connect_error)
|
|
325
|
+
|
|
311
326
|
bearer_token = self._bearer_value(self.token)
|
|
312
327
|
query = urlencode(
|
|
313
328
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-homely
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere.
|
|
5
5
|
Author: Ludvik Blichfeldt Rød
|
|
6
6
|
License-Expression: MIT
|
|
@@ -102,14 +102,23 @@ async def main() -> None:
|
|
|
102
102
|
|
|
103
103
|
- `authenticate(username, password) -> TokenResponse`
|
|
104
104
|
- `refresh_access_token(refresh_token) -> TokenResponse`
|
|
105
|
+
- `fetch_token_details(username, password) -> TokenEndpointResult`
|
|
106
|
+
- `fetch_refresh_token_details(refresh_token) -> TokenEndpointResult`
|
|
105
107
|
- `get_locations_or_raise(token) -> list[dict]`
|
|
106
108
|
- `get_home_data_or_raise(token, location_id) -> dict`
|
|
107
109
|
- `HomelyWebSocket(...).connect_or_raise()`
|
|
108
110
|
|
|
111
|
+
Legacy compatibility helpers remain available:
|
|
112
|
+
|
|
113
|
+
- `fetch_token_with_reason(username, password) -> tuple[dict | None, str | None]`
|
|
114
|
+
- `fetch_token(username, password) -> dict | None`
|
|
115
|
+
- `fetch_refresh_token(refresh_token) -> dict | None`
|
|
116
|
+
|
|
109
117
|
Main exports:
|
|
110
118
|
|
|
111
119
|
- `HomelyClient`
|
|
112
120
|
- `HomelyWebSocket`
|
|
121
|
+
- `TokenEndpointResult`
|
|
113
122
|
- `TokenResponse`
|
|
114
123
|
- `HomelyConnectionError`
|
|
115
124
|
- `HomelyAuthError`
|
|
@@ -121,12 +130,33 @@ Main exports:
|
|
|
121
130
|
- `HomelyConnectionError`: network or service unavailable
|
|
122
131
|
- `HomelyAuthError`: invalid credentials or rejected token
|
|
123
132
|
- `HomelyResponseError`: unexpected response or HTTP failure
|
|
133
|
+
Carries `status` and `body_preview` when available.
|
|
124
134
|
- `HomelyWebSocketError`: websocket could not be established
|
|
125
135
|
|
|
136
|
+
## Token Diagnostics
|
|
137
|
+
|
|
138
|
+
If you need more than success/failure, use the detailed token helpers:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
result = await client.fetch_refresh_token_details(refresh_token)
|
|
142
|
+
if result.ok:
|
|
143
|
+
print(result.token.access_token)
|
|
144
|
+
else:
|
|
145
|
+
print(result.reason, result.status, result.detail, result.body_preview)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`TokenEndpointResult.reason` can distinguish between invalid credentials, invalid refresh
|
|
149
|
+
tokens, network errors, timeouts, malformed JSON, malformed payloads, empty responses, and
|
|
150
|
+
unexpected HTTP failures.
|
|
151
|
+
|
|
152
|
+
## Websocket Note
|
|
153
|
+
|
|
154
|
+
Refreshing an access token does not require forcing an already-connected websocket to reconnect.
|
|
155
|
+
The websocket token only matters when a new websocket connection is established or re-established.
|
|
156
|
+
|
|
126
157
|
## License
|
|
127
158
|
|
|
128
159
|
MIT. See [LICENSE](LICENSE).
|
|
129
160
|
|
|
130
161
|
|
|
131
|
-
⭐ If you find this
|
|
132
|
-
|
|
162
|
+
⭐ If you find this package useful, please consider giving it a star on [GitHub](https://github.com/ludvikroed/python-homely)! ⭐
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
homely/__init__.py,sha256=PlZPdFw1D9L2lBeNmdKAsYGgLHjjwRP3XAwmYBRo2Is,746
|
|
2
|
+
homely/client.py,sha256=iqM1ChFEVez5PJcvWk8BlkkVx4bekaQacBa-rAvSxpo,18276
|
|
3
|
+
homely/exceptions.py,sha256=w6y5MC8yjPe5QtI-YEKUsbAPF1D0ARWx6ZkRsLTjrgQ,1040
|
|
4
|
+
homely/models.py,sha256=8chNpjo1SF4TGww7XPWFUK3kXILH3bSmUnjNteyfsys,1842
|
|
5
|
+
homely/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
6
|
+
homely/websocket.py,sha256=g6WJxvEjt-pjWkEk3qyBdhs6OCX3l-hGBOJ3dcPxHCI,15538
|
|
7
|
+
python_homely-0.1.2.dist-info/licenses/LICENSE,sha256=nNVHKvQjryAonWsz5TbhzLd6M2kZaoNUvhxz92MgeAA,1079
|
|
8
|
+
python_homely-0.1.2.dist-info/METADATA,sha256=piiYKUni3USyn2FNt6V8qvVCwDOk6aCkBKAesK7a78Q,5239
|
|
9
|
+
python_homely-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
python_homely-0.1.2.dist-info/top_level.txt,sha256=auE-j6ghVMdT_jAw04jiXthgbuLpi-jYBU0fCkKvREQ,7
|
|
11
|
+
python_homely-0.1.2.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|