aiohomematic 2025.11.3__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.

Potentially problematic release.


This version of aiohomematic might be problematic. Click here for more details.

Files changed (77) hide show
  1. aiohomematic/__init__.py +61 -0
  2. aiohomematic/async_support.py +212 -0
  3. aiohomematic/central/__init__.py +2309 -0
  4. aiohomematic/central/decorators.py +155 -0
  5. aiohomematic/central/rpc_server.py +295 -0
  6. aiohomematic/client/__init__.py +1848 -0
  7. aiohomematic/client/_rpc_errors.py +81 -0
  8. aiohomematic/client/json_rpc.py +1326 -0
  9. aiohomematic/client/rpc_proxy.py +311 -0
  10. aiohomematic/const.py +1127 -0
  11. aiohomematic/context.py +18 -0
  12. aiohomematic/converter.py +108 -0
  13. aiohomematic/decorators.py +302 -0
  14. aiohomematic/exceptions.py +164 -0
  15. aiohomematic/hmcli.py +186 -0
  16. aiohomematic/model/__init__.py +140 -0
  17. aiohomematic/model/calculated/__init__.py +84 -0
  18. aiohomematic/model/calculated/climate.py +290 -0
  19. aiohomematic/model/calculated/data_point.py +327 -0
  20. aiohomematic/model/calculated/operating_voltage_level.py +299 -0
  21. aiohomematic/model/calculated/support.py +234 -0
  22. aiohomematic/model/custom/__init__.py +177 -0
  23. aiohomematic/model/custom/climate.py +1532 -0
  24. aiohomematic/model/custom/cover.py +792 -0
  25. aiohomematic/model/custom/data_point.py +334 -0
  26. aiohomematic/model/custom/definition.py +871 -0
  27. aiohomematic/model/custom/light.py +1128 -0
  28. aiohomematic/model/custom/lock.py +394 -0
  29. aiohomematic/model/custom/siren.py +275 -0
  30. aiohomematic/model/custom/support.py +41 -0
  31. aiohomematic/model/custom/switch.py +175 -0
  32. aiohomematic/model/custom/valve.py +114 -0
  33. aiohomematic/model/data_point.py +1123 -0
  34. aiohomematic/model/device.py +1445 -0
  35. aiohomematic/model/event.py +208 -0
  36. aiohomematic/model/generic/__init__.py +217 -0
  37. aiohomematic/model/generic/action.py +34 -0
  38. aiohomematic/model/generic/binary_sensor.py +30 -0
  39. aiohomematic/model/generic/button.py +27 -0
  40. aiohomematic/model/generic/data_point.py +171 -0
  41. aiohomematic/model/generic/dummy.py +147 -0
  42. aiohomematic/model/generic/number.py +76 -0
  43. aiohomematic/model/generic/select.py +39 -0
  44. aiohomematic/model/generic/sensor.py +74 -0
  45. aiohomematic/model/generic/switch.py +54 -0
  46. aiohomematic/model/generic/text.py +29 -0
  47. aiohomematic/model/hub/__init__.py +333 -0
  48. aiohomematic/model/hub/binary_sensor.py +24 -0
  49. aiohomematic/model/hub/button.py +28 -0
  50. aiohomematic/model/hub/data_point.py +340 -0
  51. aiohomematic/model/hub/number.py +39 -0
  52. aiohomematic/model/hub/select.py +49 -0
  53. aiohomematic/model/hub/sensor.py +37 -0
  54. aiohomematic/model/hub/switch.py +44 -0
  55. aiohomematic/model/hub/text.py +30 -0
  56. aiohomematic/model/support.py +586 -0
  57. aiohomematic/model/update.py +143 -0
  58. aiohomematic/property_decorators.py +496 -0
  59. aiohomematic/py.typed +0 -0
  60. aiohomematic/rega_scripts/fetch_all_device_data.fn +92 -0
  61. aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
  62. aiohomematic/rega_scripts/get_serial.fn +44 -0
  63. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
  64. aiohomematic/rega_scripts/set_program_state.fn +12 -0
  65. aiohomematic/rega_scripts/set_system_variable.fn +15 -0
  66. aiohomematic/store/__init__.py +34 -0
  67. aiohomematic/store/dynamic.py +551 -0
  68. aiohomematic/store/persistent.py +988 -0
  69. aiohomematic/store/visibility.py +812 -0
  70. aiohomematic/support.py +664 -0
  71. aiohomematic/validator.py +112 -0
  72. aiohomematic-2025.11.3.dist-info/METADATA +144 -0
  73. aiohomematic-2025.11.3.dist-info/RECORD +77 -0
  74. aiohomematic-2025.11.3.dist-info/WHEEL +5 -0
  75. aiohomematic-2025.11.3.dist-info/entry_points.txt +2 -0
  76. aiohomematic-2025.11.3.dist-info/licenses/LICENSE +21 -0
  77. aiohomematic-2025.11.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1326 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025
