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.
- {python_homely-0.1.1/src/python_homely.egg-info → python_homely-0.1.2}/PKG-INFO +33 -3
- {python_homely-0.1.1 → python_homely-0.1.2}/README.md +32 -2
- {python_homely-0.1.1 → python_homely-0.1.2}/pyproject.toml +4 -1
- {python_homely-0.1.1 → python_homely-0.1.2}/src/homely/__init__.py +3 -2
- {python_homely-0.1.1 → python_homely-0.1.2}/src/homely/client.py +220 -96
- {python_homely-0.1.1 → python_homely-0.1.2}/src/homely/exceptions.py +4 -0
- {python_homely-0.1.1 → python_homely-0.1.2}/src/homely/models.py +35 -1
- {python_homely-0.1.1 → python_homely-0.1.2}/src/homely/websocket.py +16 -15
- {python_homely-0.1.1 → python_homely-0.1.2/src/python_homely.egg-info}/PKG-INFO +33 -3
- {python_homely-0.1.1 → python_homely-0.1.2}/src/python_homely.egg-info/SOURCES.txt +1 -0
- python_homely-0.1.2/src/python_homely.egg-info/dependency_links.txt +1 -0
- {python_homely-0.1.1 → python_homely-0.1.2}/tests/test_sdk.py +208 -3
- {python_homely-0.1.1 → python_homely-0.1.2}/LICENSE +0 -0
- {python_homely-0.1.1 → python_homely-0.1.2}/setup.cfg +0 -0
- /python_homely-0.1.1/src/python_homely.egg-info/dependency_links.txt → /python_homely-0.1.2/src/homely/py.typed +0 -0
- {python_homely-0.1.1 → python_homely-0.1.2}/src/python_homely.egg-info/requires.txt +0 -0
- {python_homely-0.1.1 → python_homely-0.1.2}/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.2
|
|
4
4
|
Summary: Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere.
|
|
5
5
|
Author: Ludvik Blichfeldt Rød
|
|
6
6
|
License-Expression: MIT
|
|
@@ -102,14 +102,23 @@ async def main() -> None:
|
|
|
102
102
|
|
|
103
103
|
- `authenticate(username, password) -> TokenResponse`
|
|
104
104
|
- `refresh_access_token(refresh_token) -> TokenResponse`
|
|
105
|
+
- `fetch_token_details(username, password) -> TokenEndpointResult`
|
|
106
|
+
- `fetch_refresh_token_details(refresh_token) -> TokenEndpointResult`
|
|
105
107
|
- `get_locations_or_raise(token) -> list[dict]`
|
|
106
108
|
- `get_home_data_or_raise(token, location_id) -> dict`
|
|
107
109
|
- `HomelyWebSocket(...).connect_or_raise()`
|
|
108
110
|
|
|
111
|
+
Legacy compatibility helpers remain available:
|
|
112
|
+
|
|
113
|
+
- `fetch_token_with_reason(username, password) -> tuple[dict | None, str | None]`
|
|
114
|
+
- `fetch_token(username, password) -> dict | None`
|
|
115
|
+
- `fetch_refresh_token(refresh_token) -> dict | None`
|
|
116
|
+
|
|
109
117
|
Main exports:
|
|
110
118
|
|
|
111
119
|
- `HomelyClient`
|
|
112
120
|
- `HomelyWebSocket`
|
|
121
|
+
- `TokenEndpointResult`
|
|
113
122
|
- `TokenResponse`
|
|
114
123
|
- `HomelyConnectionError`
|
|
115
124
|
- `HomelyAuthError`
|
|
@@ -121,12 +130,33 @@ Main exports:
|
|
|
121
130
|
- `HomelyConnectionError`: network or service unavailable
|
|
122
131
|
- `HomelyAuthError`: invalid credentials or rejected token
|
|
123
132
|
- `HomelyResponseError`: unexpected response or HTTP failure
|
|
133
|
+
Carries `status` and `body_preview` when available.
|
|
124
134
|
- `HomelyWebSocketError`: websocket could not be established
|
|
125
135
|
|
|
136
|
+
## Token Diagnostics
|
|
137
|
+
|
|
138
|
+
If you need more than success/failure, use the detailed token helpers:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
result = await client.fetch_refresh_token_details(refresh_token)
|
|
142
|
+
if result.ok:
|
|
143
|
+
print(result.token.access_token)
|
|
144
|
+
else:
|
|
145
|
+
print(result.reason, result.status, result.detail, result.body_preview)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`TokenEndpointResult.reason` can distinguish between invalid credentials, invalid refresh
|
|
149
|
+
tokens, network errors, timeouts, malformed JSON, malformed payloads, empty responses, and
|
|
150
|
+
unexpected HTTP failures.
|
|
151
|
+
|
|
152
|
+
## Websocket Note
|
|
153
|
+
|
|
154
|
+
Refreshing an access token does not require forcing an already-connected websocket to reconnect.
|
|
155
|
+
The websocket token only matters when a new websocket connection is established or re-established.
|
|
156
|
+
|
|
126
157
|
## License
|
|
127
158
|
|
|
128
159
|
MIT. See [LICENSE](LICENSE).
|
|
129
160
|
|
|
130
161
|
|
|
131
|
-
⭐ If you find this
|
|
132
|
-
|
|
162
|
+
⭐ If you find this package useful, please consider giving it a star on [GitHub](https://github.com/ludvikroed/python-homely)! ⭐
|
|
@@ -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.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.
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
297
|
-
async def connect():
|
|
296
|
+
async def connect() -> None:
|
|
298
297
|
self._on_connect()
|
|
299
298
|
|
|
300
|
-
|
|
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
|
-
|
|
305
|
-
async def message(data):
|
|
302
|
+
async def message(data: Any) -> None:
|
|
306
303
|
self._on_event(data)
|
|
307
304
|
|
|
308
|
-
|
|
309
|
-
async def event(data):
|
|
305
|
+
async def event(data: Any) -> None:
|
|
310
306
|
self._on_event(data)
|
|
311
307
|
|
|
312
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere.
|
|
5
5
|
Author: Ludvik Blichfeldt Rød
|
|
6
6
|
License-Expression: MIT
|
|
@@ -102,14 +102,23 @@ async def main() -> None:
|
|
|
102
102
|
|
|
103
103
|
- `authenticate(username, password) -> TokenResponse`
|
|
104
104
|
- `refresh_access_token(refresh_token) -> TokenResponse`
|
|
105
|
+
- `fetch_token_details(username, password) -> TokenEndpointResult`
|
|
106
|
+
- `fetch_refresh_token_details(refresh_token) -> TokenEndpointResult`
|
|
105
107
|
- `get_locations_or_raise(token) -> list[dict]`
|
|
106
108
|
- `get_home_data_or_raise(token, location_id) -> dict`
|
|
107
109
|
- `HomelyWebSocket(...).connect_or_raise()`
|
|
108
110
|
|
|
111
|
+
Legacy compatibility helpers remain available:
|
|
112
|
+
|
|
113
|
+
- `fetch_token_with_reason(username, password) -> tuple[dict | None, str | None]`
|
|
114
|
+
- `fetch_token(username, password) -> dict | None`
|
|
115
|
+
- `fetch_refresh_token(refresh_token) -> dict | None`
|
|
116
|
+
|
|
109
117
|
Main exports:
|
|
110
118
|
|
|
111
119
|
- `HomelyClient`
|
|
112
120
|
- `HomelyWebSocket`
|
|
121
|
+
- `TokenEndpointResult`
|
|
113
122
|
- `TokenResponse`
|
|
114
123
|
- `HomelyConnectionError`
|
|
115
124
|
- `HomelyAuthError`
|
|
@@ -121,12 +130,33 @@ Main exports:
|
|
|
121
130
|
- `HomelyConnectionError`: network or service unavailable
|
|
122
131
|
- `HomelyAuthError`: invalid credentials or rejected token
|
|
123
132
|
- `HomelyResponseError`: unexpected response or HTTP failure
|
|
133
|
+
Carries `status` and `body_preview` when available.
|
|
124
134
|
- `HomelyWebSocketError`: websocket could not be established
|
|
125
135
|
|
|
136
|
+
## Token Diagnostics
|
|
137
|
+
|
|
138
|
+
If you need more than success/failure, use the detailed token helpers:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
result = await client.fetch_refresh_token_details(refresh_token)
|
|
142
|
+
if result.ok:
|
|
143
|
+
print(result.token.access_token)
|
|
144
|
+
else:
|
|
145
|
+
print(result.reason, result.status, result.detail, result.body_preview)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`TokenEndpointResult.reason` can distinguish between invalid credentials, invalid refresh
|
|
149
|
+
tokens, network errors, timeouts, malformed JSON, malformed payloads, empty responses, and
|
|
150
|
+
unexpected HTTP failures.
|
|
151
|
+
|
|
152
|
+
## Websocket Note
|
|
153
|
+
|
|
154
|
+
Refreshing an access token does not require forcing an already-connected websocket to reconnect.
|
|
155
|
+
The websocket token only matters when a new websocket connection is established or re-established.
|
|
156
|
+
|
|
126
157
|
## License
|
|
127
158
|
|
|
128
159
|
MIT. See [LICENSE](LICENSE).
|
|
129
160
|
|
|
130
161
|
|
|
131
|
-
⭐ If you find this
|
|
132
|
-
|
|
162
|
+
⭐ If you find this package useful, please consider giving it a star on [GitHub](https://github.com/ludvikroed/python-homely)! ⭐
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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.
|
|
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(
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|