python-homely 0.1.0__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-homely
3
- Version: 0.1.0
3
+ Version: 0.1.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-homely"
7
- version = "0.1.0"
7
+ version = "0.1.1"
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"
@@ -1,6 +1,6 @@
1
1
  """Reusable Homely client package extracted from the integration."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
4
4
 
5
5
  from .client import (
6
6
  BASE_URL,
@@ -0,0 +1,349 @@
1
+ """Async Homely API client."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Any
6
+
7
+ import aiohttp
8
+
9
+ from .exceptions import (
10
+ HomelyAuthError,
11
+ HomelyConnectionError,
12
+ HomelyResponseError,
13
+ )
14
+ from .models import 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
+ class HomelyClient:
50
+ """Small reusable async client for the Homely cloud API."""
51
+
52
+ def __init__(
53
+ self,
54
+ session: aiohttp.ClientSession,
55
+ *,
56
+ base_url: str = BASE_URL,
57
+ timeout: aiohttp.ClientTimeout = REQUEST_TIMEOUT,
58
+ ) -> None:
59
+ """Initialize the client with a caller-managed aiohttp session."""
60
+ self._session = session
61
+ self._base_url = base_url
62
+ self._timeout = timeout
63
+
64
+ @property
65
+ def base_url(self) -> str:
66
+ """Return the configured API base URL."""
67
+ return self._base_url
68
+
69
+ @property
70
+ def timeout(self) -> aiohttp.ClientTimeout:
71
+ """Return the configured request timeout."""
72
+ return self._timeout
73
+
74
+ async def authenticate(
75
+ self,
76
+ username: str,
77
+ password: str,
78
+ ) -> TokenResponse:
79
+ """Authenticate and return a typed token response.
80
+
81
+ Raises a typed SDK exception on failure.
82
+ """
83
+ response, status = await self._fetch_token_payload(username, password)
84
+ if response is not None:
85
+ try:
86
+ return TokenResponse.from_dict(response)
87
+ except (KeyError, TypeError, ValueError) as err:
88
+ raise HomelyResponseError(
89
+ "Homely authentication response missing required fields",
90
+ status=status,
91
+ body=_response_preview(response),
92
+ ) from err
93
+ if status in (400, 401, 403):
94
+ raise HomelyAuthError("Invalid Homely username or password")
95
+ if status in (200, 201):
96
+ raise HomelyResponseError(
97
+ "Homely authentication response could not be parsed",
98
+ status=status,
99
+ )
100
+ raise HomelyConnectionError("Could not connect to Homely")
101
+
102
+ async def _fetch_token_payload(
103
+ self,
104
+ username: str,
105
+ password: str,
106
+ ) -> tuple[dict[str, Any] | None, int | None]:
107
+ """Fetch access token payload and include HTTP status when available."""
108
+ url = f"{self._base_url}oauth/token"
109
+ payload = {
110
+ "username": username,
111
+ "password": password,
112
+ }
113
+
114
+ try:
115
+ async with self._session.post(url, json=payload, timeout=self._timeout) as response:
116
+ if response.status in (200, 201):
117
+ try:
118
+ parsed = await response.json()
119
+ except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
120
+ _LOGGER.debug(
121
+ "Token fetch returned invalid JSON status=%s: %s",
122
+ response.status,
123
+ err,
124
+ )
125
+ return None, response.status
126
+ if not isinstance(parsed, dict):
127
+ _LOGGER.debug(
128
+ "Token fetch returned unexpected payload type status=%s payload_type=%s",
129
+ response.status,
130
+ type(parsed).__name__,
131
+ )
132
+ return None, response.status
133
+ _LOGGER.debug("Token fetch successful")
134
+ return parsed, response.status
135
+
136
+ _LOGGER.debug("Token fetch failed with status=%s", response.status)
137
+ return None, response.status
138
+ except (aiohttp.ClientError, TimeoutError) as err:
139
+ _LOGGER.debug("Token fetch network error: %s", err)
140
+ return None, None
141
+
142
+ async def fetch_token_with_reason(
143
+ self,
144
+ username: str,
145
+ password: str,
146
+ ) -> tuple[dict[str, Any] | None, str | None]:
147
+ """Fetch access token and return optional reason key on failure."""
148
+ response, status = await self._fetch_token_payload(username, password)
149
+ if response is not None:
150
+ return response, None
151
+ if status in (400, 401, 403):
152
+ return None, "invalid_auth"
153
+ return None, "cannot_connect"
154
+
155
+ async def fetch_token(
156
+ self,
157
+ username: str,
158
+ password: str,
159
+ ) -> dict[str, Any] | None:
160
+ """Fetch access token from API."""
161
+ response, _reason = await self.fetch_token_with_reason(username, password)
162
+ return response
163
+
164
+ async def _fetch_refresh_token_payload(
165
+ self,
166
+ refresh_token: str,
167
+ ) -> tuple[dict[str, Any] | None, int | None]:
168
+ """Refresh access token payload and include HTTP status when available."""
169
+ url = f"{self._base_url}oauth/refresh-token"
170
+ payload = {
171
+ "refresh_token": refresh_token,
172
+ }
173
+
174
+ try:
175
+ async with self._session.post(url, json=payload, timeout=self._timeout) as response:
176
+ if response.status in (200, 201):
177
+ try:
178
+ parsed = await response.json()
179
+ except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
180
+ _LOGGER.debug(
181
+ "Token refresh returned invalid JSON status=%s: %s",
182
+ response.status,
183
+ err,
184
+ )
185
+ return None, response.status
186
+ if not isinstance(parsed, dict):
187
+ _LOGGER.debug(
188
+ "Token refresh returned unexpected payload type status=%s payload_type=%s",
189
+ response.status,
190
+ type(parsed).__name__,
191
+ )
192
+ return None, response.status
193
+ _LOGGER.debug("Token refresh successful")
194
+ return parsed, response.status
195
+ _LOGGER.debug("Token refresh failed with status=%s", response.status)
196
+ return None, response.status
197
+ except (aiohttp.ClientError, TimeoutError) as err:
198
+ _LOGGER.debug("Token refresh network error: %s", err)
199
+ return None, None
200
+
201
+ async def fetch_refresh_token(self, refresh_token: str) -> dict[str, Any] | None:
202
+ """Refresh access token using refresh token."""
203
+ response, _status = await self._fetch_refresh_token_payload(refresh_token)
204
+ return response
205
+
206
+ async def refresh_access_token(self, refresh_token: str) -> TokenResponse:
207
+ """Refresh access token and return a typed token response."""
208
+ response, status = await self._fetch_refresh_token_payload(refresh_token)
209
+ if response is not None:
210
+ try:
211
+ return TokenResponse.from_dict(response)
212
+ except (KeyError, TypeError, ValueError) as err:
213
+ raise HomelyResponseError(
214
+ "Homely refresh response missing required fields",
215
+ status=status,
216
+ body=_response_preview(response),
217
+ ) from err
218
+ if status in (400, 401, 403):
219
+ raise HomelyAuthError("Homely rejected the supplied refresh token")
220
+ if status in (200, 201):
221
+ raise HomelyResponseError(
222
+ "Homely refresh response could not be parsed",
223
+ status=status,
224
+ )
225
+ raise HomelyConnectionError("Could not refresh Homely access token")
226
+
227
+ async def _get_locations_payload(
228
+ self,
229
+ token: str,
230
+ ) -> tuple[list[dict[str, Any]] | None, int | None]:
231
+ """Get locations payload and include HTTP status when available."""
232
+ url = f"{self._base_url}locations"
233
+ headers = {"Authorization": auth_header_value(token)}
234
+
235
+ try:
236
+ async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
237
+ if response.status == 200:
238
+ try:
239
+ parsed = await response.json()
240
+ except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
241
+ _LOGGER.debug(
242
+ "Locations fetch returned invalid JSON status=%s: %s",
243
+ response.status,
244
+ err,
245
+ )
246
+ return None, response.status
247
+ if not isinstance(parsed, list):
248
+ _LOGGER.debug(
249
+ "Locations fetch returned unexpected payload type status=%s payload_type=%s",
250
+ response.status,
251
+ type(parsed).__name__,
252
+ )
253
+ return None, response.status
254
+ _LOGGER.debug("Locations fetch successful")
255
+ return parsed, response.status
256
+ _LOGGER.debug("Locations fetch failed with status=%s", response.status)
257
+ return None, response.status
258
+ except (aiohttp.ClientError, TimeoutError) as err:
259
+ _LOGGER.debug("Locations fetch network error: %s", err)
260
+ return None, None
261
+
262
+ async def get_locations(self, token: str) -> list[dict[str, Any]] | None:
263
+ """Get locations from API."""
264
+ locations, _status = await self._get_locations_payload(token)
265
+ return locations
266
+
267
+ async def get_locations_or_raise(self, token: str) -> list[dict[str, Any]]:
268
+ """Get locations from API or raise a typed exception."""
269
+ locations, status = await self._get_locations_payload(token)
270
+ if locations is not None:
271
+ return locations
272
+ if status in (401, 403):
273
+ raise HomelyAuthError("Homely rejected the supplied access token")
274
+ if status == 200:
275
+ raise HomelyResponseError(
276
+ "Homely locations response could not be parsed",
277
+ status=status,
278
+ )
279
+ raise HomelyConnectionError("Could not fetch Homely locations")
280
+
281
+ async def get_home_data(
282
+ self,
283
+ token: str,
284
+ location_id: str | int,
285
+ ) -> dict[str, Any] | None:
286
+ """Get location data from API."""
287
+ data, _status = await self.get_home_data_with_status(token, location_id)
288
+ return data
289
+
290
+ async def get_home_data_with_status(
291
+ self,
292
+ token: str,
293
+ location_id: str | int,
294
+ ) -> tuple[dict[str, Any] | None, int | None]:
295
+ """Get location data from API and include HTTP status when available."""
296
+ url = f"{self._base_url}home/{location_id}"
297
+ headers = {"Authorization": auth_header_value(token)}
298
+
299
+ try:
300
+ async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
301
+ if response.status == 200:
302
+ try:
303
+ parsed = await response.json()
304
+ except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
305
+ _LOGGER.debug(
306
+ "Location data fetch returned invalid JSON status=%s location_id=%s: %s",
307
+ response.status,
308
+ _log_identifier(location_id),
309
+ err,
310
+ )
311
+ return None, response.status
312
+ if not isinstance(parsed, dict):
313
+ _LOGGER.debug(
314
+ "Location data fetch returned unexpected payload type status=%s location_id=%s payload_type=%s",
315
+ response.status,
316
+ _log_identifier(location_id),
317
+ type(parsed).__name__,
318
+ )
319
+ return None, response.status
320
+ return parsed, response.status
321
+ _LOGGER.debug(
322
+ "Location data fetch failed with status=%s location_id=%s",
323
+ response.status,
324
+ _log_identifier(location_id),
325
+ )
326
+ return None, response.status
327
+ except (aiohttp.ClientError, TimeoutError) as err:
328
+ _LOGGER.debug(
329
+ "Location data fetch network error location_id=%s: %s",
330
+ _log_identifier(location_id),
331
+ err,
332
+ )
333
+ return None, None
334
+
335
+ async def get_home_data_or_raise(
336
+ self,
337
+ token: str,
338
+ location_id: str | int,
339
+ ) -> dict[str, Any]:
340
+ """Get location data from API or raise a typed exception."""
341
+ data, status = await self.get_home_data_with_status(token, location_id)
342
+ if data is not None:
343
+ return data
344
+ if status in (401, 403):
345
+ raise HomelyAuthError("Homely rejected the supplied access token")
346
+ raise HomelyResponseError(
347
+ "Could not fetch Homely location data",
348
+ status=status,
349
+ )
@@ -14,6 +14,17 @@ from .exceptions import HomelyWebSocketError
14
14
  _LOGGER = logging.getLogger(__name__)
