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.
- homecom_alt/__init__.py +53 -0
- homecom_alt/base.py +532 -0
- homecom_alt/commodule.py +431 -0
- homecom_alt/const.py +332 -0
- homecom_alt/exceptions.py +26 -0
- homecom_alt/generic.py +42 -0
- homecom_alt/icom.py +586 -0
- homecom_alt/k40.py +2393 -0
- homecom_alt/model.py +131 -0
- homecom_alt/py.typed +0 -0
- homecom_alt/rac.py +315 -0
- homecom_alt/rrc2.py +665 -0
- homecom_alt/wddw2.py +297 -0
- homecom_alt-1.6.4.dist-info/METADATA +94 -0
- homecom_alt-1.6.4.dist-info/RECORD +16 -0
- homecom_alt-1.6.4.dist-info/WHEEL +4 -0
homecom_alt/__init__.py
ADDED
|
@@ -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)
|