python-homely 0.1.1__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-homely
3
- Version: 0.1.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 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)! ⭐
@@ -69,14 +69,23 @@ async def main() -> None:
69
69
 
70
70
  - `authenticate(username, password) -> TokenResponse`
71
71
  - `refresh_access_token(refresh_token) -> TokenResponse`
72
+ - `fetch_token_details(username, password) -> TokenEndpointResult`
73
+ - `fetch_refresh_token_details(refresh_token) -> TokenEndpointResult`
72
74
  - `get_locations_or_raise(token) -> list[dict]`
73
75
  - `get_home_data_or_raise(token, location_id) -> dict`
74
76
  - `HomelyWebSocket(...).connect_or_raise()`
75
77
 
78
+ Legacy compatibility helpers remain available:
79
+
80
+ - `fetch_token_with_reason(username, password) -> tuple[dict | None, str | None]`
81
+ - `fetch_token(username, password) -> dict | None`
82
+ - `fetch_refresh_token(refresh_token) -> dict | None`
83
+
76
84
  Main exports:
77
85
 
78
86
  - `HomelyClient`
79
87
  - `HomelyWebSocket`
88
+ - `TokenEndpointResult`
80
89
  - `TokenResponse`
81
90
  - `HomelyConnectionError`
82
91
  - `HomelyAuthError`
@@ -88,12 +97,33 @@ Main exports:
88
97
  - `HomelyConnectionError`: network or service unavailable
89
98
  - `HomelyAuthError`: invalid credentials or rejected token
90
99
  - `HomelyResponseError`: unexpected response or HTTP failure
100
+ Carries `status` and `body_preview` when available.
91
101
  - `HomelyWebSocketError`: websocket could not be established
92
102
 
103
+ ## Token Diagnostics
104
+
105
+ If you need more than success/failure, use the detailed token helpers:
106
+
107
+ ```python
108
+ result = await client.fetch_refresh_token_details(refresh_token)
109
+ if result.ok:
110
+ print(result.token.access_token)
111
+ else:
112
+ print(result.reason, result.status, result.detail, result.body_preview)
113
+ ```
114
+
115
+ `TokenEndpointResult.reason` can distinguish between invalid credentials, invalid refresh
116
+ tokens, network errors, timeouts, malformed JSON, malformed payloads, empty responses, and
117
+ unexpected HTTP failures.
118
+
119
+ ## Websocket Note
120
+
121
+ Refreshing an access token does not require forcing an already-connected websocket to reconnect.
122
+ The websocket token only matters when a new websocket connection is established or re-established.
123
+
93
124
  ## License
94
125
 
95
126
  MIT. See [LICENSE](LICENSE).
96
127
 
97
128
 
