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.
- {python_homely-0.1.0 → python_homely-0.1.2}/PKG-INFO +33 -3
- {python_homely-0.1.0 → python_homely-0.1.2}/README.md +32 -2
- {python_homely-0.1.0 → python_homely-0.1.2}/pyproject.toml +4 -1
- {python_homely-0.1.0 → python_homely-0.1.2}/src/homely/__init__.py +3 -2
- python_homely-0.1.2/src/homely/client.py +473 -0
- {python_homely-0.1.0 → python_homely-0.1.2}/src/homely/exceptions.py +4 -0
- {python_homely-0.1.0 → python_homely-0.1.2}/src/homely/models.py +35 -1
- {python_homely-0.1.0 → python_homely-0.1.2}/src/homely/websocket.py +40 -25
- {python_homely-0.1.0 → python_homely-0.1.2}/src/python_homely.egg-info/PKG-INFO +33 -3
- {python_homely-0.1.0 → 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.2/tests/test_sdk.py +481 -0
- python_homely-0.1.0/src/homely/client.py +0 -209
- python_homely-0.1.0/tests/test_sdk.py +0 -195
- {python_homely-0.1.0 → python_homely-0.1.2}/LICENSE +0 -0
- {python_homely-0.1.0 → python_homely-0.1.2}/setup.cfg +0 -0
- /python_homely-0.1.0/src/python_homely.egg-info/dependency_links.txt → /python_homely-0.1.2/src/homely/py.typed +0 -0
- {python_homely-0.1.0 → python_homely-0.1.2}/src/python_homely.egg-info/requires.txt +0 -0
- {python_homely-0.1.0 → 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
|
]
|
|
@@ -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
|
|