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 CHANGED
@@ -1,6 +1,6 @@
1
1
  """Reusable Homely client package extracted from the integration."""
2
2
 
3
- __version__ = "0.1.0"
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
- 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")
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 fetch_token_with_reason(
125
+ async def _fetch_token_payload(
72
126
  self,
73
127
  username: str,
74
128
  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"
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
- 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"
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
- response, _reason = await self.fetch_token_with_reason(username, password)
106
- return response
168
+ result = await self.fetch_token_details(username, password)
169
+ return result.raw
107
170
 
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"
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
- _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)
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
- 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")
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 get_locations(self, token: str) -> list[dict[str, Any]] | None:
134
- """Get locations from API."""
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 await response.json()
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.get_locations(token)
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
- return await response.json(), response.status
178
- body = await response.text()
179
- body_preview = body.replace("\n", " ")[:200]
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 body_preview=%r",
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 = f"context_id={self.context_id} location_id={self.location_id}"
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.info("WebSocket connected %s", self._ctx())
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.warning("WebSocket disconnected %s (%s)", self._ctx(), reason)
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.warning("WebSocket disconnected %s", self._ctx())
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.info(
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.info(
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.info("WebSocket reconnect attempt %s succeeded %s", attempt, self._ctx())
245
+ _LOGGER.debug("WebSocket reconnect attempt %s succeeded %s", attempt, self._ctx())
232
246
  return
233
247
 
234
- if attempt == 1 or attempt % self._reconnect_warn_every == 0:
235
- _LOGGER.warning(
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
- @self.socket.event
283
- async def connect():
296
+ async def connect() -> None:
284
297
  self._on_connect()
285
298
 
286
- @self.socket.event
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
- @self.socket.event
291
- async def message(data):
302
+ async def message(data: Any) -> None:
292
303
  self._on_event(data)
293
304
 
294
- @self.socket.event
295
- async def event(data):
305
+ async def event(data: Any) -> None:
296
306
  self._on_event(data)
297
307
 
298
- @self.socket.on("*")
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
- @self.socket.event
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.0
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 integration useful, please consider giving it a star on [GitHub](https://github.com/ludvikroed/python-homely)! ⭐
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,,