98
- ⭐ If you find this integration useful, please consider giving it a star on [GitHub](https://github.com/ludvikroed/python-homely)! ⭐
99
-
129
+ ⭐ If you find this package useful, please consider giving it a star on [GitHub](https://github.com/ludvikroed/python-homely)! ⭐
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-homely"
7
- version = "0.1.1"
7
+ version = "0.1.2"
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"
@@ -51,6 +51,9 @@ package-dir = {"" = "src"}
51
51
  [tool.setuptools.packages.find]
52
52
  where = ["src"]
53
53
 
54
+ [tool.setuptools.package-data]
55
+ homely = ["py.typed"]
56
+
54
57
  [tool.pytest.ini_options]
55
58
  asyncio_mode = "auto"
56
59
  testpaths = ["tests"]
@@ -1,6 +1,6 @@
1
1
  """Reusable Homely client package extracted from the integration."""
2
2
 
3
- __version__ = "0.1.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
  ]
@@ -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
 
@@ -46,6 +46,36 @@ def _response_preview(payload: Any) -> str:
46
46
  return f"{text[:200]}..."
47
47
 
48
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
+
49
79
  class HomelyClient:
50
80
  """Small reusable async client for the Homely cloud API."""
51
81
 
@@ -80,24 +110,17 @@ class HomelyClient:
80
110
 
81
111
  Raises a typed SDK exception on failure.
82
112
  """
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")
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
+ )
101
124
 
102
125
  async def _fetch_token_payload(
103
126
  self,
@@ -105,39 +128,25 @@ class HomelyClient:
105
128
  password: str,
106
129
  ) -> tuple[dict[str, Any] | None, int | None]:
107
130
  """Fetch access token payload and include HTTP status when available."""
108
- url = f"{self._base_url}oauth/token"
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."""
109
140
  payload = {
110
141
  "username": username,
111
142
  "password": password,
112
143
  }
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
144
+ return await self._post_token_endpoint(
145
+ endpoint="oauth/token",
146
+ payload=payload,
147
+ invalid_reason="invalid_auth",
148
+ action="Token fetch",
149
+ )
141
150
 
142
151
  async def fetch_token_with_reason(
143
152
  self,
@@ -145,12 +154,10 @@ class HomelyClient:
145
154
  password: str,
146
155
  ) -> tuple[dict[str, Any] | None, str | None]:
147
156
  """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"
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)
154
161
 
155
162
  async def fetch_token(
156
163
  self,
@@ -158,71 +165,185 @@ class HomelyClient:
158
165
  password: str,
159
166
  ) -> dict[str, Any] | None:
160
167
  """Fetch access token from API."""
161
- response, _reason = await self.fetch_token_with_reason(username, password)
162
- return response
168
+ result = await self.fetch_token_details(username, password)
169
+ return result.raw
163
170
 
164
171
  async def _fetch_refresh_token_payload(
165
172
  self,
166
173
  refresh_token: str,
167
174
  ) -> tuple[dict[str, Any] | None, int | None]:
168
175
  """Refresh access token payload and include HTTP status when available."""
169
- url = f"{self._base_url}oauth/refresh-token"
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."""
170
184
  payload = {
171
185
  "refresh_token": refresh_token,
172
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}"
173
223
 
174
224
  try:
175
225
  async with self._session.post(url, json=payload, timeout=self._timeout) as response:
226
+ body_preview = await self._response_text_preview(response)
176
227
  if response.status in (200, 201):
177
228
  try:
178
229
  parsed = await response.json()
179
230
  except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
231
+ detail = _error_detail(err)
180
232
  _LOGGER.debug(
181
- "Token refresh returned invalid JSON status=%s: %s",
233
+ "%s returned invalid JSON status=%s: %s",
234
+ action,
182
235
  response.status,
183
- err,
236
+ detail,
237
+ )
238
+ return TokenEndpointResult(
239
+ reason="invalid_json",
240
+ status=response.status,
241
+ detail=detail,
242
+ body_preview=body_preview,
184
243
  )
185
- return None, response.status
186
244
  if not isinstance(parsed, dict):
245
+ detail = f"payload_type={type(parsed).__name__}"
187
246
  _LOGGER.debug(
188
- "Token refresh returned unexpected payload type status=%s payload_type=%s",
247
+ "%s returned unexpected payload type status=%s payload_type=%s",
248
+ action,
189
249
  response.status,
190
250
  type(parsed).__name__,
191
251
  )
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
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)
317
+ return None
205
318
 
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):
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"):
221
335
  raise HomelyResponseError(
222
- "Homely refresh response could not be parsed",
223
- status=status,
336
+ malformed_message,
337
+ status=result.status,
338
+ body=result.body_preview,
339
+ body_preview=result.body_preview,
224
340
  )
225
- raise HomelyConnectionError("Could not refresh Homely access token")
341
+ raise HomelyResponseError(
342
+ response_message,
343
+ status=result.status,
344
+ body=result.body_preview,
345
+ body_preview=result.body_preview,
346
+ )
226
347
 