15
15
 
16
16
 
17
+ def _log_identifier(value: str | int | None) -> str | None:
18
+ """Return a shortened identifier suitable for logs."""
19
+ if value is None:
20
+ return None
21
+
22
+ text = str(value)
23
+ if len(text) <= 8:
24
+ return text
25
+ return f"{text[:8]}..."
26
+
27
+
17
28
  class HomelyWebSocket:
18
29
  """WebSocket client for Homely using Socket.IO."""
19
30
 
@@ -45,9 +56,12 @@ class HomelyWebSocket:
45
56
 
46
57
  def _ctx(self, device_id: str | None = None) -> str:
47
58
  """Build consistent log context."""
48
- base = f"context_id={self.context_id} location_id={self.location_id}"
59
+ base = (
60
+ f"context_id={_log_identifier(self.context_id)} "
61
+ f"location_id={_log_identifier(self.location_id)}"
62
+ )
49
63
  if device_id:
50
- return f"{base} device_id={device_id}"
64
+ return f"{base} device_id={_log_identifier(device_id)}"
51
65
  return base
52
66
 
53
67
  @property
@@ -82,14 +96,14 @@ class HomelyWebSocket:
82
96
 
83
97
  if status_changed:
84
98
  if status == "Connected":
85
- _LOGGER.info("WebSocket connected %s", self._ctx())
99
+ _LOGGER.debug("WebSocket connected %s", self._ctx())
86
100
  elif status == "Disconnected":
