homecom_alt 1.6.4__py3-none-any.whl

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.
@@ -0,0 +1,53 @@
1
+ """Python wrapper for controlling homecom easy devices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import HomeComAlt
6
+ from .commodule import HomeComCommodule
7
+ from .exceptions import (
8
+ ApiError,
9
+ AuthFailedError,
10
+ BhcError,
11
+ InvalidSensorDataError,
12
+ NotRespondingError,
13
+ )
14
+ from .generic import HomeComGeneric
15
+ from .icom import HomeComIcom
16
+ from .k40 import HomeComK40
17
+ from .model import (
18
+ BHCDeviceCommodule,
19
+ BHCDeviceGeneric,
20
+ BHCDeviceIcom,
21
+ BHCDeviceK40,
22
+ BHCDeviceRac,
23
+ BHCDeviceRrc2,
24
+ BHCDeviceWddw2,
25
+ ConnectionOptions,
26
+ )
27
+ from .rac import HomeComRac
28
+ from .rrc2 import HomeComRrc2
29
+ from .wddw2 import HomeComWddw2
30
+
31
+ __all__ = [
32
+ "ApiError",
33
+ "AuthFailedError",
34
+ "BHCDeviceCommodule",
35
+ "BHCDeviceGeneric",
36
+ "BHCDeviceIcom",
37
+ "BHCDeviceK40",
38
+ "BHCDeviceRac",
39
+ "BHCDeviceRrc2",
40
+ "BHCDeviceWddw2",
41
+ "BhcError",
42
+ "ConnectionOptions",
43
+ "HomeComAlt",
44
+ "HomeComCommodule",
45
+ "HomeComGeneric",
46
+ "HomeComIcom",
47
+ "HomeComK40",
48
+ "HomeComRac",
49
+ "HomeComRrc2",
50
+ "HomeComWddw2",
51
+ "InvalidSensorDataError",
52
+ "NotRespondingError",
53
+ ]
homecom_alt/base.py ADDED
@@ -0,0 +1,532 @@
1
+ """Python wrapper for controlling homecom easy devices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from datetime import UTC, datetime, timedelta
9
+ from http import HTTPStatus
10
+ from typing import TYPE_CHECKING, Any
11
+ from urllib.parse import urlencode
12
+
13
+ import jwt
14
+ from aiohttp import (
15
+ ClientConnectorError,
16
+ ClientResponseError,
17
+ ClientSession,
18
+ ContentTypeError,
19
+ )
20
+ from tenacity import (
21
+ after_log,
22
+ retry,
23
+ retry_if_exception_type,
24
+ stop_after_attempt,
25
+ wait_incrementing,
26
+ )
27
+
28
+ from .const import (
29
+ BOSCHCOM_DOMAIN,
30
+ BOSCHCOM_ENDPOINT_BULK,
31
+ BOSCHCOM_ENDPOINT_DWH_WATER_TOTAL_CONSUMPTION,
32
+ BOSCHCOM_ENDPOINT_FIRMWARE,
33
+ BOSCHCOM_ENDPOINT_GATEWAYS,
34
+ BOSCHCOM_ENDPOINT_HS_ACTUAL_POWER,
35
+ BOSCHCOM_ENDPOINT_HS_ELECTRICITY_TOTAL_CONSUMPTION,
36
+ BOSCHCOM_ENDPOINT_HS_OPERATION_HOURS,
37
+ BOSCHCOM_ENDPOINT_HS_POWER_PERCENTAGE,
38
+ BOSCHCOM_ENDPOINT_HS_TOTAL_NUMBER_OF_STARTS,
39
+ BOSCHCOM_ENDPOINT_NOTIFICATIONS,
40
+ BOSCHCOM_ENDPOINT_PV_LIST,
41
+ BOSCHCOM_ENDPOINT_SYSTEM_BRAND,
42
+ BOSCHCOM_ENDPOINT_SYSTEM_HEALTH_STATUS,
43
+ BOSCHCOM_ENDPOINT_SYSTEM_INFO,
44
+ BOSCHCOM_ENDPOINT_TIME,
45
+ BOSCHCOM_ENDPOINT_TIME2,
46
+ DEFAULT_TIMEOUT,
47
+ JSON,
48
+ MAX_BULK_ENDPOINTS,
49
+ OAUTH_BROWSER_VERIFIER,
50
+ OAUTH_DOMAIN,
51
+ OAUTH_ENDPOINT,
52
+ OAUTH_PARAMS,
53
+ OAUTH_PARAMS_BUDERUS,
54
+ OAUTH_REFRESH_PARAMS,
55
+ URLENCODED,
56
+ )
57
+ from .exceptions import (
58
+ ApiError,
59
+ AuthFailedError,
60
+ InvalidSensorDataError,
61
+ NotRespondingError,
62
+ )
63
+
64
+ if TYPE_CHECKING:
65
+ from .model import (
66
+ ConnectionOptions,
67
+ )
68
+
69
+ _LOGGER = logging.getLogger(__name__)
70
+
71
+ _NOT_FOUND_CACHE_TTL: float = 86400.0 # 24 hours
72
+
73
+
74
+ class HomeComAlt:
75
+ """Main class to perform HomeCom Easy requests."""
76
+
77
+ def __init__(
78
+ self, session: ClientSession, options: ConnectionOptions, auth_provider: bool
79
+ ) -> None:
80
+ """Initialize."""
81
+ self._options = options
82
+ self._session = session
83
+ self._count = 0
84
+ self._update_errors: int = 0
85
+ self._auth_provider = auth_provider
86
+ self._oauth_params = (
87
+ OAUTH_PARAMS_BUDERUS if options.brand == "buderus" else OAUTH_PARAMS
88
+ )
89
+ self._oauth_refresh_params = OAUTH_REFRESH_PARAMS
90
+ self._lock = asyncio.Lock()
91
+ self._not_found_cache: dict[str, float] = {}
92
+
93
+ @property
94
+ def refresh_token(self) -> str | None:
95
+ """Return the refresh token."""
96
+ return self._options.refresh_token
97
+
98
+ @refresh_token.setter
99
+ def refresh_token(self, value: str) -> None:
100
+ """Set the refresh token."""
101
+ self._options.refresh_token = value
102
+
103
+ @property
104
+ def token(self) -> str | None:
105
+ """Return the access token."""
106
+ return self._options.token
107
+
108
+ @token.setter
109
+ def token(self, value: str) -> None:
110
+ """Set the access token."""
111
+ self._options.token = value
112
+
113
+ @classmethod
114
+ async def create(
115
+ cls, session: ClientSession, options: ConnectionOptions, auth_provider: bool
116
+ ) -> HomeComAlt:
117
+ """Create a new device instance."""
118
+ return cls(session, options, auth_provider)
119
+
120
+ async def async_request_bulk(
121
+ self, device_id: str, endpoints: list[str]
122
+ ) -> dict[str, Any] | None:
123
+ """Retrieve data from the device with an endpoint bundling multiple requests.
124
+
125
+ Automatically chunks into multiple calls if more than
126
+ MAX_BULK_ENDPOINTS are requested (API limit).
127
+ """
128
+ await self.get_token()
129
+
130
+ if len(endpoints) <= MAX_BULK_ENDPOINTS:
131
+ return await self._async_request_bulk_single(device_id, endpoints)
132
+
133
+ # Chunk into multiple bulk calls
134
+ result: dict[str, Any] = {}
135
+ for i in range(0, len(endpoints), MAX_BULK_ENDPOINTS):
136
+ chunk = endpoints[i : i + MAX_BULK_ENDPOINTS]
137
+ chunk_result = await self._async_request_bulk_single(device_id, chunk)
138
+ if chunk_result:
139
+ result.update(chunk_result)
140
+ return result or None
141
+
142
+ async def _async_request_bulk_single(
143
+ self, device_id: str, endpoints: list[str]
144
+ ) -> dict[str, Any] | None:
145
+ """Send a single bulk request for up to 30 endpoints."""
146
+ # The bulk endpoint expects resource paths WITHOUT the "/resource"
147
+ # prefix; sending the full path returns serverStatus 403 with a null
148
+ # gatewayResponse. Strip on send and map back to original keys when
149
+ # parsing the response.
150
+ sent_to_original: dict[str, str] = {
151
+ e.removeprefix("/resource"): e for e in endpoints
152
+ }
153
+ response = await self._async_http_request(
154
+ "post",
155
+ BOSCHCOM_DOMAIN + BOSCHCOM_ENDPOINT_BULK,
156
+ [
157
+ {
158
+ "gatewayId": device_id,
159
+ "resourcePaths": list(sent_to_original.keys()),
160
+ }
161
+ ],
162
+ JSON,
163
+ )
164
+ json_response = await self._to_data(response)
165
+ if json_response is None:
166
+ return None
167
+
168
+ result: dict[str, Any] = {}
169
+ try:
170
+ device_response = json_response[0]
171
+ endpoint_responses = device_response["resourcePaths"]
172
+ for endpoint_response in endpoint_responses:
173
+ returned_path = endpoint_response["resourcePath"]
174
+ endpoint = sent_to_original.get(returned_path, returned_path)
175
+ server_status = endpoint_response["serverStatus"]
176
+ if server_status != HTTPStatus.OK.value:
177
+ _LOGGER.warning("Endpoint %s returned %s", endpoint, server_status)
178
+ continue
179
+ device_endpoint_response = endpoint_response["gatewayResponse"]
180
+ device_endpoint_response_status = device_endpoint_response["status"]
181
+ if device_endpoint_response_status != HTTPStatus.OK.value:
182
+ _LOGGER.warning(
183
+ "Endpoint %s returned %s",
184
+ endpoint,
185
+ device_endpoint_response_status,
186
+ )
187
+ continue
188
+ payload = device_endpoint_response["payload"]
189
+ result[endpoint] = payload
190
+ except (KeyError, IndexError, TypeError):
191
+ return None
192
+ else:
193
+ return result
194
+
195
+ async def _async_http_request( # noqa: PLR0912
196
+ self,
197
+ method: str,
198
+ url: str,
199
+ data: Any | None = None,
200
+ req_type: int | None = None,
201
+ ) -> Any:
202
+ """Retrieve data from the device."""
203
+ if method.upper() == "GET" and url in self._not_found_cache:
204
+ if time.monotonic() - self._not_found_cache[url] < _NOT_FOUND_CACHE_TTL:
205
+ _LOGGER.debug("Skipping cached 404 endpoint %s", url)
206
+ return {}
207
+ del self._not_found_cache[url]
208
+
209
+ headers = {
210
+ "Authorization": f"Bearer {self._options.token}" # Set Bearer token
211
+ }
212
+ # JSON request
213
+ if req_type == JSON:
214
+ headers["Content-Type"] = "application/json; charset=UTF-8"
215
+ elif req_type == URLENCODED:
216
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
217
+
218
+ try:
219
+ _LOGGER.debug("Requesting %s, method: %s", url, method)
220
+ resp = await self._session.request(
221
+ method,
222
+ url,
223
+ raise_for_status=True,
224
+ data=data if req_type != JSON else None,
225
+ json=data if req_type == JSON else None,
226
+ timeout=DEFAULT_TIMEOUT,
227
+ headers=headers,
228
+ allow_redirects=True,
229
+ )
230
+ except ClientResponseError as error:
231
+ if error.status == HTTPStatus.UNAUTHORIZED.value:
232
+ raise AuthFailedError("Authorization has failed") from error
233
+ if (
234
+ error.status == HTTPStatus.BAD_REQUEST.value
235
+ and url == "https://singlekey-id.com/auth/connect/token"
236
+ ):
237
+ return None
238
+ if error.status == HTTPStatus.NOT_FOUND.value:
239
+ _LOGGER.debug("Endpoint %s returned %s", url, error.status)
240
+ if method.upper() == "GET":
241
+ self._not_found_cache[url] = time.monotonic()
242
+ return {}
243
+ if error.status == HTTPStatus.FORBIDDEN.value:
244
+ _LOGGER.debug("Endpoint %s returned %s", url, error.status)
245
+ return {}
246
+ if error.status in (
247
+ HTTPStatus.BAD_GATEWAY.value, # 502
248
+ HTTPStatus.GATEWAY_TIMEOUT.value, # 504
249
+ ):
250
+ _LOGGER.warning("Endpoint %s returned %s", url, error.status)
251
+ return {}
252
+ if error.status == HTTPStatus.TOO_MANY_REQUESTS.value:
253
+ _LOGGER.warning("Endpoint %s returned %s", url, error.status)
254
+ raise NotRespondingError(f"{url} is rate limited") from error
255
+ raise ApiError(
256
+ f"Invalid response from url {url}: {error.status}"
257
+ ) from error
258
+ except (TimeoutError, ClientConnectorError) as error:
259
+ raise NotRespondingError(f"{url} is not responding") from error
260
+
261
+ _LOGGER.debug("Data retrieved from %s, status: %s", url, resp.status)
262
+ if resp.status not in {HTTPStatus.OK.value, HTTPStatus.NO_CONTENT.value}:
263
+ raise ApiError(f"Invalid response from {url}: {resp.status}")
264
+
265
+ return resp
266
+
267
+ @staticmethod
268
+ async def _to_data(response: Any) -> Any | None:
269
+ if not response:
270
+ return None
271
+ try:
272
+ return await response.json()
273
+ except (ValueError, ContentTypeError) as error:
274
+ _LOGGER.warning("Failed to parse response as JSON: %s", error)
275
+ return None
276
+
277
+ @retry(
278
+ retry=retry_if_exception_type(NotRespondingError),
279
+ stop=stop_after_attempt(5),
280
+ wait=wait_incrementing(start=5, increment=5),
281
+ after=after_log(_LOGGER, logging.DEBUG),
282
+ )
283
+ async def async_get_devices(self) -> Any:
284
+ """Get devices."""
285
+ await self.get_token()
286
+ response = await self._async_http_request(
287
+ "get",
288
+ BOSCHCOM_DOMAIN + BOSCHCOM_ENDPOINT_GATEWAYS,
289
+ )
290
+ try:
291
+ return response.json()
292
+ except ValueError as error:
293
+ raise InvalidSensorDataError("Invalid devices data") from error
294
+
295
+ async def async_get_firmware(self, device_id: str) -> Any:
296
+ """Get firmware."""
297
+ await self.get_token()
298
+ response = await self._async_http_request(
299
+ "get",
300
+ BOSCHCOM_DOMAIN
301
+ + BOSCHCOM_ENDPOINT_GATEWAYS
302
+ + device_id
303
+ + BOSCHCOM_ENDPOINT_FIRMWARE,
304
+ )
305
+ return await self._to_data(response)
306
+
307
+ async def async_get_system_info(self, device_id: str) -> Any:
308
+ """Get system info."""
309
+ response = await self._async_http_request(
310
+ "get",
311
+ BOSCHCOM_DOMAIN
312
+ + BOSCHCOM_ENDPOINT_GATEWAYS
313
+ + device_id
314
+ + BOSCHCOM_ENDPOINT_SYSTEM_INFO,
315
+ )
316
+ return await self._to_data(response)
317
+
318
+ async def async_get_system_health_status(self, device_id: str) -> Any:
319
+ """Get system health status (ok/error/maintenance)."""
320
+ response = await self._async_http_request(
321
+ "get",
322
+ BOSCHCOM_DOMAIN
323
+ + BOSCHCOM_ENDPOINT_GATEWAYS
324
+ + device_id
325
+ + BOSCHCOM_ENDPOINT_SYSTEM_HEALTH_STATUS,
326
+ )
327
+ return await self._to_data(response)
328
+
329
+ async def async_get_system_brand(self, device_id: str) -> Any:
330
+ """Get system brand identifier."""
331
+ response = await self._async_http_request(
332
+ "get",
333
+ BOSCHCOM_DOMAIN
334
+ + BOSCHCOM_ENDPOINT_GATEWAYS
335
+ + device_id
336
+ + BOSCHCOM_ENDPOINT_SYSTEM_BRAND,
337
+ )
338
+ return await self._to_data(response)
339
+
340
+ async def async_get_hs_total_number_of_starts(self, device_id: str) -> Any:
341
+ """Get total heat-source number of starts (top-level, not hs1)."""
342
+ response = await self._async_http_request(
343
+ "get",
344
+ BOSCHCOM_DOMAIN
345
+ + BOSCHCOM_ENDPOINT_GATEWAYS
346
+ + device_id
347
+ + BOSCHCOM_ENDPOINT_HS_TOTAL_NUMBER_OF_STARTS,
348
+ )
349
+ return await self._to_data(response)
350
+
351
+ async def async_get_hs_actual_power(self, device_id: str) -> Any:
352
+ """Get current heat-source power draw (hs1/actualPower)."""
353
+ response = await self._async_http_request(
354
+ "get",
355
+ BOSCHCOM_DOMAIN
356
+ + BOSCHCOM_ENDPOINT_GATEWAYS
357
+ + device_id
358
+ + BOSCHCOM_ENDPOINT_HS_ACTUAL_POWER,
359
+ )
360
+ return await self._to_data(response)
361
+
362
+ async def async_get_hs_power_percentage(self, device_id: str) -> Any:
363
+ """Get heat-source power percentage (hs1/powerPercentage)."""
364
+ response = await self._async_http_request(
365
+ "get",
366
+ BOSCHCOM_DOMAIN
367
+ + BOSCHCOM_ENDPOINT_GATEWAYS
368
+ + device_id
369
+ + BOSCHCOM_ENDPOINT_HS_POWER_PERCENTAGE,
370
+ )
371
+ return await self._to_data(response)
372
+
373
+ async def async_get_hs_operation_hours(self, device_id: str) -> Any:
374
+ """Get cumulative heat-source operating hours (hs1/operationHours)."""
375
+ response = await self._async_http_request(
376
+ "get",
377
+ BOSCHCOM_DOMAIN
378
+ + BOSCHCOM_ENDPOINT_GATEWAYS
379
+ + device_id
380
+ + BOSCHCOM_ENDPOINT_HS_OPERATION_HOURS,
381
+ )
382
+ return await self._to_data(response)
383
+
384
+ async def async_get_hs_electricity_total_consumption(self, device_id: str) -> Any:
385
+ """Get total electricity consumed by heat sources (kWh)."""
386
+ response = await self._async_http_request(
387
+ "get",
388
+ BOSCHCOM_DOMAIN
389
+ + BOSCHCOM_ENDPOINT_GATEWAYS
390
+ + device_id
391
+ + BOSCHCOM_ENDPOINT_HS_ELECTRICITY_TOTAL_CONSUMPTION,
392
+ )
393
+ return await self._to_data(response)
394
+
395
+ async def async_get_dhw_water_total_consumption(self, device_id: str) -> Any:
396
+ """Get total DHW water consumption (litres, top-level dhwCircuits)."""
397
+ response = await self._async_http_request(
398
+ "get",
399
+ BOSCHCOM_DOMAIN
400
+ + BOSCHCOM_ENDPOINT_GATEWAYS
401
+ + device_id
402
+ + BOSCHCOM_ENDPOINT_DWH_WATER_TOTAL_CONSUMPTION,
403
+ )
404
+ return await self._to_data(response)
405
+
406
+ async def async_get_notifications(self, device_id: str) -> Any:
407
+ """Get notifications."""
408
+ response = await self._async_http_request(
409
+ "get",
410
+ BOSCHCOM_DOMAIN
411
+ + BOSCHCOM_ENDPOINT_GATEWAYS
412
+ + device_id
413
+ + BOSCHCOM_ENDPOINT_NOTIFICATIONS,
414
+ )
415
+ return await self._to_data(response)
416
+
417
+ async def async_get_pv_list(self, device_id: str) -> Any:
418
+ """Get pv list."""
419
+ response = await self._async_http_request(
420
+ "get",
421
+ BOSCHCOM_DOMAIN
422
+ + BOSCHCOM_ENDPOINT_GATEWAYS
423
+ + device_id
424
+ + BOSCHCOM_ENDPOINT_PV_LIST,
425
+ )
426
+ return await self._to_data(response)
427
+
428
+ async def async_get_time(self, device_id: str) -> Any:
429
+ """Get gateway time."""
430
+ last_exc: Exception | None = None
431
+
432
+ for ep in (BOSCHCOM_ENDPOINT_TIME, BOSCHCOM_ENDPOINT_TIME2):
433
+ try:
434
+ response = await self._async_http_request(
435
+ "get",
436
+ BOSCHCOM_DOMAIN + BOSCHCOM_ENDPOINT_GATEWAYS + device_id + ep,
437
+ )
438
+ if isinstance(response, dict) and response == {}:
439
+ raise ApiError(f"{ep} not supported for this device.") # noqa: TRY301
440
+ return await self._to_data(response)
441
+ except AuthFailedError:
442
+ raise
443
+ except (ApiError, NotRespondingError) as exc:
444
+ last_exc = exc
445
+ continue
446
+ raise last_exc or ApiError("Both time endpoints failed.")
447
+
448
+ def check_jwt(self) -> bool:
449
+ """Check if token is expired."""
450
+ if not self._options.token:
451
+ return False
452
+ try:
453
+ exp = jwt.decode(
454
+ self._options.token, options={"verify_signature": False}
455
+ ).get("exp")
456
+ if exp is None:
457
+ _LOGGER.error("Token missing 'exp' claim")
458
+ return False
459
+ return datetime.now(UTC) < datetime.fromtimestamp(exp, UTC) - timedelta(
460
+ minutes=5
461
+ )
462
+ except jwt.DecodeError as err:
463
+ _LOGGER.error("Invalid token: %s", err)
464
+ return False
465
+
466
+ async def get_token(self) -> bool | None:
467
+ """Retrieve a new token using the refresh token."""
468
+ if self._auth_provider:
469
+ if self.check_jwt():
470
+ return None
471
+
472
+ async with self._lock:
473
+ if self.check_jwt():
474
+ return None
475
+
476
+ if self._options.refresh_token:
477
+ data = {**self._oauth_refresh_params}
478
+ data["refresh_token"] = self._options.refresh_token
479
+ response = await self._async_http_request(
480
+ "post", OAUTH_DOMAIN + OAUTH_ENDPOINT, data, 2
481
+ )
482
+ if response is not None:
483
+ try:
484
+ response_json = await response.json()
485
+ except ValueError as error:
486
+ raise InvalidSensorDataError(
487
+ "Invalid devices data"
488
+ ) from error
489
+
490
+ if response_json:
491
+ self._options.token = response_json["access_token"]
492
+ self._options.refresh_token = response_json["refresh_token"]
493
+ return True
494
+
495
+ if self._options.code:
496
+ response = await self.validate_auth(
497
+ self._options.code, OAUTH_BROWSER_VERIFIER
498
+ )
499
+ if response:
500
+ self._options.code = None
501
+ self._options.token = response["access_token"]
502
+ self._options.refresh_token = response["refresh_token"]
503
+ return True
504
+ raise AuthFailedError("Failed to refresh")
505
+ return None
506
+
507
+ async def validate_auth(self, code: str, code_verifier: str) -> Any | None:
508
+ """Get access and refresh token from singlekey-id."""
509
+ response = await self._async_http_request(
510
+ "post",
511
+ OAUTH_DOMAIN + OAUTH_ENDPOINT,
512
+ "code="
513
+ + code
514
+ + "&"
515
+ + urlencode(self._oauth_params)
516
+ + "&code_verifier="
517
+ + code_verifier,
518
+ 2,
519
+ )
520
+ try:
521
+ return await response.json()
522
+ except ValueError as error:
523
+ raise AuthFailedError("Authorization has failed") from error
524
+
525
+ async def async_action_universal_get(self, device_id: str, path: str) -> Any:
526
+ """Query any endpoint."""
527
+ await self.get_token()
528
+ response = await self._async_http_request(
529
+ "get",
530
+ BOSCHCOM_DOMAIN + BOSCHCOM_ENDPOINT_GATEWAYS + device_id + path,
531
+ )
532
+ return await self._to_data(response)