227
348
  async def _get_locations_payload(
228
349
  self,
@@ -246,7 +367,8 @@ class HomelyClient:
246
367
  return None, response.status
247
368
  if not isinstance(parsed, list):
248
369
  _LOGGER.debug(
249
- "Locations fetch returned unexpected payload type status=%s payload_type=%s",
370
+ "Locations fetch returned unexpected payload type "
371
+ "status=%s payload_type=%s",
250
372
  response.status,
251
373
  type(parsed).__name__,
252
374
  )
@@ -303,7 +425,8 @@ class HomelyClient:
303
425
  parsed = await response.json()
304
426
  except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
305
427
  _LOGGER.debug(
306
- "Location data fetch returned invalid JSON status=%s location_id=%s: %s",
428
+ "Location data fetch returned invalid JSON "
429
+ "status=%s location_id=%s: %s",
307
430
  response.status,
308
431
  _log_identifier(location_id),
309
432
  err,
@@ -311,7 +434,8 @@ class HomelyClient:
311
434
  return None, response.status
312
435
  if not isinstance(parsed, dict):
313
436
  _LOGGER.debug(
314
- "Location data fetch returned unexpected payload type status=%s location_id=%s payload_type=%s",
437
+ "Location data fetch returned unexpected payload "
438
+ "type status=%s location_id=%s payload_type=%s",
315
439
  response.status,
316
440
  _log_identifier(location_id),
317
441
  type(parsed).__name__,
@@ -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
 
@@ -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
@@ -45,9 +45,9 @@ class HomelyWebSocket:
45
45
  self.location_id = location_id
46
46
  self.token = token
47
47
  self.on_data_update = on_data_update
48
- self.socket = None
48
+ self.socket: Any | None = None
49
49
  self._is_closing = False
50
- self._reconnect_task: asyncio.Task | None = None
50
+ self._reconnect_task: asyncio.Task[None] | None = None
51
51
  self._reconnect_interval = 300
52
52
  self._reconnect_warn_every = 12
53
53
  self._status_update_callback = status_update_callback
@@ -268,7 +268,7 @@ class HomelyWebSocket:
268
268
  return False
269
269
 
270
270
  try:
271
- import socketio
271
+ import socketio # type: ignore[import-untyped]
272
272
  except ImportError:
273
273
  _LOGGER.error("python-socketio is not installed. WebSocket disabled %s.", self._ctx())
274
274
  self._set_status("Disconnected", "socketio missing")
@@ -293,35 +293,36 @@ class HomelyWebSocket:
293
293
  engineio_logger=False,
294
294
  )
295
295
 
296
- @self.socket.event
297
- async def connect():
296
+ async def connect() -> None:
298
297
  self._on_connect()
299
298
 
300
- @self.socket.event
301
- async def disconnect(*args):
299
+ async def disconnect(*args: Any) -> None:
302
300
  self._on_disconnect(self._build_reason(args[0] if args else None))
303
301
 
304
- @self.socket.event
305
- async def message(data):
302
+ async def message(data: Any) -> None:
306
303
  self._on_event(data)
307
304
 
308
- @self.socket.event
309
- async def event(data):
305
+ async def event(data: Any) -> None:
310
306
  self._on_event(data)
311
307
 
312
- @self.socket.on("*")
313
- async def catch_all(event, data):
308
+ async def catch_all(event: str, data: Any) -> None:
314
309
  if event not in ("connect", "disconnect", "message", "event", "connect_error"):
315
310
  _LOGGER.debug("WebSocket event %s type=%s", self._ctx(), event)
316
311
  self._on_event({"type": event, "payload": data})
317
312
 
318
- @self.socket.event
319
- async def connect_error(data):
313
+ async def connect_error(data: Any) -> None:
320
314
  raw_reason = self._build_reason(data)
321
315
  reason = f"connect_error: {raw_reason}" if raw_reason else "connect_error"
322
316
  _LOGGER.debug("WebSocket connect_error %s: %s", self._ctx(), reason)
323
317
  self._on_disconnect(reason)
324
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
+
325
326
  bearer_token = self._bearer_value(self.token)
326
327
  query = urlencode(
327
328
  {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-homely
3
- Version: 0.1.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 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)! ⭐
@@ -5,6 +5,7 @@ src/homely/__init__.py
5
5
  src/homely/client.py
6
6
  src/homely/exceptions.py
7
7
  src/homely/models.py
8
+ src/homely/py.typed
8
9
  src/homely/websocket.py
9
10
  src/python_homely.egg-info/PKG-INFO
10
11
  src/python_homely.egg-info/SOURCES.txt
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import aiohttp
5
+ import pytest
5
6
 
6
7
  from homely import (
7
8
  HomelyAuthError,
@@ -10,6 +11,7 @@ from homely import (
10
11
  HomelyResponseError,
11
12
  HomelyWebSocket,
12
13
  HomelyWebSocketError,
14
+ TokenEndpointResult,
13
15
  TokenResponse,
14
16
  __version__,
15
17
  auth_header_value,
@@ -87,7 +89,7 @@ class _FakeAsyncCallable:
87
89
  async def test_sdk_exports_public_symbols():
88
90
  """The SDK should expose a clean public surface."""
89
91
  assert auth_header_value("token") == "Bearer token"
90
- assert __version__ == "0.1.1"
92
+ assert __version__ == "0.1.2"
91
93
 
92
94
 
93
95
  async def test_authenticate_returns_typed_token():
@@ -119,6 +121,84 @@ async def test_authenticate_returns_typed_token():
119
121
  )
120
122
 
121
123
 
124
+ async def test_fetch_token_details_returns_typed_result():
125
+ """Detailed token fetches should preserve both token and metadata."""
126
+ client = HomelyClient(
127
+ _FakeSession(
128
+ post_response=_FakeResponse(
129
+ status=200,
130
+ json_data={
131
+ "access_token": "token",
132
+ "refresh_token": "refresh",
133
+ "expires_in": 1800,
134
+ },
135
+ )
136
+ )
137
+ )
138
+
139
+ result = await client.fetch_token_details("user", "pass")
140
+
141
+ assert result == TokenEndpointResult(
142
+ token=TokenResponse(
143
+ access_token="token",
144
+ refresh_token="refresh",
145
+ expires_in=1800,
146
+ raw={
147
+ "access_token": "token",
148
+ "refresh_token": "refresh",
149
+ "expires_in": 1800,
150
+ },
151
+ ),
152
+ status=200,
153
+ )
154
+ assert result.ok is True
155
+ assert result.raw == {
156
+ "access_token": "token",
157
+ "refresh_token": "refresh",
158
+ "expires_in": 1800,
159
+ }
160
+
161
+
162
+ async def test_fetch_token_details_returns_invalid_auth_reason():
163
+ """Detailed token fetches should classify auth failures explicitly."""
164
+ client = HomelyClient(
165
+ _FakeSession(
166
+ post_response=_FakeResponse(status=401, text_data='{"error":"unauthorized"}')
167
+ )
168
+ )
169
+
170
+ result = await client.fetch_token_details("user", "pass")
171
+
172
+ assert result.ok is False
173
+ assert result.reason == "invalid_auth"
174
+ assert result.status == 401
175
+ assert result.body_preview == '{"error":"unauthorized"}'
176
+
177
+
178
+ async def test_fetch_token_details_returns_timeout_reason():
179
+ """Detailed token fetches should preserve timeout classification."""
180
+ client = HomelyClient(_FakeSession(post_exc=TimeoutError()))
181
+
182
+ result = await client.fetch_token_details("user", "pass")
183
+
184
+ assert result.reason == "timeout"
185
+ assert result.detail == "TimeoutError"
186
+
187
+
188
+ async def test_fetch_token_with_reason_remains_backward_compatible():
189
+ """Legacy coarse token reasons should still be preserved."""
190
+ client = HomelyClient(
191
+ _FakeSession(
192
+ post_response=_FakeResponse(status=200, json_exc=ValueError("bad json"))
193
+ )
194
+ )
195
+
196
+ response, reason = await client.fetch_token_with_reason("user", "pass")
197
+
198
+ assert response is None
199
+ assert reason == "cannot_connect"
200
+
201
+
122
202
  async def test_authenticate_raises_auth_error():
123
203
  """Authentication failures should raise HomelyAuthError."""
124
204
  client = HomelyClient(_FakeSession(post_response=_FakeResponse(status=401)))
@@ -131,6 +211,26 @@ async def test_authenticate_raises_auth_error():
131
211
  raise AssertionError("Expected HomelyAuthError")
132
212
 
133
213
 
214
+ async def test_authenticate_raises_response_error_with_body_preview_on_invalid_json():
215
+ """Malformed successful auth payloads should include a short body preview."""
216
+ client = HomelyClient(
217
+ _FakeSession(
218
+ post_response=_FakeResponse(
219
+ status=200,
220
+ text_data="not-json",
221
+ json_exc=ValueError("bad json"),
222
+ )
223
+ )
224
+ )
225
+
226
+ with pytest.raises(HomelyResponseError) as err_info:
227
+ await client.authenticate("user", "pass")
228
+
229
+ assert err_info.value.status == 200
230
+ assert err_info.value.body_preview == "not-json"
231
+ assert err_info.value.body == "not-json"
232
+
233
+
134
234
  async def test_refresh_access_token_raises_connection_error_on_timeout():
135
235
  """Refresh failures should raise HomelyConnectionError."""
136
236
  client = HomelyClient(_FakeSession(post_exc=TimeoutError()))
@@ -155,6 +255,99 @@ async def test_refresh_access_token_raises_auth_error_on_expired_refresh_token()
155
255
  raise AssertionError("Expected HomelyAuthError")
156
256
 
157
257
 
258
+ async def test_fetch_refresh_token_details_returns_http_error_with_preview():
259
+ """Detailed refresh fetches should include status and body previews."""
260
+ client = HomelyClient(
261
+ _FakeSession(post_response=_FakeResponse(status=503, text_data="upstream down"))
262
+ )
263
+
264
+ result = await client.fetch_refresh_token_details("refresh-token")
265
+
266
+ assert result.reason == "http_error"
267
+ assert result.status == 503
268
+ assert result.body_preview == "upstream down"
269
+
270
+
271
+ async def test_fetch_refresh_token_details_returns_invalid_payload_for_non_dict_success():
272
+ """Detailed refresh fetches should reject non-dict success payloads explicitly."""
273
+ client = HomelyClient(
274
+ _FakeSession(post_response=_FakeResponse(status=200, json_data=["bad-payload"]))
275
+ )
276
+
277
+ result = await client.fetch_refresh_token_details("refresh-token")
278
+
279
+ assert result.token is None
280
+ assert result.reason == "invalid_payload"
281
+ assert result.status == 200
282
+ assert result.detail == "payload_type=list"
283
+ assert result.body_preview == "['bad-payload']"
284
+
285
+
286
+ async def test_fetch_refresh_token_wrapper_remains_backward_compatible():
287
+ """The legacy refresh wrapper should still return the raw payload on success."""
288
+ client = HomelyClient(
289
+ _FakeSession(
290
+ post_response=_FakeResponse(
291
+ status=200,
292
+ json_data={
293
+ "access_token": "token",
294
+ "refresh_token": "refresh",
295
+ "expires_in": 120,
296
+ },
297
+ )
298
+ )
299
+ )
300
+
301
+ response = await client.fetch_refresh_token("refresh-token")
302
+
303
+ assert response == {
304
+ "access_token": "token",
305
+ "refresh_token": "refresh",
306
+ "expires_in": 120,
307
+ }
308
+
309
+
310
+ async def test_refresh_access_token_raises_response_error_with_preview():
311
+ """Refresh HTTP failures should surface response metadata for callers."""
312
+ client = HomelyClient(
313
+ _FakeSession(post_response=_FakeResponse(status=503, text_data="upstream down"))
314
+ )
315
+
316
+ with pytest.raises(HomelyResponseError) as err_info:
317
+ await client.refresh_access_token("refresh-token")
318
+
319
+ assert err_info.value.status == 503
320
+ assert err_info.value.body_preview == "upstream down"
321
+ assert err_info.value.body == "upstream down"
322
+
323
+
324
+ async def test_response_error_preserves_body_and_preview_separately():
325
+ """Response errors should keep body and body_preview as separate fields."""
326
+ err = HomelyResponseError(
327
+ "bad response",
328
+ status=502,
329
+ body="full body",
330
+ body_preview="trimmed preview",
331
+ )
332
+
333
+ assert err.status == 502
334
+ assert err.body == "full body"
335
+ assert err.body_preview == "trimmed preview"
336
+
337
+
338
+ async def test_response_error_uses_body_as_preview_when_preview_is_missing():
339
+ """Response errors should keep legacy body-only construction working."""
340
+ err = HomelyResponseError(
341
+ "bad response",
342
+ status=502,
343
+ body="full body",
344
+ )
345
+
346
+ assert err.status == 502
347
+ assert err.body == "full body"
348
+ assert err.body_preview == "full body"
349
+
350
+
158
351
  async def test_get_locations_or_raise_raises_connection_error():
159
352
  """Location lookup failures should raise HomelyConnectionError."""
160
353
  client = HomelyClient(_FakeSession(get_exc=aiohttp.ClientError("boom")))
@@ -198,7 +391,12 @@ async def test_get_home_data_or_raise_raises_response_error():
198
391
  async def test_authenticate_raises_response_error_on_malformed_success_payload():
199
392
  """Malformed successful auth responses should raise HomelyResponseError."""
200
393
  client = HomelyClient(
201
- _FakeSession(post_response=_FakeResponse(status=200, json_data={"refresh_token": "refresh"}))
394
+ _FakeSession(
395
+ post_response=_FakeResponse(
396
+ status=200,
397
+ json_data={"refresh_token": "refresh"},
398
+ )
399
+ )
202
400
  )
203
401
 
204
402
  try:
@@ -211,7 +409,14 @@ async def test_authenticate_raises_response_error_on_malformed_success_payload()
211
409
 
212
410
  async def test_get_locations_or_raise_raises_response_error_on_malformed_success_payload():
213
411
  """Malformed successful location responses should raise HomelyResponseError."""
214
- client = HomelyClient(_FakeSession(get_response=_FakeResponse(status=200, json_data={"bad": "payload"})))
412
+ client = HomelyClient(
413
+ _FakeSession(
414
+ get_response=_FakeResponse(
415
+ status=200,
416
+ json_data={"bad": "payload"},
417
+ )
418
+ )
419
+ )
215
420
 
216
421
  try:
217
422
  await client.get_locations_or_raise("token")
File without changes
File without changes