87
101
  if reason and self._should_warn_disconnect(reason):
88
- _LOGGER.warning("WebSocket disconnected %s (%s)", self._ctx(), reason)
102
+ _LOGGER.info("WebSocket disconnected %s (%s)", self._ctx(), reason)
89
103
  elif reason:
90
104
  _LOGGER.debug("WebSocket disconnected %s (%s)", self._ctx(), reason)
91
105
  else:
92
- _LOGGER.warning("WebSocket disconnected %s", self._ctx())
106
+ _LOGGER.info("WebSocket disconnected %s", self._ctx())
93
107
  else:
94
108
  if reason:
95
109
  _LOGGER.debug(
@@ -198,14 +212,14 @@ class HomelyWebSocket:
198
212
 
199
213
  self._reconnect_task = loop.create_task(self._reconnect_loop())
200
214
  if reason:
201
- _LOGGER.info(
215
+ _LOGGER.debug(
202
216
  "Started reconnect loop %s (%s). interval=%ss, retries=infinite",
203
217
  self._ctx(),
204
218
  reason,
205
219
  self._reconnect_interval,
206
220
  )
207
221
  else:
208
- _LOGGER.info(
222
+ _LOGGER.debug(
209
223
  "Started reconnect loop %s. interval=%ss, retries=infinite",
210
224
  self._ctx(),
211
225
  self._reconnect_interval,
@@ -228,11 +242,11 @@ class HomelyWebSocket:
228
242
  _LOGGER.debug("WebSocket reconnect attempt %s started %s", attempt, self._ctx())
229
243
  success = await self.connect(from_reconnect_loop=True)
230
244
  if success:
231
- _LOGGER.info("WebSocket reconnect attempt %s succeeded %s", attempt, self._ctx())
245
+ _LOGGER.debug("WebSocket reconnect attempt %s succeeded %s", attempt, self._ctx())
232
246
  return
233
247
 
234
- if attempt == 1 or attempt % self._reconnect_warn_every == 0:
235
- _LOGGER.warning(
248
+ if attempt % self._reconnect_warn_every == 0:
249
+ _LOGGER.info(
236
250
  "WebSocket reconnect attempt %s failed %s. Retrying in %s seconds",
237
251
  attempt,
238
252
  self._ctx(),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-homely
3
- Version: 0.1.0
3
+ Version: 0.1.1
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
@@ -19,10 +19,18 @@ from homely import (
19
19
  class _FakeResponse:
20
20
  """Simple async HTTP response stub."""
21
21
 
22
- def __init__(self, *, status: int, json_data=None, text_data: str = "") -> None:
22
+ def __init__(
23
+ self,
24
+ *,
25
+ status: int,
26
+ json_data=None,
27
+ text_data: str = "",
28
+ json_exc: Exception | None = None,
29
+ ) -> None:
23
30
  self.status = status
24
31
  self._json_data = json_data
25
32
  self._text_data = text_data
33
+ self._json_exc = json_exc
26
34
 
27
35
  async def __aenter__(self):
28
36
  return self
@@ -31,6 +39,8 @@ class _FakeResponse:
31
39
  return False
32
40
 
33
41
  async def json(self):
42
+ if self._json_exc is not None:
43
+ raise self._json_exc
34
44
  return self._json_data
35
45
 
36
46
  async def text(self):
@@ -77,7 +87,7 @@ class _FakeAsyncCallable:
77
87
  async def test_sdk_exports_public_symbols():
78
88
  """The SDK should expose a clean public surface."""
79
89
  assert auth_header_value("token") == "Bearer token"
80
- assert __version__ == "0.1.0"
90
+ assert __version__ == "0.1.1"
81
91
 
82
92
 
83
93
  async def test_authenticate_returns_typed_token():
@@ -133,6 +143,18 @@ async def test_refresh_access_token_raises_connection_error_on_timeout():
133
143
  raise AssertionError("Expected HomelyConnectionError")
134
144
 
135
145
 
146
+ async def test_refresh_access_token_raises_auth_error_on_expired_refresh_token():
147
+ """Refresh auth failures should surface as HomelyAuthError."""
148
+ client = HomelyClient(_FakeSession(post_response=_FakeResponse(status=400)))
149
+
150
+ try:
151
+ await client.refresh_access_token("refresh-token")
152
+ except HomelyAuthError:
153
+ pass
154
+ else:
155
+ raise AssertionError("Expected HomelyAuthError")
156
+
157
+
136
158
  async def test_get_locations_or_raise_raises_connection_error():
137
159
  """Location lookup failures should raise HomelyConnectionError."""
138
160
  client = HomelyClient(_FakeSession(get_exc=aiohttp.ClientError("boom")))
@@ -145,6 +167,18 @@ async def test_get_locations_or_raise_raises_connection_error():
145
167
  raise AssertionError("Expected HomelyConnectionError")
146
168
 
147
169
 
170
+ async def test_get_locations_or_raise_raises_auth_error_on_unauthorized():
171
+ """Unauthorized location lookups should raise HomelyAuthError."""
172
+ client = HomelyClient(_FakeSession(get_response=_FakeResponse(status=401)))
173
+
174
+ try:
175
+ await client.get_locations_or_raise("token")
176
+ except HomelyAuthError:
177
+ pass
178
+ else:
179
+ raise AssertionError("Expected HomelyAuthError")
180
+
181
+
148
182
  async def test_get_home_data_or_raise_raises_response_error():
149
183
  """Unexpected data fetch failures should carry response metadata."""
150
184
  client = HomelyClient(
@@ -161,20 +195,67 @@ async def test_get_home_data_or_raise_raises_response_error():
161
195
  raise AssertionError("Expected HomelyResponseError")
162
196
 
163
197
 
198
+ async def test_authenticate_raises_response_error_on_malformed_success_payload():
199
+ """Malformed successful auth responses should raise HomelyResponseError."""
200
+ client = HomelyClient(
201
+ _FakeSession(post_response=_FakeResponse(status=200, json_data={"refresh_token": "refresh"}))
202
+ )
203
+
204
+ try:
205
+ await client.authenticate("user", "pass")
206
+ except HomelyResponseError as err:
207
+ assert err.status == 200
208
+ else:
209
+ raise AssertionError("Expected HomelyResponseError")
210
+
211
+
212
+ async def test_get_locations_or_raise_raises_response_error_on_malformed_success_payload():
213
+ """Malformed successful location responses should raise HomelyResponseError."""
214
+ client = HomelyClient(_FakeSession(get_response=_FakeResponse(status=200, json_data={"bad": "payload"})))
215
+
216
+ try:
217
+ await client.get_locations_or_raise("token")
218
+ except HomelyResponseError as err:
219
+ assert err.status == 200
220
+ else:
221
+ raise AssertionError("Expected HomelyResponseError")
222
+
223
+
224
+ async def test_get_home_data_or_raise_raises_response_error_on_invalid_json():
225
+ """Malformed successful location payloads should raise HomelyResponseError."""
226
+ client = HomelyClient(
227
+ _FakeSession(
228
+ get_response=_FakeResponse(status=200, json_exc=ValueError("bad json"))
229
+ )
230
+ )
231
+
232
+ try:
233
+ await client.get_home_data_or_raise("token", "loc-1")
234
+ except HomelyResponseError as err:
235
+ assert err.status == 200
236
+ else:
237
+ raise AssertionError("Expected HomelyResponseError")
238
+
239
+
164
240
  async def test_websocket_public_aliases_cover_package_api():
165
241
  """The websocket client should expose package-friendly aliases."""
166
242
  ws = HomelyWebSocket(
167
- location_id="loc-1",
243
+ location_id="11111111-2222-3333-4444-555555555555",
168
244
  token="token",
169
245
  on_data_update=lambda _data: None,
170
- context_id="ctx-1",
246
+ context_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
171
247
  )
172
248
 
173
- assert ws.context_id == "ctx-1"
174
- assert ws.entry_id == "ctx-1"
249
+ assert ws.context_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
250
+ assert ws.entry_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
175
251
 
176
252
  ws.set_token("new-token")
177
253
  assert ws.token == "new-token"
254
+ assert ws._ctx("99999999-8888-7777-6666-555555555555") == (
255
+ "context_id=aaaaaaaa... "
256
+ "location_id=11111111... "
257
+ "device_id=99999999..."
258
+ )
178
259
 
179
260
 
180
261
  async def test_websocket_connect_or_raise_uses_typed_exception():
@@ -1,209 +0,0 @@
1
- """Async Homely API client."""
2
- from __future__ import annotations
3
-
4
- import logging
5
- from typing import Any
6
-
7
- import aiohttp
8
-
9
- from .exceptions import (
10
- HomelyAuthError,
11
- HomelyConnectionError,
12
- HomelyResponseError,
13
- )
14
- from .models import 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 auth_header_value(token: str | None) -> str:
23
- """Return normalized Authorization header value."""
24
- normalized = (token or "").strip()
25
- if normalized.lower().startswith("bearer "):
26
- return normalized
27
- return f"Bearer {normalized}"
28
-
29
-
30
- class HomelyClient:
31
- """Small reusable async client for the Homely cloud API."""
32
-
33
- def __init__(
34
- self,
35
- session: aiohttp.ClientSession,
36
- *,
37
- base_url: str = BASE_URL,
38
- timeout: aiohttp.ClientTimeout = REQUEST_TIMEOUT,
39
- ) -> None:
40
- """Initialize the client with a caller-managed aiohttp session."""
41
- self._session = session
42
- self._base_url = base_url
43
- self._timeout = timeout
44
-
45
- @property
46
- def base_url(self) -> str:
47
- """Return the configured API base URL."""
48
- return self._base_url
49
-
50
- @property
51
- def timeout(self) -> aiohttp.ClientTimeout:
52
- """Return the configured request timeout."""
53
- return self._timeout
54
-
55
- async def authenticate(
56
- self,
57
- username: str,
58
- password: str,
59
- ) -> TokenResponse:
60
- """Authenticate and return a typed token response.
61
-
62
- Raises a typed SDK exception on failure.
63
- """
64
- response, reason = await self.fetch_token_with_reason(username, password)
65
- if response:
66
- return TokenResponse.from_dict(response)
67
- if reason == "invalid_auth":
68
- raise HomelyAuthError("Invalid Homely username or password")
69
- raise HomelyConnectionError("Could not connect to Homely")
70
-
71
- async def fetch_token_with_reason(
72
- self,
73
- username: str,
74
- password: str,
75
- ) -> tuple[dict[str, Any] | None, str | None]:
76
- """Fetch access token and return optional reason key on failure."""
77
- url = f"{self._base_url}oauth/token"
78
- payload = {
79
- "username": username,
80
- "password": password,
81
- }
82
-
83
- try:
84
- async with self._session.post(url, json=payload, timeout=self._timeout) as response:
85
- if response.status in (200, 201):
86
- _LOGGER.debug("Token fetch successful")
87
- return await response.json(), None
88
-
89
- if response.status in (400, 401, 403):
90
- _LOGGER.debug("Token fetch rejected with status=%s", response.status)
91
- return None, "invalid_auth"
92
-
93
- _LOGGER.warning("Token fetch failed with status=%s", response.status)
94
- return None, "cannot_connect"
95
- except (aiohttp.ClientError, TimeoutError) as err:
96
- _LOGGER.warning("Token fetch network error: %s", err)
97
- return None, "cannot_connect"
98
-
99
- async def fetch_token(
100
- self,
101
- username: str,
102
- password: str,
103
- ) -> dict[str, Any] | None:
104
- """Fetch access token from API."""
105
- response, _reason = await self.fetch_token_with_reason(username, password)
106
- return response
107
-
108
- async def fetch_refresh_token(self, refresh_token: str) -> dict[str, Any] | None:
109
- """Refresh access token using refresh token."""
110
- url = f"{self._base_url}oauth/refresh-token"
111
- payload = {
112
- "refresh_token": refresh_token,
113
- }
114
-
115
- try:
116
- async with self._session.post(url, json=payload, timeout=self._timeout) as response:
117
- if response.status in (200, 201):
118
- _LOGGER.debug("Token refresh successful")
119
- return await response.json()
120
- _LOGGER.debug("Token refresh failed with status=%s", response.status)
121
- return None
122
- except (aiohttp.ClientError, TimeoutError) as err:
123
- _LOGGER.debug("Token refresh network error: %s", err)
124
- return None
125
-
126
- async def refresh_access_token(self, refresh_token: str) -> TokenResponse:
127
- """Refresh access token and return a typed token response."""
128
- response = await self.fetch_refresh_token(refresh_token)
129
- if response:
130
- return TokenResponse.from_dict(response)
131
- raise HomelyConnectionError("Could not refresh Homely access token")
132
-
133
- async def get_locations(self, token: str) -> list[dict[str, Any]] | None:
134
- """Get locations from API."""
135
- url = f"{self._base_url}locations"
136
- headers = {"Authorization": auth_header_value(token)}
137
-
138
- try:
139
- async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
140
- if response.status == 200:
141
- _LOGGER.debug("Locations fetch successful")
142
- return await response.json()
143
- _LOGGER.debug("Locations fetch failed with status=%s", response.status)
144
- return None
145
- except (aiohttp.ClientError, TimeoutError) as err:
146
- _LOGGER.debug("Locations fetch network error: %s", err)
147
- return None
148
-
149
- async def get_locations_or_raise(self, token: str) -> list[dict[str, Any]]:
150
- """Get locations from API or raise a typed exception."""
151
- locations = await self.get_locations(token)
152
- if locations is not None:
153
- return locations
154
- raise HomelyConnectionError("Could not fetch Homely locations")
155
-
156
- async def get_home_data(
157
- self,
158
- token: str,
159
- location_id: str | int,
160
- ) -> dict[str, Any] | None:
161
- """Get location data from API."""
162
- data, _status = await self.get_home_data_with_status(token, location_id)
163
- return data
164
-
165
- async def get_home_data_with_status(
166
- self,
167
- token: str,
168
- location_id: str | int,
169
- ) -> tuple[dict[str, Any] | None, int | None]:
170
- """Get location data from API and include HTTP status when available."""
171
- url = f"{self._base_url}home/{location_id}"
172
- headers = {"Authorization": auth_header_value(token)}
173
-
174
- try:
175
- async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
176
- if response.status == 200:
177
- return await response.json(), response.status
178
- body = await response.text()
179
- body_preview = body.replace("\n", " ")[:200]
180
- _LOGGER.debug(
181
- "Location data fetch failed with status=%s location_id=%s body_preview=%r",
182
- response.status,
183
- location_id,
184
- body_preview,
185
- )
186
- return None, response.status
187
- except (aiohttp.ClientError, TimeoutError) as err:
188
- _LOGGER.debug(
189
- "Location data fetch network error location_id=%s: %s",
190
- location_id,
191
- err,
192
- )
193
- return None, None
194
-
195
- async def get_home_data_or_raise(
196
- self,
197
- token: str,
198
- location_id: str | int,
199
- ) -> dict[str, Any]:
200
- """Get location data from API or raise a typed exception."""
201
- data, status = await self.get_home_data_with_status(token, location_id)
202
- if data is not None:
203
- return data
204
- if status in (401, 403):
205
- raise HomelyAuthError("Homely rejected the supplied access token")
206
- raise HomelyResponseError(
207
- "Could not fetch Homely location data",
208
- status=status,
209
- )
File without changes
File without changes
File without changes