python-homely 0.1.1__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_homely-0.1.1/src/python_homely.egg-info → python_homely-0.1.3}/PKG-INFO +33 -3
- {python_homely-0.1.1 → python_homely-0.1.3}/README.md +32 -2
- {python_homely-0.1.1 → python_homely-0.1.3}/pyproject.toml +4 -1
- {python_homely-0.1.1 → python_homely-0.1.3}/src/homely/__init__.py +12 -3
- {python_homely-0.1.1 → python_homely-0.1.3}/src/homely/client.py +220 -96
- {python_homely-0.1.1 → python_homely-0.1.3}/src/homely/exceptions.py +4 -0
- {python_homely-0.1.1 → python_homely-0.1.3}/src/homely/models.py +35 -1
- {python_homely-0.1.1 → python_homely-0.1.3}/src/homely/websocket.py +130 -20
- {python_homely-0.1.1 → python_homely-0.1.3/src/python_homely.egg-info}/PKG-INFO +33 -3
- {python_homely-0.1.1 → python_homely-0.1.3}/src/python_homely.egg-info/SOURCES.txt +1 -0
- python_homely-0.1.3/src/python_homely.egg-info/dependency_links.txt +1 -0
- python_homely-0.1.3/tests/test_sdk.py +541 -0
- python_homely-0.1.1/tests/test_sdk.py +0 -276
- {python_homely-0.1.1 → python_homely-0.1.3}/LICENSE +0 -0
- {python_homely-0.1.1 → python_homely-0.1.3}/setup.cfg +0 -0
- /python_homely-0.1.1/src/python_homely.egg-info/dependency_links.txt → /python_homely-0.1.3/src/homely/py.typed +0 -0
- {python_homely-0.1.1 → python_homely-0.1.3}/src/python_homely.egg-info/requires.txt +0 -0
- {python_homely-0.1.1 → python_homely-0.1.3}/src/python_homely.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-homely
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere.
|
|
5
5
|
Author: Ludvik Blichfeldt Rød
|
|
6
6
|
License-Expression: MIT
|
|
@@ -102,14 +102,23 @@ async def main() -> None:
|
|
|
102
102
|
|
|
103
103
|
- `authenticate(username, password) -> TokenResponse`
|
|
104
104
|
- `refresh_access_token(refresh_token) -> TokenResponse`
|
|
105
|
+
- `fetch_token_details(username, password) -> TokenEndpointResult`
|
|
106
|
+
- `fetch_refresh_token_details(refresh_token) -> TokenEndpointResult`
|
|
105
107
|
- `get_locations_or_raise(token) -> list[dict]`
|
|
106
108
|
- `get_home_data_or_raise(token, location_id) -> dict`
|
|
107
109
|
- `HomelyWebSocket(...).connect_or_raise()`
|
|
108
110
|
|
|
111
|
+
Legacy compatibility helpers remain available:
|
|
112
|
+
|
|
113
|
+
- `fetch_token_with_reason(username, password) -> tuple[dict | None, str | None]`
|
|
114
|
+
- `fetch_token(username, password) -> dict | None`
|
|
115
|
+
- `fetch_refresh_token(refresh_token) -> dict | None`
|
|
116
|
+
|
|
109
117
|
Main exports:
|
|
110
118
|
|
|
111
119
|
- `HomelyClient`
|
|
112
120
|
- `HomelyWebSocket`
|
|
121
|
+
- `TokenEndpointResult`
|
|
113
122
|
- `TokenResponse`
|
|
114
123
|
- `HomelyConnectionError`
|
|
115
124
|
- `HomelyAuthError`
|
|
@@ -121,12 +130,33 @@ Main exports:
|
|
|
121
130
|
- `HomelyConnectionError`: network or service unavailable
|
|
122
131
|
- `HomelyAuthError`: invalid credentials or rejected token
|
|
123
132
|
- `HomelyResponseError`: unexpected response or HTTP failure
|
|
133
|
+
Carries `status` and `body_preview` when available.
|
|
124
134
|
- `HomelyWebSocketError`: websocket could not be established
|
|
125
135
|
|
|
136
|
+
## Token Diagnostics
|
|
137
|
+
|
|
138
|
+
If you need more than success/failure, use the detailed token helpers:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
result = await client.fetch_refresh_token_details(refresh_token)
|
|
142
|
+
if result.ok:
|
|
143
|
+
print(result.token.access_token)
|
|
144
|
+
else:
|
|
145
|
+
print(result.reason, result.status, result.detail, result.body_preview)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`TokenEndpointResult.reason` can distinguish between invalid credentials, invalid refresh
|
|
149
|
+
tokens, network errors, timeouts, malformed JSON, malformed payloads, empty responses, and
|
|
150
|
+
unexpected HTTP failures.
|
|
151
|
+
|
|
152
|
+
## Websocket Note
|
|
153
|
+
|
|
154
|
+
Refreshing an access token does not require forcing an already-connected websocket to reconnect.
|
|
155
|
+
The websocket token only matters when a new websocket connection is established or re-established.
|
|
156
|
+
|
|
126
157
|
## License
|
|
127
158
|
|
|
128
159
|
MIT. See [LICENSE](LICENSE).
|
|
129
160
|
|
|
130
161
|
|
|
131
|
-
⭐ If you find this
|
|
132
|
-
|
|
162
|
+
⭐ If you find this package useful, please consider giving it a star on [GitHub](https://github.com/ludvikroed/python-homely)! ⭐
|
|
@@ -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
|
|
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.
|
|
7
|
+
version = "0.1.3"
|
|
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.
|
|
3
|
+
__version__ = "0.1.3"
|
|
4
4
|
|
|
5
5
|
from .client import (
|
|
6
6
|
BASE_URL,
|
|
@@ -15,8 +15,13 @@ from .exceptions import (
|
|
|
15
15
|
HomelyResponseError,
|
|
16
16
|
HomelyWebSocketError,
|
|
17
17
|
)
|
|
18
|
-
from .models import TokenResponse
|
|
19
|
-
from .websocket import
|
|
18
|
+
from .models import TokenEndpointResult, TokenResponse
|
|
19
|
+
from .websocket import (
|
|
20
|
+
WEBSOCKET_STATUS_OPTIONS,
|
|
21
|
+
HomelyWebSocket,
|
|
22
|
+
WebSocketConnectionState,
|
|
23
|
+
normalize_websocket_status,
|
|
24
|
+
)
|
|
20
25
|
|
|
21
26
|
__all__ = [
|
|
22
27
|
"__version__",
|
|
@@ -24,11 +29,15 @@ __all__ = [
|
|
|
24
29
|
"REQUEST_TIMEOUT",
|
|
25
30
|
"HomelyClient",
|
|
26
31
|
"HomelyWebSocket",
|
|
32
|
+
"WebSocketConnectionState",
|
|
27
33
|
"HomelyError",
|
|
28
34
|
"HomelyConnectionError",
|
|
29
35
|
"HomelyAuthError",
|
|
30
36
|
"HomelyResponseError",
|
|
31
37
|
"HomelyWebSocketError",
|
|
38
|
+
"TokenEndpointResult",
|
|
32
39
|
"TokenResponse",
|
|
40
|
+
"WEBSOCKET_STATUS_OPTIONS",
|
|
33
41
|
"auth_header_value",
|
|
42
|
+
"normalize_websocket_status",
|
|
34
43
|
]
|
|
@@ -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
|
-
|
|
84
|
-
if
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
149
|
-
if
|
|
150
|
-
return
|
|
151
|
-
|
|
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
|
-
|
|
162
|
-
return
|
|
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
|
-
|
|
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
|
-
"
|
|
233
|
+
"%s returned invalid JSON status=%s: %s",
|
|
234
|
+
action,
|
|
182
235
|
response.status,
|
|
183
|
-
|
|
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
|
-
"
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|