3
+ """
4
+ Asynchronous JSON-RPC client for Homematic CCU-compatible backends.
5
+
6
+ Overview
7
+ --------
8
+ JsonRpcAioHttpClient wraps CCU JSON-RPC endpoints to provide:
9
+ - Login and session handling with automatic renewal
10
+ - Execution of ReGa scripts and JSON-RPC methods
11
+ - Access to system variables, programs, and device/channel metadata
12
+ - Reading/writing paramsets and values where supported
13
+ - Robust error handling, optional TLS, and rate-limiting via semaphores
14
+
15
+ Usage
16
+ -----
17
+ This client is usually managed by CentralUnit through ClientJsonCCU, but can be
18
+ used directly for advanced tasks. Typical flow:
19
+
20
+ client = JsonRpcAioHttpClient(username, password, device_url, connection_state, aiohttp_session, tls=True)
21
+ await client.get_system_information()
22
+ data = await client.get_all_device_data(interface)
23
+
24
+ Notes
25
+ -----
26
+ - Some JSON-RPC methods are backend/firmware dependent. The client detects and
27
+ store supported methods at runtime.
28
+ - Binary/text encodings are handled carefully (UTF-8 / ISO-8859-1) for script IO.
29
+
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ from asyncio import Semaphore
36
+ from collections.abc import Mapping
37
+ from datetime import datetime
38
+ from enum import StrEnum
39
+ from functools import partial
40
+ from json import JSONDecodeError
41
+ import logging
42
+ import os
43
+ from pathlib import Path
44
+ from ssl import SSLContext
45
+ from typing import Any, Final
46
+ from urllib.parse import unquote
47
+
48
+ from aiohttp import (
49
+ ClientConnectorCertificateError,
50
+ ClientConnectorError,
51
+ ClientError,
52
+ ClientResponse,
53
+ ClientSession,
54
+ ClientTimeout,
55
+ ContentTypeError,
56
+ TCPConnector,
57
+ )
58
+ import orjson
59
+
60
+ from aiohomematic import central as hmcu
61
+ from aiohomematic.async_support import Looper
62
+ from aiohomematic.client._rpc_errors import RpcContext, map_jsonrpc_error
63
+ from aiohomematic.const import (
64
+ ALWAYS_ENABLE_SYSVARS_BY_ID,
65
+ DEFAULT_INCLUDE_INTERNAL_PROGRAMS,
66
+ DEFAULT_INCLUDE_INTERNAL_SYSVARS,
67
+ ISO_8859_1,
68
+ JSON_SESSION_AGE,
69
+ MAX_CONCURRENT_HTTP_SESSIONS,
70
+ PATH_JSON_RPC,
71
+ REGA_SCRIPT_PATH,
72
+ RENAME_SYSVAR_BY_NAME,
73
+ TIMEOUT,
74
+ UTF_8,
75
+ DescriptionMarker,
76
+ DeviceDescription,
77
+ Interface,
78
+ ParameterData,
79
+ ParamsetKey,
80
+ ProgramData,
81
+ RegaScript,
82
+ SystemInformation,
83
+ SystemVariableData,
84
+ SysvarType,
85
+ )
86
+ from aiohomematic.exceptions import (
87
+ BaseHomematicException,
88
+ ClientException,
89
+ InternalBackendException,
90
+ NoConnectionException,
91
+ UnsupportedException,
92
+ )
93
+ from aiohomematic.model.support import convert_value
94
+ from aiohomematic.property_decorators import hm_property
95
+ from aiohomematic.store import SessionRecorder
96
+ from aiohomematic.support import (
97
+ LogContextMixin,
98
+ cleanup_text_from_html_tags,
99
+ element_matches_key,
100
+ extract_exc_args,
101
+ get_tls_context,
102
+ log_boundary_error,
103
+ parse_sys_var,
104
+ )
105
+
106
+ _LOGGER: Final = logging.getLogger(__name__)
107
+
108
+
109
+ class _JsonKey(StrEnum):
110
+ """Enum for Homematic json keys."""
111
+
112
+ ADDRESS = "address"
113
+ CHANNEL_IDS = "channelIds"
114
+ DESCRIPTION = "description"
115
+ ERROR = "error"
116
+ ID = "id"
117
+ INTERFACE = "interface"
118
+ IS_ACTIVE = "isActive"
119
+ IS_INTERNAL = "isInternal"
120
+ LAST_EXECUTE_TIME = "lastExecuteTime"
121
+ MAX_VALUE = "maxValue"
122
+ MESSAGE = "message"
123
+ MIN_VALUE = "minValue"
124
+ NAME = "name"
125
+ PARAMSET_KEY = "paramsetKey"
126
+ PASSWORD = "password"
127
+ RESULT = "result"
128
+ SCRIPT = "script"
129
+ SERIAL = "serial"
130
+ SESSION_ID = "_session_id_"
131
+ SET = "set"
132
+ STATE = "state"
133
+ TYPE = "type"
134
+ UNIT = "unit"
135
+ USERNAME = "username"
136
+ VALUE = "value"
137
+ VALUE_KEY = "valueKey"
138
+ VALUE_LIST = "valueList"
139
+
140
+
141
+ class _JsonRpcMethod(StrEnum):
142
+ """Enum for Homematic json rpc methods types."""
143
+
144
+ CCU_GET_AUTH_ENABLED = "CCU.getAuthEnabled"
145
+ CCU_GET_HTTPS_REDIRECT_ENABLED = "CCU.getHttpsRedirectEnabled"
146
+ CHANNEL_HAS_PROGRAM_IDS = "Channel.hasProgramIds"
147
+ DEVICE_LIST_ALL_DETAIL = "Device.listAllDetail"
148
+ INTERFACE_GET_DEVICE_DESCRIPTION = "Interface.getDeviceDescription"
149
+ INTERFACE_GET_MASTER_VALUE = "Interface.getMasterValue"
150
+ INTERFACE_GET_PARAMSET = "Interface.getParamset"
151
+ INTERFACE_GET_PARAMSET_DESCRIPTION = "Interface.getParamsetDescription"
152
+ INTERFACE_GET_VALUE = "Interface.getValue"
153
+ INTERFACE_IS_PRESENT = "Interface.isPresent"
154
+ INTERFACE_LIST_DEVICES = "Interface.listDevices"
155
+ INTERFACE_LIST_INTERFACES = "Interface.listInterfaces"
156
+ INTERFACE_PUT_PARAMSET = "Interface.putParamset"
157
+ INTERFACE_SET_VALUE = "Interface.setValue"
158
+ PROGRAM_EXECUTE = "Program.execute"
159
+ PROGRAM_GET_ALL = "Program.getAll"
160
+ REGA_RUN_SCRIPT = "ReGa.runScript"
161
+ ROOM_GET_ALL = "Room.getAll"
162
+ SESSION_LOGIN = "Session.login"
163
+ SESSION_LOGOUT = "Session.logout"
164
+ SESSION_RENEW = "Session.renew"
165
+ SUBSECTION_GET_ALL = "Subsection.getAll"
166
+ SYSTEM_LIST_METHODS = "system.listMethods"
167
+ SYSVAR_DELETE_SYSVAR_BY_NAME = "SysVar.deleteSysVarByName"
168
+ SYSVAR_GET_ALL = "SysVar.getAll"
169
+ SYSVAR_GET_VALUE_BY_NAME = "SysVar.getValueByName"
170
+ SYSVAR_SET_BOOL = "SysVar.setBool"
171
+ SYSVAR_SET_FLOAT = "SysVar.setFloat"
172
+
173
+
174
+ _PARALLEL_EXECUTION_LIMITED_JSONRPC_METHODS: Final = (
175
+ _JsonRpcMethod.INTERFACE_GET_DEVICE_DESCRIPTION,
176
+ _JsonRpcMethod.INTERFACE_GET_MASTER_VALUE,
177
+ _JsonRpcMethod.INTERFACE_GET_PARAMSET,
178
+ _JsonRpcMethod.INTERFACE_GET_PARAMSET_DESCRIPTION,
179
+ _JsonRpcMethod.INTERFACE_GET_VALUE,
180
+ )
181
+
182
+
183
+ class AioJsonRpcAioHttpClient(LogContextMixin):
184
+ """Connection to CCU JSON-RPC Server."""
185
+
186
+ def __init__(
187
+ self,
188
+ *,
189
+ username: str,
190
+ password: str,
191
+ device_url: str,
192
+ connection_state: hmcu.CentralConnectionState,
193
+ client_session: ClientSession | None,
194
+ tls: bool = False,
195
+ verify_tls: bool = False,
196
+ session_recorder: SessionRecorder | None = None,
197
+ ) -> None:
198
+ """Session setup."""
199
+ self._client_session: Final = (
200
+ ClientSession(connector=TCPConnector(limit=MAX_CONCURRENT_HTTP_SESSIONS))
201
+ if client_session is None
202
+ else client_session
203
+ )
204
+ self._is_internal_session: Final = bool(client_session is None)
205
+ self._connection_state: Final = connection_state
206
+ self._username: Final = username
207
+ self._password: Final = password
208
+ self._looper = Looper()
209
+ self._tls: Final = tls
210
+ self._tls_context: Final[SSLContext | bool] = get_tls_context(verify_tls=verify_tls) if tls else False
211
+ self._url: Final = f"{device_url}{PATH_JSON_RPC}"
212
+ self._script_cache: Final[dict[str, str]] = {}
213
+ self._last_session_id_refresh: datetime | None = None
214
+ self._session_id: str | None = None
215
+ self._session_recorder: Final = session_recorder
216
+ self._supported_methods: tuple[str, ...] | None = None
217
+ self._sema: Final = Semaphore(value=MAX_CONCURRENT_HTTP_SESSIONS)
218
+
219
+ @property
220
+ def is_activated(self) -> bool:
221
+ """If session exists, then it is activated."""
222
+ return self._session_id is not None
223
+
224
+ @hm_property(log_context=True)
225
+ def url(self) -> str | None:
226
+ """Return url."""
227
+ return self._url
228
+
229
+ @hm_property(log_context=True)
230
+ def tls(self) -> bool:
231
+ """Return tls."""
232
+ return self._tls
233
+
234
+ async def _login_or_renew(self) -> bool:
235
+ """Renew JSON-RPC session or perform login."""
236
+ if not self.is_activated:
237
+ self._session_id = await self._do_login()
238
+ self._last_session_id_refresh = datetime.now()
239
+ return self._session_id is not None
240
+ if self._session_id:
241
+ self._session_id = await self._do_renew_login(session_id=self._session_id)
242
+ return self._session_id is not None
243
+
244
+ async def _do_renew_login(self, *, session_id: str) -> str | None:
245
+ """Renew JSON-RPC session or perform login."""
246
+ if self._has_session_recently_refreshed:
247
+ return session_id
248
+ method = _JsonRpcMethod.SESSION_RENEW
249
+ response = await self._do_post(
250
+ session_id=session_id,
251
+ method=method,
252
+ extra_params={_JsonKey.SESSION_ID: session_id},
253
+ )
254
+ if response[_JsonKey.RESULT] is True:
255
+ self._last_session_id_refresh = datetime.now()
256
+ _LOGGER.debug("DO_RENEW_LOGIN: method: %s [%s]", method, session_id)
257
+ return session_id
258
+
259
+ return await self._do_login()
260
+
261
+ @property
262
+ def _has_session_recently_refreshed(self) -> bool:
263
+ """Check if session id has been modified within 90 seconds."""
264
+ if self._last_session_id_refresh is None:
265
+ return False
266
+ delta = datetime.now() - self._last_session_id_refresh
267
+ return delta.seconds < JSON_SESSION_AGE
268
+
269
+ async def _do_login(self) -> str | None:
270
+ """Login to the backend and return session."""
271
+ if not self._has_credentials:
272
+ _LOGGER.warning("DO_LOGIN failed: No credentials set")
273
+ return None
274
+
275
+ session_id: str | None = None
276
+
277
+ params = {
278
+ _JsonKey.USERNAME: self._username,
279
+ _JsonKey.PASSWORD: self._password,
280
+ }
281
+ method = _JsonRpcMethod.SESSION_LOGIN
282
+ response = await self._do_post(
283
+ session_id=False,
284
+ method=method,
285
+ extra_params=params,
286
+ use_default_params=False,
287
+ )
288
+
289
+ if result := response[_JsonKey.RESULT]:
290
+ session_id = result
291
+
292
+ _LOGGER.debug("DO_LOGIN: method: %s [%s]", method, session_id)
293
+
294
+ return session_id
295
+
296
+ async def _post(
297
+ self,
298
+ *,
299
+ method: _JsonRpcMethod,
300
+ extra_params: dict[_JsonKey, Any] | None = None,
301
+ use_default_params: bool = True,
302
+ keep_session: bool = True,
303
+ ) -> dict[str, Any] | Any:
304
+ """Reusable JSON-RPC POST function."""
305
+ if keep_session:
306
+ await self._login_or_renew()
307
+ session_id = self._session_id
308
+ else:
309
+ session_id = await self._do_login()
310
+
311
+ if not session_id:
312
+ raise ClientException("Error while logging in")
313
+
314
+ if self._supported_methods is None:
315
+ await self._check_supported_methods()
316
+
317
+ response = await self._do_post(
318
+ session_id=session_id,
319
+ method=method,
320
+ extra_params=extra_params,
321
+ use_default_params=use_default_params,
322
+ )
323
+
324
+ if extra_params:
325
+ _LOGGER.debug("POST method: %s [%s]", method, extra_params)
326
+ else:
327
+ _LOGGER.debug("POST method: %s", method)
328
+
329
+ if not keep_session:
330
+ await self._do_logout(session_id=session_id)
331
+
332
+ return response
333
+
334
+ async def _post_script(
335
+ self,
336
+ *,
337
+ script_name: str,
338
+ extra_params: dict[_JsonKey, Any] | None = None,
339
+ keep_session: bool = True,
340
+ ) -> dict[str, Any] | Any:
341
+ """Reusable JSON-RPC POST_SCRIPT function."""
342
+ # Load and validate script first to avoid any network when script is missing
343
+ if (script := await self._get_script(script_name=script_name)) is None:
344
+ raise ClientException(f"Script file for {script_name} does not exist")
345
+
346
+ # Prepare session only after we know we have a script to run
347
+ if keep_session:
348
+ await self._login_or_renew()
349
+ session_id = self._session_id
350
+ else:
351
+ session_id = await self._do_login()
352
+
353
+ if not session_id:
354
+ raise ClientException("Error while logging in")
355
+
356
+ if self._supported_methods is None:
357
+ await self._check_supported_methods()
358
+
359
+ if extra_params:
360
+ for variable, value in extra_params.items():
361
+ script = script.replace(f"##{variable}##", value)
362
+
363
+ method = _JsonRpcMethod.REGA_RUN_SCRIPT
364
+ response = await self._do_post(
365
+ session_id=session_id,
366
+ method=method,
367
+ extra_params={_JsonKey.SCRIPT: script},
368
+ )
369
+
370
+ _LOGGER.debug("POST_SCRIPT: method: %s [%s]", method, script_name)
371
+
372
+ try:
373
+ if not response[_JsonKey.ERROR] and (resp := response[_JsonKey.RESULT]) and isinstance(resp, str):
374
+ response[_JsonKey.RESULT] = orjson.loads(resp)
375
+ finally:
376
+ if not keep_session:
377
+ await self._do_logout(session_id=session_id)
378
+
379
+ return response
380
+
381
+ async def _get_script(self, *, script_name: str) -> str | None:
382
+ """Return a script from the script cache. Load if required."""
383
+ if script_name in self._script_cache:
384
+ return self._script_cache[script_name]
385
+
386
+ def _load_script(script_name: str) -> str | None:
387
+ """Load script from file system."""
388
+ script_file = os.path.join(Path(__file__).resolve().parent, REGA_SCRIPT_PATH, script_name)
389
+ try:
390
+ if script := Path(script_file).read_text(encoding=UTF_8):
391
+ self._script_cache[script_name] = script
392
+ return script
393
+ except FileNotFoundError:
394
+ return None
395
+ return None
396
+
397
+ return await self._looper.async_add_executor_job(_load_script, script_name, name=f"load_script-{script_name}")
398
+
399
+ async def _do_post(
400
+ self,
401
+ *,
402
+ session_id: bool | str,
403
+ method: _JsonRpcMethod,
404
+ extra_params: dict[_JsonKey, Any] | None = None,
405
+ use_default_params: bool = True,
406
+ ) -> dict[str, Any] | Any:
407
+ """Reusable JSON-RPC POST function."""
408
+ if not self._client_session:
409
+ raise ClientException("ClientSession not initialized")
410
+ if not self._has_credentials:
411
+ raise ClientException("No credentials set")
412
+ if self._supported_methods and method not in self._supported_methods:
413
+ raise UnsupportedException(f"POST: method '{method} not supported by the backend.")
414
+
415
+ params = _get_params(session_id=session_id, extra_params=extra_params, use_default_params=use_default_params)
416
+
417
+ try:
418
+ payload = orjson.dumps({"method": method, "params": params, "jsonrpc": "1.1", "id": 0})
419
+
420
+ headers = {
421
+ "Content-Type": "application/json",
422
+ "Content-Length": str(len(payload)),
423
+ }
424
+
425
+ post_call = partial(
426
+ self._client_session.post,
427
+ url=self._url,
428
+ data=payload,
429
+ headers=headers,
430
+ timeout=ClientTimeout(total=TIMEOUT),
431
+ ssl=self._tls_context,
432
+ )
433
+ if method in _PARALLEL_EXECUTION_LIMITED_JSONRPC_METHODS:
434
+ async with self._sema:
435
+ if (response := await asyncio.shield(post_call())) is None:
436
+ raise ClientException("POST method failed with no response")
437
+ elif (response := await asyncio.shield(post_call())) is None:
438
+ raise ClientException("POST method failed with no response")
439
+
440
+ if response.status == 200:
441
+ json_response = await asyncio.shield(self._get_json_reponse(response=response))
442
+ self._record_session(method=method, params=params, response=json_response)
443
+ if error := json_response[_JsonKey.ERROR]:
444
+ # Map JSON-RPC error to actionable exception with context
445
+ ctx = RpcContext(protocol="json-rpc", method=str(method), host=self._url)
446
+ exc = map_jsonrpc_error(error=error, ctx=ctx)
447
+ # Structured boundary log at warning level (recoverable per-call failure)
448
+ log_boundary_error(
449
+ logger=_LOGGER,
450
+ boundary="json-rpc",
451
+ action=str(method),
452
+ err=exc,
453
+ level=logging.WARNING,
454
+ log_context=self.log_context,
455
+ )
456
+ _LOGGER.debug("POST: %s", exc)
457
+ raise exc
458
+
459
+ return json_response
460
+
461
+ message = f"Status: {response.status}"
462
+ json_response = await asyncio.shield(self._get_json_reponse(response=response))
463
+ if error := json_response[_JsonKey.ERROR]:
464
+ ctx = RpcContext(protocol="json-rpc", method=str(method), host=self._url)
465
+ exc = map_jsonrpc_error(error=error, ctx=ctx)
466
+ log_boundary_error(
467
+ logger=_LOGGER,
468
+ boundary="json-rpc",
469
+ action=str(method),
470
+ err=exc,
471
+ level=logging.WARNING,
472
+ log_context=dict(self.log_context) | {"status": response.status},
473
+ )
474
+ raise exc
475
+ raise ClientException(message)
476
+ except BaseHomematicException as bhe:
477
+ self._record_session(method=method, params=params, exc=bhe)
478
+ if method in (_JsonRpcMethod.SESSION_LOGIN, _JsonRpcMethod.SESSION_LOGOUT, _JsonRpcMethod.SESSION_RENEW):
479
+ self.clear_session()
480
+ # Domain error at boundary -> warning
481
+ log_boundary_error(
482
+ logger=_LOGGER,
483
+ boundary="json-rpc",
484
+ action=str(method),
485
+ err=bhe,
486
+ level=logging.WARNING,
487
+ log_context=self.log_context,
488
+ )
489
+ raise
490
+
491
+ except ClientConnectorCertificateError as cccerr:
492
+ self.clear_session()
493
+ message = f"ClientConnectorCertificateError[{cccerr}]"
494
+ if self._tls is False and cccerr.ssl is True:
495
+ message = (
496
+ f"{message}. Possible reason: 'Automatic forwarding to HTTPS' is enabled in the backend, "
497
+ f"but this integration is not configured to use TLS"
498
+ )
499
+ log_boundary_error(
500
+ logger=_LOGGER,
501
+ boundary="json-rpc",
502
+ action=str(method),
503
+ err=cccerr,
504
+ level=logging.ERROR,
505
+ log_context=self.log_context,
506
+ )
507
+ raise ClientException(message) from cccerr
508
+ except ClientConnectorError as cceerr:
509
+ self.clear_session()
510
+ message = f"ClientConnectorError[{cceerr}]"
511
+ log_boundary_error(
512
+ logger=_LOGGER,
513
+ boundary="json-rpc",
514
+ action=str(method),
515
+ err=cceerr,
516
+ level=logging.ERROR,
517
+ log_context=self.log_context,
518
+ )
519
+ raise ClientException(message) from cceerr
520
+ except (ClientError, OSError) as err:
521
+ self.clear_session()
522
+ log_boundary_error(
523
+ logger=_LOGGER,
524
+ boundary="json-rpc",
525
+ action=str(method),
526
+ err=err,
527
+ level=logging.ERROR,
528
+ log_context=self.log_context,
529
+ )
530
+ raise NoConnectionException(err) from err
531
+ except (TypeError, Exception) as exc:
532
+ self.clear_session()
533
+ log_boundary_error(
534
+ logger=_LOGGER,
535
+ boundary="json-rpc",
536
+ action=str(method),
537
+ err=exc,
538
+ level=logging.ERROR,
539
+ log_context=self.log_context,
540
+ )
541
+ raise ClientException(exc) from exc
542
+
543
+ def _record_session(
544
+ self,
545
+ *,
546
+ method: str,
547
+ params: Mapping[str, Any],
548
+ response: dict[str, Any] | None = None,
549
+ exc: Exception | None = None,
550
+ ) -> bool:
551
+ """Record the session."""
552
+ if method == _JsonRpcMethod.SESSION_LOGIN and isinstance(params, dict):
553
+ if params.get(_JsonKey.USERNAME):
554
+ params[_JsonKey.USERNAME] = "********"
555
+ if params.get(_JsonKey.PASSWORD):
556
+ params[_JsonKey.PASSWORD] = "********"
557
+
558
+ if self._session_recorder and self._session_recorder.active:
559
+ self._session_recorder.add_json_rpc_session(
560
+ method=method, params=dict(params), response=response, session_exc=exc
561
+ )
562
+ return True
563
+ return False
564
+
565
+ async def _get_json_reponse(self, *, response: ClientResponse) -> dict[str, Any] | Any:
566
+ """Return the json object from response."""
567
+ try:
568
+ return await response.json(encoding=UTF_8)
569
+ except ValueError as verr:
570
+ _LOGGER.debug(
571
+ "DO_POST: ValueError [%s] Unable to parse JSON. Trying workaround",
572
+ extract_exc_args(exc=verr),
573
+ )
574
+ # Workaround for bug in CCU
575
+ return orjson.loads((await response.read()).decode(encoding=UTF_8))
576
+
577
+ async def logout(self) -> None:
578
+ """Logout of the backend."""
579
+ try:
580
+ await self._looper.block_till_done()
581
+ await self._do_logout(session_id=self._session_id)
582
+ except BaseHomematicException:
583
+ _LOGGER.debug("LOGOUT: logout failed")
584
+
585
+ async def stop(self) -> None:
586
+ """Stop the json rpc client."""
587
+ if self._is_internal_session:
588
+ await self._client_session.close()
589
+
590
+ async def _do_logout(self, *, session_id: str | None) -> None:
591
+ """Logout of the backend."""
592
+ if not session_id:
593
+ _LOGGER.debug("DO_LOGOUT: Not logged in. Not logging out.")
594
+ return
595
+
596
+ method = _JsonRpcMethod.SESSION_LOGOUT
597
+ params = {_JsonKey.SESSION_ID: session_id}
598
+ try:
599
+ await self._do_post(
600
+ session_id=session_id,
601
+ method=method,
602
+ extra_params=params,
603
+ )
604
+ _LOGGER.debug("DO_LOGOUT: method: %s [%s]", method, session_id)
605
+ finally:
606
+ self.clear_session()
607
+
608
+ @property
609
+ def _has_credentials(self) -> bool:
610
+ """Return if credentials are available."""
611
+ return self._username is not None and self._username != "" and self._password is not None
612
+
613
+ def clear_session(self) -> None:
614
+ """Clear the current session."""
615
+ self._session_id = None
616
+
617
+ async def execute_program(self, *, pid: str) -> bool:
618
+ """Execute a program on the backend."""
619
+ params = {
620
+ _JsonKey.ID: pid,
621
+ }
622
+
623
+ response = await self._post(method=_JsonRpcMethod.PROGRAM_EXECUTE, extra_params=params)
624
+ _LOGGER.debug("EXECUTE_PROGRAM: Executing a program")
625
+
626
+ if json_result := response[_JsonKey.RESULT]:
627
+ _LOGGER.debug(
628
+ "EXECUTE_PROGRAM: Result while executing program: %s",
629
+ str(json_result),
630
+ )
631
+
632
+ return True
633
+
634
+ async def set_program_state(self, *, pid: str, state: bool) -> bool:
635
+ """Set the program state on the backend."""
636
+ params = {
637
+ _JsonKey.ID: pid,
638
+ _JsonKey.STATE: "1" if state else "0",
639
+ }
640
+ response = await self._post_script(script_name=RegaScript.SET_PROGRAM_STATE, extra_params=params)
641
+
642
+ _LOGGER.debug("SET_PROGRAM_STATE: Setting program state: %s", state)
643
+ if json_result := response[_JsonKey.RESULT]:
644
+ _LOGGER.debug(
645
+ "SET_PROGRAM_STATE: Result while setting program state: %s",
646
+ str(json_result),
647
+ )
648
+
649
+ return True
650
+
651
+ async def set_system_variable(self, *, legacy_name: str, value: Any) -> bool:
652
+ """Set a system variable on the backend."""
653
+ params = {_JsonKey.NAME: legacy_name, _JsonKey.VALUE: value}
654
+ if isinstance(value, bool):
655
+ params[_JsonKey.VALUE] = int(value)
656
+ response = await self._post(method=_JsonRpcMethod.SYSVAR_SET_BOOL, extra_params=params)
657
+ elif isinstance(value, str):
658
+ if (clean_text := cleanup_text_from_html_tags(text=value)) != value:
659
+ params[_JsonKey.VALUE] = clean_text
660
+ _LOGGER.warning(
661
+ "SET_SYSTEM_VARIABLE: Value (%s) contains html tags. These are filtered out when writing.",
662
+ value,
663
+ )
664
+ response = await self._post_script(script_name=RegaScript.SET_SYSTEM_VARIABLE, extra_params=params)
665
+ else:
666
+ response = await self._post(method=_JsonRpcMethod.SYSVAR_SET_FLOAT, extra_params=params)
667
+
668
+ _LOGGER.debug("SET_SYSTEM_VARIABLE: Setting System variable")
669
+ if json_result := response[_JsonKey.RESULT]:
670
+ _LOGGER.debug(
671
+ "SET_SYSTEM_VARIABLE: Result while setting variable: %s",
672
+ str(json_result),
673
+ )
674
+
675
+ return True
676
+
677
+ async def delete_system_variable(self, *, name: str) -> bool:
678
+ """Delete a system variable from the backend."""
679
+ params = {_JsonKey.NAME: name}
680
+ response = await self._post(
681
+ method=_JsonRpcMethod.SYSVAR_DELETE_SYSVAR_BY_NAME,
682
+ extra_params=params,
683
+ )
684
+
685
+ _LOGGER.debug("DELETE_SYSTEM_VARIABLE: Getting System variable")
686
+ if json_result := response[_JsonKey.RESULT]:
687
+ deleted = json_result
688
+ _LOGGER.debug("DELETE_SYSTEM_VARIABLE: Deleted: %s", str(deleted))
689
+
690
+ return True
691
+
692
+ async def get_system_variable(self, *, name: str) -> Any:
693
+ """Get single system variable from the backend."""
694
+ params = {_JsonKey.NAME: name}
695
+ response = await self._post(
696
+ method=_JsonRpcMethod.SYSVAR_GET_VALUE_BY_NAME,
697
+ extra_params=params,
698
+ )
699
+
700
+ _LOGGER.debug("GET_SYSTEM_VARIABLE: Getting System variable")
701
+ return response[_JsonKey.RESULT]
702
+
703
+ async def get_all_system_variables(
704
+ self, *, markers: tuple[DescriptionMarker | str, ...]
705
+ ) -> tuple[SystemVariableData, ...]:
706
+ """Get all system variables from the backend."""
707
+ variables: list[SystemVariableData] = []
708
+
709
+ response = await self._post(
710
+ method=_JsonRpcMethod.SYSVAR_GET_ALL,
711
+ )
712
+
713
+ _LOGGER.debug("GET_ALL_SYSTEM_VARIABLES: Getting all system variables")
714
+ if json_result := response[_JsonKey.RESULT]:
715
+ descriptions = await self._get_system_variable_descriptions()
716
+ for var in json_result:
717
+ enabled_default = False
718
+ extended_sysvar = False
719
+ var_id = var[_JsonKey.ID]
720
+ legacy_name = var[_JsonKey.NAME]
721
+ is_internal = var[_JsonKey.IS_INTERNAL]
722
+ if new_name := RENAME_SYSVAR_BY_NAME.get(legacy_name):
723
+ legacy_name = new_name
724
+ if var_id in ALWAYS_ENABLE_SYSVARS_BY_ID:
725
+ enabled_default = True
726
+
727
+ if enabled_default is False and is_internal is True:
728
+ if var_id in ALWAYS_ENABLE_SYSVARS_BY_ID:
729
+ enabled_default = True
730
+ elif markers:
731
+ if DescriptionMarker.INTERNAL not in markers:
732
+ continue
733
+ enabled_default = True
734
+ elif DEFAULT_INCLUDE_INTERNAL_SYSVARS is False:
735
+ continue # type: ignore[unreachable]
736
+
737
+ description = descriptions.get(var_id)
738
+ if enabled_default is False and not is_internal and markers:
739
+ if not element_matches_key(
740
+ search_elements=markers,
741
+ compare_with=description,
742
+ ignore_case=False,
743
+ do_left_wildcard_search=True,
744
+ ):
745
+ continue
746
+ enabled_default = True
747
+
748
+ org_data_type = var[_JsonKey.TYPE]
749
+ raw_value = var[_JsonKey.VALUE]
750
+ if org_data_type == SysvarType.NUMBER:
751
+ data_type = SysvarType.FLOAT if "." in raw_value else SysvarType.INTEGER
752
+ else:
753
+ data_type = org_data_type
754
+
755
+ if description:
756
+ extended_sysvar = DescriptionMarker.HAHM in description
757
+ # Remove default markers from description
758
+ for marker in DescriptionMarker:
759
+ description = description.replace(marker, "").strip()
760
+ unit = var[_JsonKey.UNIT]
761
+ values: tuple[str, ...] | None = None
762
+ if val_list := var.get(_JsonKey.VALUE_LIST):
763
+ values = tuple(val_list.split(";"))
764
+ try:
765
+ value = parse_sys_var(data_type=data_type, raw_value=raw_value)
766
+ max_value = None
767
+ if raw_max_value := var.get(_JsonKey.MAX_VALUE):
768
+ max_value = parse_sys_var(data_type=data_type, raw_value=raw_max_value)
769
+ min_value = None
770
+ if raw_min_value := var.get(_JsonKey.MIN_VALUE):
771
+ min_value = parse_sys_var(data_type=data_type, raw_value=raw_min_value)
772
+ variables.append(
773
+ SystemVariableData(
774
+ vid=var_id,
775
+ legacy_name=legacy_name,
776
+ data_type=data_type,
777
+ description=description,
778
+ unit=unit,
779
+ value=value,
780
+ values=values,
781
+ max_value=max_value,
782
+ min_value=min_value,
783
+ extended_sysvar=extended_sysvar,
784
+ enabled_default=enabled_default,
785
+ )
786
+ )
787
+ except (ValueError, TypeError) as vterr:
788
+ _LOGGER.warning(
789
+ "GET_ALL_SYSTEM_VARIABLES failed: %s [%s] Failed to parse SysVar %s ",
790
+ vterr.__class__.__name__,
791
+ extract_exc_args(exc=vterr),
792
+ legacy_name,
793
+ )
794
+
795
+ return tuple(variables)
796
+
797
+ async def _get_program_descriptions(self) -> Mapping[str, str]:
798
+ """Get all program descriptions from the backend via script."""
799
+ descriptions: dict[str, str] = {}
800
+ try:
801
+ response = await self._post_script(script_name=RegaScript.GET_PROGRAM_DESCRIPTIONS)
802
+
803
+ _LOGGER.debug("GET_PROGRAM_DESCRIPTIONS: Getting program descriptions")
804
+ if json_result := response[_JsonKey.RESULT]:
805
+ for data in json_result:
806
+ descriptions[data[_JsonKey.ID]] = cleanup_text_from_html_tags(
807
+ text=unquote(string=data[_JsonKey.DESCRIPTION], encoding=ISO_8859_1)
808
+ )
809
+ except JSONDecodeError as jderr:
810
+ _LOGGER.error(
811
+ "GET_PROGRAM_DESCRIPTIONS failed: Unable to decode json: %s",
812
+ extract_exc_args(exc=jderr),
813
+ )
814
+ return descriptions
815
+
816
+ async def _get_system_variable_descriptions(self) -> Mapping[str, str]:
817
+ """Get all system variable descriptions from the backend via script."""
818
+ descriptions: dict[str, str] = {}
819
+ try:
820
+ response = await self._post_script(script_name=RegaScript.GET_SYSTEM_VARIABLE_DESCRIPTIONS)
821
+
822
+ _LOGGER.debug("GET_SYSTEM_VARIABLE_DESCRIPTIONS: Getting system variable descriptions")
823
+ if json_result := response[_JsonKey.RESULT]:
824
+ for data in json_result:
825
+ descriptions[data[_JsonKey.ID]] = cleanup_text_from_html_tags(
826
+ text=unquote(string=data[_JsonKey.DESCRIPTION], encoding=ISO_8859_1)
827
+ )
828
+ except JSONDecodeError as jderr:
829
+ _LOGGER.error(
830
+ "GET_SYSTEM_VARIABLE_DESCRIPTIONS failed: Unable to decode json: %s",
831
+ extract_exc_args(exc=jderr),
832
+ )
833
+ return descriptions
834
+
835
+ async def get_all_channel_ids_room(self) -> Mapping[str, set[str]]:
836
+ """Get all channel_ids per room from the backend."""
837
+ channel_ids_room: dict[str, set[str]] = {}
838
+
839
+ response = await self._post(
840
+ method=_JsonRpcMethod.ROOM_GET_ALL,
841
+ )
842
+
843
+ _LOGGER.debug("GET_ALL_CHANNEL_IDS_PER_ROOM: Getting all rooms")
844
+ if json_result := response[_JsonKey.RESULT]:
845
+ for room in json_result:
846
+ room_id = room[_JsonKey.ID]
847
+ room_name = room[_JsonKey.NAME]
848
+ if room_id not in channel_ids_room:
849
+ channel_ids_room[room_id] = set()
850
+ channel_ids_room[room_id].add(room_name)
851
+ for channel_id in room[_JsonKey.CHANNEL_IDS]:
852
+ if channel_id not in channel_ids_room:
853
+ channel_ids_room[channel_id] = set()
854
+ channel_ids_room[channel_id].add(room_name)
855
+
856
+ return channel_ids_room
857
+
858
+ async def get_all_channel_ids_function(self) -> Mapping[str, set[str]]:
859
+ """Get all channel_ids per function from the backend."""
860
+ channel_ids_function: dict[str, set[str]] = {}
861
+
862
+ response = await self._post(
863
+ method=_JsonRpcMethod.SUBSECTION_GET_ALL,
864
+ )
865
+
866
+ _LOGGER.debug("GET_ALL_CHANNEL_IDS_PER_FUNCTION: Getting all functions")
867
+ if json_result := response[_JsonKey.RESULT]:
868
+ for function in json_result:
869
+ function_id = function[_JsonKey.ID]
870
+ function_name = function[_JsonKey.NAME]
871
+ if function_id not in channel_ids_function:
872
+ channel_ids_function[function_id] = set()
873
+ channel_ids_function[function_id].add(function_name)
874
+ for channel_id in function[_JsonKey.CHANNEL_IDS]:
875
+ if channel_id not in channel_ids_function:
876
+ channel_ids_function[channel_id] = set()
877
+ channel_ids_function[channel_id].add(function_name)
878
+
879
+ return channel_ids_function
880
+
881
+ async def get_device_description(self, *, interface: Interface, address: str) -> DeviceDescription | None:
882
+ """Get device descriptions from the backend."""
883
+ device_description: DeviceDescription | None = None
884
+ params = {
885
+ _JsonKey.INTERFACE: interface,
886
+ _JsonKey.ADDRESS: address,
887
+ }
888
+
889
+ response = await self._post(method=_JsonRpcMethod.INTERFACE_GET_DEVICE_DESCRIPTION, extra_params=params)
890
+
891
+ _LOGGER.debug("GET_DEVICE_DESCRIPTION: Getting the device description")
892
+ if json_result := response[_JsonKey.RESULT]:
893
+ device_description = self._convert_device_description(json_data=json_result)
894
+
895
+ return device_description
896
+
897
+ @staticmethod
898
+ def _convert_device_description(*, json_data: dict[str, Any]) -> DeviceDescription:
899
+ """Convert json data to device description."""
900
+ device_description = DeviceDescription(
901
+ TYPE=json_data["type"],
902
+ ADDRESS=json_data["address"],
903
+ PARAMSETS=json_data["paramsets"],
904
+ )
905
+ if available_firmware := json_data.get("availableFirmware"):
906
+ device_description["AVAILABLE_FIRMWARE"] = available_firmware
907
+ if children := json_data.get("children"):
908
+ device_description["CHILDREN"] = children
909
+ if firmware := json_data.get("firmware"):
910
+ device_description["FIRMWARE"] = firmware
911
+ if firmware_updatable := json_data.get("firmwareUpdatable"):
912
+ device_description["FIRMWARE_UPDATABLE"] = firmware_updatable
913
+ if firmware_update_state := json_data.get("firmwareUpdateState"):
914
+ device_description["FIRMWARE_UPDATE_STATE"] = firmware_update_state
915
+ if interface := json_data.get("interface"):
916
+ device_description["INTERFACE"] = interface
917
+ if parent := json_data.get("parent"):
918
+ device_description["PARENT"] = parent
919
+ if link_source_role := json_data.get("linkSourceRole"):
920
+ device_description["LINK_SOURCE_ROLES"] = link_source_role
921
+ if link_target_role := json_data.get("linkTargetRole"):
922
+ device_description["LINK_TARGET_ROLES"] = link_target_role
923
+ if rx_mode := json_data.get("rxMode"):
924
+ device_description["RX_MODE"] = rx_mode
925
+ if subtype := json_data.get("subType"):
926
+ device_description["SUBTYPE"] = subtype
927
+ if updatable := json_data.get("updatable"):
928
+ device_description["UPDATABLE"] = updatable
929
+ return device_description
930
+
931
+ async def get_device_details(self) -> tuple[dict[str, Any], ...]:
932
+ """Get the device details of the backend."""
933
+ device_details: tuple[dict[str, Any], ...] = ()
934
+
935
+ response = await self._post(
936
+ method=_JsonRpcMethod.DEVICE_LIST_ALL_DETAIL,
937
+ )
938
+
939
+ _LOGGER.debug("GET_DEVICE_DETAILS: Getting the device details")
940
+ if json_result := response[_JsonKey.RESULT]:
941
+ device_details = tuple(json_result)
942
+
943
+ return device_details
944
+
945
+ async def get_paramset(
946
+ self, *, interface: Interface, address: str, paramset_key: ParamsetKey | str
947
+ ) -> dict[str, Any] | None:
948
+ """Get paramset from the backend."""
949
+ paramset: dict[str, Any] = {}
950
+ params = {
951
+ _JsonKey.INTERFACE: interface,
952
+ _JsonKey.ADDRESS: address,
953
+ _JsonKey.PARAMSET_KEY: paramset_key,
954
+ }
955
+
956
+ response = await self._post(
957
+ method=_JsonRpcMethod.INTERFACE_GET_PARAMSET,
958
+ extra_params=params,
959
+ )
960
+
961
+ _LOGGER.debug("GET_PARAMSET: Getting the paramset")
962
+ if json_result := response[_JsonKey.RESULT]:
963
+ paramset = json_result
964
+
965
+ return paramset
966
+
967
+ async def put_paramset(
968
+ self,
969
+ *,
970
+ interface: Interface,
971
+ address: str,
972
+ paramset_key: ParamsetKey | str,
973
+ values: list[dict[str, Any]],
974
+ ) -> None:
975
+ """Set paramset to the backend."""
976
+ params = {
977
+ _JsonKey.INTERFACE: interface,
978
+ _JsonKey.ADDRESS: address,
979
+ _JsonKey.PARAMSET_KEY: paramset_key,
980
+ _JsonKey.SET: values,
981
+ }
982
+
983
+ response = await self._post(
984
+ method=_JsonRpcMethod.INTERFACE_PUT_PARAMSET,
985
+ extra_params=params,
986
+ )
987
+
988
+ _LOGGER.debug("PUT_PARAMSET: Putting the paramset")
989
+ if json_result := response[_JsonKey.RESULT]:
990
+ _LOGGER.debug(
991
+ "PUT_PARAMSET: Result while putting the paramset: %s",
992
+ str(json_result),
993
+ )
994
+
995
+ async def get_value(self, *, interface: Interface, address: str, paramset_key: ParamsetKey, parameter: str) -> Any:
996
+ """Get value from the backend."""
997
+ value: Any = None
998
+ params = {
999
+ _JsonKey.INTERFACE: interface,
1000
+ _JsonKey.ADDRESS: address,
1001
+ _JsonKey.VALUE_KEY: parameter,
1002
+ }
1003
+
1004
+ response = (
1005
+ await self._post(method=_JsonRpcMethod.INTERFACE_GET_MASTER_VALUE, extra_params=params)
1006
+ if paramset_key == ParamsetKey.MASTER
1007
+ else await self._post(method=_JsonRpcMethod.INTERFACE_GET_VALUE, extra_params=params)
1008
+ )
1009
+
1010
+ _LOGGER.debug("GET_VALUE: Getting the value")
1011
+ if json_result := response[_JsonKey.RESULT]:
1012
+ value = json_result
1013
+
1014
+ return value
1015
+
1016
+ async def set_value(
1017
+ self, *, interface: Interface, address: str, parameter: str, value_type: str, value: Any
1018
+ ) -> None:
1019
+ """Set value to the backend."""
1020
+ params = {
1021
+ _JsonKey.INTERFACE: interface,
1022
+ _JsonKey.ADDRESS: address,
1023
+ _JsonKey.VALUE_KEY: parameter,
1024
+ _JsonKey.TYPE: value_type,
1025
+ _JsonKey.VALUE: value,
1026
+ }
1027
+
1028
+ response = await self._post(
1029
+ method=_JsonRpcMethod.INTERFACE_SET_VALUE,
1030
+ extra_params=params,
1031
+ )
1032
+
1033
+ _LOGGER.debug("SET_VALUE: Setting the value")
1034
+ if json_result := response[_JsonKey.RESULT]:
1035
+ _LOGGER.debug(
1036
+ "SET_VALUE: Result while setting the value: %s",
1037
+ str(json_result),
1038
+ )
1039
+
1040
+ async def get_paramset_description(
1041
+ self, *, interface: Interface, address: str, paramset_key: ParamsetKey
1042
+ ) -> Mapping[str, ParameterData] | None:
1043
+ """Get paramset description from the backend."""
1044
+ paramset_description: dict[str, ParameterData] = {}
1045
+ params = {
1046
+ _JsonKey.INTERFACE: interface,
1047
+ _JsonKey.ADDRESS: address,
1048
+ _JsonKey.PARAMSET_KEY: paramset_key,
1049
+ }
1050
+
1051
+ response = await self._post(method=_JsonRpcMethod.INTERFACE_GET_PARAMSET_DESCRIPTION, extra_params=params)
1052
+
1053
+ _LOGGER.debug("GET_PARAMSET_DESCRIPTIONS: Getting the paramset descriptions")
1054
+ if json_result := response[_JsonKey.RESULT]:
1055
+ paramset_description = {data["NAME"]: self._convert_parameter_data(json_data=data) for data in json_result}
1056
+
1057
+ return paramset_description
1058
+
1059
+ @staticmethod
1060
+ def _convert_parameter_data(*, json_data: dict[str, Any]) -> ParameterData:
1061
+ """Convert json data to parameter data."""
1062
+
1063
+ _type = json_data["TYPE"]
1064
+ _value_list = json_data.get("VALUE_LIST", ())
1065
+
1066
+ parameter_data = ParameterData(
1067
+ DEFAULT=convert_value(value=json_data["DEFAULT"], target_type=_type, value_list=_value_list),
1068
+ FLAGS=int(json_data["FLAGS"]),
1069
+ ID=json_data["ID"],
1070
+ MAX=convert_value(value=json_data.get("MAX"), target_type=_type, value_list=_value_list),
1071
+ MIN=convert_value(value=json_data.get("MIN"), target_type=_type, value_list=_value_list),
1072
+ OPERATIONS=int(json_data["OPERATIONS"]),
1073
+ TYPE=_type,
1074
+ )
1075
+ if special := json_data.get("SPECIAL"):
1076
+ parameter_data["SPECIAL"] = special
1077
+ if unit := json_data.get("UNIT"):
1078
+ parameter_data["UNIT"] = str(unit)
1079
+ if value_list := _value_list:
1080
+ parameter_data["VALUE_LIST"] = value_list.split(" ")
1081
+
1082
+ return parameter_data
1083
+
1084
+ async def get_all_device_data(self, *, interface: Interface) -> Mapping[str, Any]:
1085
+ """Get the all device data of the backend."""
1086
+ all_device_data: dict[str, Any] = {}
1087
+ params = {
1088
+ _JsonKey.INTERFACE: interface,
1089
+ }
1090
+ try:
1091
+ response = await self._post_script(script_name=RegaScript.FETCH_ALL_DEVICE_DATA, extra_params=params)
1092
+
1093
+ _LOGGER.debug("GET_ALL_DEVICE_DATA: Getting all device data for interface %s", interface)
1094
+ if json_result := response[_JsonKey.RESULT]:
1095
+ all_device_data = {
1096
+ unquote(string=k, encoding=ISO_8859_1): unquote(string=v, encoding=ISO_8859_1)
1097
+ if isinstance(v, str)
1098
+ else v
1099
+ for k, v in json_result.items()
1100
+ }
1101
+
1102
+ except (ContentTypeError, JSONDecodeError) as cerr:
1103
+ raise ClientException(
1104
+ f"GET_ALL_DEVICE_DATA failed: Unable to fetch device data for interface {interface}"
1105
+ ) from cerr
1106
+
1107
+ return all_device_data
1108
+
1109
+ async def get_all_programs(self, *, markers: tuple[DescriptionMarker | str, ...]) -> tuple[ProgramData, ...]:
1110
+ """Get the all programs of the backend."""
1111
+ all_programs: list[ProgramData] = []
1112
+
1113
+ response = await self._post(
1114
+ method=_JsonRpcMethod.PROGRAM_GET_ALL,
1115
+ )
1116
+
1117
+ _LOGGER.debug("GET_ALL_PROGRAMS: Getting all programs")
1118
+ if json_result := response[_JsonKey.RESULT]:
1119
+ descriptions = await self._get_program_descriptions()
1120
+ for prog in json_result:
1121
+ enabled_default = False
1122
+ if (is_internal := prog[_JsonKey.IS_INTERNAL]) is True:
1123
+ if markers:
1124
+ if DescriptionMarker.INTERNAL not in markers:
1125
+ continue
1126
+ enabled_default = True
1127
+ elif DEFAULT_INCLUDE_INTERNAL_PROGRAMS is False:
1128
+ continue
1129
+
1130
+ pid = prog[_JsonKey.ID]
1131
+ description = descriptions.get(pid)
1132
+ if not is_internal and markers:
1133
+ if not element_matches_key(
1134
+ search_elements=markers,
1135
+ compare_with=description,
1136
+ ignore_case=False,
1137
+ do_left_wildcard_search=True,
1138
+ ):
1139
+ continue
1140
+ enabled_default = True
1141
+ if description:
1142
+ # Remove default markers from description
1143
+ for marker in DescriptionMarker:
1144
+ description = description.replace(marker, "").strip()
1145
+ name = prog[_JsonKey.NAME]
1146
+ is_active = prog[_JsonKey.IS_ACTIVE]
1147
+ last_execute_time = prog[_JsonKey.LAST_EXECUTE_TIME]
1148
+
1149
+ all_programs.append(
1150
+ ProgramData(
1151
+ pid=pid,
1152
+ legacy_name=name,
1153
+ description=description,
1154
+ is_active=is_active,
1155
+ is_internal=is_internal,
1156
+ last_execute_time=last_execute_time,
1157
+ enabled_default=enabled_default,
1158
+ )
1159
+ )
1160
+
1161
+ return tuple(all_programs)
1162
+
1163
+ async def is_present(self, *, interface: Interface) -> bool:
1164
+ """Get value from the backend."""
1165
+ value: bool = False
1166
+ params = {_JsonKey.INTERFACE: interface}
1167
+
1168
+ response = await self._post(method=_JsonRpcMethod.INTERFACE_IS_PRESENT, extra_params=params)
1169
+
1170
+ _LOGGER.debug("IS_PRESENT: Getting the value")
1171
+ if json_result := response[_JsonKey.RESULT]:
1172
+ value = bool(json_result)
1173
+
1174
+ return value
1175
+
1176
+ async def has_program_ids(self, *, channel_hmid: str) -> bool:
1177
+ """Return if a channel has program ids."""
1178
+ params = {_JsonKey.ID: channel_hmid}
1179
+ response = await self._post(
1180
+ method=_JsonRpcMethod.CHANNEL_HAS_PROGRAM_IDS,
1181
+ extra_params=params,
1182
+ )
1183
+
1184
+ _LOGGER.debug("HAS_PROGRAM_IDS: Checking if channel has program ids")
1185
+ if json_result := response[_JsonKey.RESULT]:
1186
+ return bool(json_result)
1187
+
1188
+ return False
1189
+
1190
+ async def _get_supported_methods(self) -> tuple[str, ...]:
1191
+ """Get the supported methods of the backend."""
1192
+ supported_methods: tuple[str, ...] = ()
1193
+
1194
+ try:
1195
+ await self._login_or_renew()
1196
+ if not (session_id := self._session_id):
1197
+ raise ClientException("Error while logging in")
1198
+
1199
+ response = await self._do_post(
1200
+ session_id=session_id,
1201
+ method=_JsonRpcMethod.SYSTEM_LIST_METHODS,
1202
+ )
1203
+
1204
+ _LOGGER.debug("GET_SUPPORTED_METHODS: Getting the supported methods")
1205
+ if json_result := response[_JsonKey.RESULT]:
1206
+ supported_methods = tuple(method_description[_JsonKey.NAME] for method_description in json_result)
1207
+ except BaseHomematicException:
1208
+ return ()
1209
+
1210
+ return supported_methods
1211
+
1212
+ async def _check_supported_methods(self) -> bool:
1213
+ """Check, if all required api methods are supported by the backend."""
1214
+ if self._supported_methods is None:
1215
+ self._supported_methods = await self._get_supported_methods()
1216
+ if unsupport_methods := tuple(method for method in _JsonRpcMethod if method not in self._supported_methods):
1217
+ _LOGGER.warning(
1218
+ "CHECK_SUPPORTED_METHODS: methods not supported by the backend: %s",
1219
+ ", ".join(unsupport_methods),
1220
+ )
1221
+ return False
1222
+ return True
1223
+
1224
+ async def get_system_information(self) -> SystemInformation:
1225
+ """Get system information of the the backend."""
1226
+
1227
+ if (auth_enabled := await self._get_auth_enabled()) is not None and (
1228
+ system_information := SystemInformation(
1229
+ auth_enabled=auth_enabled,
1230
+ available_interfaces=await self._list_interfaces(),
1231
+ https_redirect_enabled=await self._get_https_redirect_enabled(),
1232
+ serial=await self._get_serial(),
1233
+ )
1234
+ ):
1235
+ return system_information
1236
+
1237
+ return SystemInformation(auth_enabled=True)
1238
+
1239
+ async def _get_auth_enabled(self) -> bool:
1240
+ """Get the auth_enabled flag of the backend."""
1241
+ _LOGGER.debug("GET_AUTH_ENABLED: Getting the flag auth_enabled")
1242
+ try:
1243
+ response = await self._post(method=_JsonRpcMethod.CCU_GET_AUTH_ENABLED)
1244
+ if (json_result := response[_JsonKey.RESULT]) is not None:
1245
+ return bool(json_result)
1246
+ except InternalBackendException:
1247
+ return True
1248
+
1249
+ return True
1250
+
1251
+ async def list_devices(self, *, interface: Interface) -> tuple[DeviceDescription, ...]:
1252
+ """List devices from the backend."""
1253
+ devices: tuple[DeviceDescription, ...] = ()
1254
+ _LOGGER.debug("LIST_DEVICES: Getting all available interfaces")
1255
+ params = {
1256
+ _JsonKey.INTERFACE: interface,
1257
+ }
1258
+
1259
+ response = await self._post(
1260
+ method=_JsonRpcMethod.INTERFACE_LIST_DEVICES,
1261
+ extra_params=params,
1262
+ )
1263
+
1264
+ if json_result := response[_JsonKey.RESULT]:
1265
+ devices = tuple(self._convert_device_description(json_data=data) for data in json_result)
1266
+
1267
+ return devices
1268
+
1269
+ async def _list_interfaces(self) -> tuple[str, ...]:
1270
+ """List all available interfaces from the backend."""
1271
+ _LOGGER.debug("LIST_INTERFACES: Getting all available interfaces")
1272
+
1273
+ response = await self._post(
1274
+ method=_JsonRpcMethod.INTERFACE_LIST_INTERFACES,
1275
+ )
1276
+
1277
+ if json_result := response[_JsonKey.RESULT]:
1278
+ return tuple(interface[_JsonKey.NAME] for interface in json_result)
1279
+ return ()
1280
+
1281
+ async def _get_https_redirect_enabled(self) -> bool | None:
1282
+ """Get the auth_enabled flag of the backend."""
1283
+ _LOGGER.debug("GET_HTTPS_REDIRECT_ENABLED: Getting the flag https_redirect_enabled")
1284
+
1285
+ response = await self._post(method=_JsonRpcMethod.CCU_GET_HTTPS_REDIRECT_ENABLED)
1286
+ if (json_result := response[_JsonKey.RESULT]) is not None:
1287
+ return bool(json_result)
1288
+ return None
1289
+
1290
+ async def _get_serial(self) -> str | None:
1291
+ """Get the serial of the backend."""
1292
+ _LOGGER.debug("GET_SERIAL: Getting the backend serial")
1293
+ try:
1294
+ response = await self._post_script(script_name=RegaScript.GET_SERIAL)
1295
+
1296
+ if json_result := response[_JsonKey.RESULT]:
1297
+ # The backend may return a JSON string which needs to be decoded first
1298
+ # or an already-parsed dict. Support both.
1299
+ if isinstance(json_result, str):
1300
+ try:
1301
+ json_result = orjson.loads(json_result)
1302
+ except Exception:
1303
+ # Fall back to plain string handling; return last 10 chars
1304
+ serial_exc = str(json_result)
1305
+ return serial_exc[-10:] if len(serial_exc) > 10 else serial_exc
1306
+ serial: str = str(json_result.get(_JsonKey.SERIAL) if isinstance(json_result, dict) else json_result)
1307
+ if len(serial) > 10:
1308
+ serial = serial[-10:]
1309
+ return serial
1310
+ except JSONDecodeError as jderr:
1311
+ raise ClientException(jderr) from jderr
1312
+ return None
1313
+
1314
+
1315
+ def _get_params(
1316
+ *,
1317
+ session_id: bool | str,
1318
+ extra_params: dict[_JsonKey, Any] | None,
1319
+ use_default_params: bool,
1320
+ ) -> Mapping[str, Any]:
1321
+ """Add additional params to default prams."""
1322
+ params: dict[_JsonKey, Any] = {_JsonKey.SESSION_ID: session_id} if use_default_params else {}
1323
+ if extra_params:
1324
+ params.update(extra_params)
1325
+
1326
+ return {str(key): str(value) for key, value in params.items()}