python-homely 0.1.0__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.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)! ⭐
@@ -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.0"
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.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
  ]
@@ -0,0 +1,473 @@
1
+ """Async Homely API client."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Any, NoReturn
6
+
7
+ import aiohttp
8
+
9
+ from .exceptions import (
10
+ HomelyAuthError,
11
+ HomelyConnectionError,
12
+ HomelyResponseError,
13
+ )
14
+ from .models import TokenEndpointResult, TokenFailureReason, TokenResponse
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+ BASE_URL = "https://sdk.iotiliti.cloud/homely/"
19
+ REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=20)
20
+
21
+
22
+ def _log_identifier(value: str | int | None) -> str | None:
23
+ """Return a shortened identifier suitable for debug logs."""
24
+ if value is None:
25
+ return None
26
+
27
+ text = str(value)
28
+ if len(text) <= 8:
29
+ return text
30
+ return f"{text[:8]}..."
31
+
32
+
33
+ def auth_header_value(token: str | None) -> str:
34
+ """Return normalized Authorization header value."""
35
+ normalized = (token or "").strip()
36
+ if normalized.lower().startswith("bearer "):
37
+ return normalized
38
+ return f"Bearer {normalized}"
39
+
40
+
41
+ def _response_preview(payload: Any) -> str:
42
+ """Return a short safe preview of a response payload for exceptions."""
43
+ text = repr(payload)
44
+ if len(text) <= 200:
45
+ return text
46
+ return f"{text[:200]}..."
47
+
48
+
49
+ 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
+
79
+ class HomelyClient:
80
+ """Small reusable async client for the Homely cloud API."""
81
+
82
+ def __init__(
83
+ self,
84
+ session: aiohttp.ClientSession,
85
+ *,
86
+ base_url: str = BASE_URL,
87
+ timeout: aiohttp.ClientTimeout = REQUEST_TIMEOUT,
88
+ ) -> None:
89
+ """Initialize the client with a caller-managed aiohttp session."""
90
+ self._session = session
91
+ self._base_url = base_url
92
+ self._timeout = timeout
93
+
94
+ @property
95
+ def base_url(self) -> str:
96
+ """Return the configured API base URL."""
97
+ return self._base_url
98
+
99
+ @property
100
+ def timeout(self) -> aiohttp.ClientTimeout:
101
+ """Return the configured request timeout."""
102
+ return self._timeout
103
+
104
+ async def authenticate(
105
+ self,
106
+ username: str,
107
+ password: str,
108
+ ) -> TokenResponse:
109
+ """Authenticate and return a typed token response.
110
+
111
+ Raises a typed SDK exception on failure.
112
+ """
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
+ )
124
+
125
+ async def _fetch_token_payload(
126
+ self,
127
+ username: str,
128
+ password: str,
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."""
140
+ payload = {
141
+ "username": username,
142
+ "password": password,
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
+ )
150
+
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)
161
+
162
+ async def fetch_token(
163
+ self,
164
+ username: str,
165
+ password: str,
166
+ ) -> dict[str, Any] | None:
167
+ """Fetch access token from API."""
168
+ result = await self.fetch_token_details(username, password)
169
+ return result.raw
170
+
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."""
184
+ payload = {
185
+ "refresh_token": refresh_token,
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}"
223
+
224
+ try:
225
+ async with self._session.post(url, json=payload, timeout=self._timeout) as response:
226
+ body_preview = await self._response_text_preview(response)
227
+ if response.status in (200, 201):
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)
317
+ return None
318
+
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
+ )
347
+
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."""
353
+ url = f"{self._base_url}locations"
354
+ headers = {"Authorization": auth_header_value(token)}
355
+
356
+ try:
357
+ async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
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
376
+ _LOGGER.debug("Locations fetch successful")
377
+ return parsed, response.status
378
+ _LOGGER.debug("Locations fetch failed with status=%s", response.status)
379
+ return None, response.status
380
+ except (aiohttp.ClientError, TimeoutError) as err:
381
+ _LOGGER.debug("Locations fetch network error: %s", err)
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
388
+
389
+ async def get_locations_or_raise(self, token: str) -> list[dict[str, Any]]:
390
+ """Get locations from API or raise a typed exception."""
391
+ locations, status = await self._get_locations_payload(token)
392
+ if locations is not None:
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
+ )
401
+ raise HomelyConnectionError("Could not fetch Homely locations")
402
+
403
+ async def get_home_data(
404
+ self,
405
+ token: str,
406
+ location_id: str | int,
407
+ ) -> dict[str, Any] | None:
408
+ """Get location data from API."""
409
+ data, _status = await self.get_home_data_with_status(token, location_id)
410
+ return data
411
+
412
+ async def get_home_data_with_status(
413
+ self,
414
+ token: str,
415
+ location_id: str | int,
416
+ ) -> tuple[dict[str, Any] | None, int | None]:
417
+ """Get location data from API and include HTTP status when available."""
418
+ url = f"{self._base_url}home/{location_id}"
419
+ headers = {"Authorization": auth_header_value(token)}
420
+
421
+ try:
422
+ async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
423
+ if response.status == 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
445
+ _LOGGER.debug(
446
+ "Location data fetch failed with status=%s location_id=%s",
447
+ response.status,
448
+ _log_identifier(location_id),
449
+ )
450
+ return None, response.status
451
+ except (aiohttp.ClientError, TimeoutError) as err:
452
+ _LOGGER.debug(
453
+ "Location data fetch network error location_id=%s: %s",
454
+ _log_identifier(location_id),
455
+ err,
456
+ )
457
+ return None, None
458
+
459
+ async def get_home_data_or_raise(
460
+ self,
461
+ token: str,
462
+ location_id: str | int,
463
+ ) -> dict[str, Any]:
464
+ """Get location data from API or raise a typed exception."""
465
+ data, status = await self.get_home_data_with_status(token, location_id)
466
+ if data is not None:
467
+ return data
468
+ if status in (401, 403):
469
+ raise HomelyAuthError("Homely rejected the supplied access token")
470
+ raise HomelyResponseError(
471
+ "Could not fetch Homely location data",
472
+ status=status,
473
+ )
@@ -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