aiohomematic 2026.1.29__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.
Files changed (188) hide show
  1. aiohomematic/__init__.py +110 -0
  2. aiohomematic/_log_context_protocol.py +29 -0
  3. aiohomematic/api.py +410 -0
  4. aiohomematic/async_support.py +250 -0
  5. aiohomematic/backend_detection.py +462 -0
  6. aiohomematic/central/__init__.py +103 -0
  7. aiohomematic/central/async_rpc_server.py +760 -0
  8. aiohomematic/central/central_unit.py +1152 -0
  9. aiohomematic/central/config.py +463 -0
  10. aiohomematic/central/config_builder.py +772 -0
  11. aiohomematic/central/connection_state.py +160 -0
  12. aiohomematic/central/coordinators/__init__.py +38 -0
  13. aiohomematic/central/coordinators/cache.py +414 -0
  14. aiohomematic/central/coordinators/client.py +480 -0
  15. aiohomematic/central/coordinators/connection_recovery.py +1141 -0
  16. aiohomematic/central/coordinators/device.py +1166 -0
  17. aiohomematic/central/coordinators/event.py +514 -0
  18. aiohomematic/central/coordinators/hub.py +532 -0
  19. aiohomematic/central/decorators.py +184 -0
  20. aiohomematic/central/device_registry.py +229 -0
  21. aiohomematic/central/events/__init__.py +104 -0
  22. aiohomematic/central/events/bus.py +1392 -0
  23. aiohomematic/central/events/integration.py +424 -0
  24. aiohomematic/central/events/types.py +194 -0
  25. aiohomematic/central/health.py +762 -0
  26. aiohomematic/central/rpc_server.py +353 -0
  27. aiohomematic/central/scheduler.py +794 -0
  28. aiohomematic/central/state_machine.py +391 -0
  29. aiohomematic/client/__init__.py +203 -0
  30. aiohomematic/client/_rpc_errors.py +187 -0
  31. aiohomematic/client/backends/__init__.py +48 -0
  32. aiohomematic/client/backends/base.py +335 -0
  33. aiohomematic/client/backends/capabilities.py +138 -0
  34. aiohomematic/client/backends/ccu.py +487 -0
  35. aiohomematic/client/backends/factory.py +116 -0
  36. aiohomematic/client/backends/homegear.py +294 -0
  37. aiohomematic/client/backends/json_ccu.py +252 -0
  38. aiohomematic/client/backends/protocol.py +316 -0
  39. aiohomematic/client/ccu.py +1857 -0
  40. aiohomematic/client/circuit_breaker.py +459 -0
  41. aiohomematic/client/config.py +64 -0
  42. aiohomematic/client/handlers/__init__.py +40 -0
  43. aiohomematic/client/handlers/backup.py +157 -0
  44. aiohomematic/client/handlers/base.py +79 -0
  45. aiohomematic/client/handlers/device_ops.py +1085 -0
  46. aiohomematic/client/handlers/firmware.py +144 -0
  47. aiohomematic/client/handlers/link_mgmt.py +199 -0
  48. aiohomematic/client/handlers/metadata.py +436 -0
  49. aiohomematic/client/handlers/programs.py +144 -0
  50. aiohomematic/client/handlers/sysvars.py +100 -0
  51. aiohomematic/client/interface_client.py +1304 -0
  52. aiohomematic/client/json_rpc.py +2068 -0
  53. aiohomematic/client/request_coalescer.py +282 -0
  54. aiohomematic/client/rpc_proxy.py +629 -0
  55. aiohomematic/client/state_machine.py +324 -0
  56. aiohomematic/const.py +2207 -0
  57. aiohomematic/context.py +275 -0
  58. aiohomematic/converter.py +270 -0
  59. aiohomematic/decorators.py +390 -0
  60. aiohomematic/exceptions.py +185 -0
  61. aiohomematic/hmcli.py +997 -0
  62. aiohomematic/i18n.py +193 -0
  63. aiohomematic/interfaces/__init__.py +407 -0
  64. aiohomematic/interfaces/central.py +1067 -0
  65. aiohomematic/interfaces/client.py +1096 -0
  66. aiohomematic/interfaces/coordinators.py +63 -0
  67. aiohomematic/interfaces/model.py +1921 -0
  68. aiohomematic/interfaces/operations.py +217 -0
  69. aiohomematic/logging_context.py +134 -0
  70. aiohomematic/metrics/__init__.py +125 -0
  71. aiohomematic/metrics/_protocols.py +140 -0
  72. aiohomematic/metrics/aggregator.py +534 -0
  73. aiohomematic/metrics/dataclasses.py +489 -0
  74. aiohomematic/metrics/emitter.py +292 -0
  75. aiohomematic/metrics/events.py +183 -0
  76. aiohomematic/metrics/keys.py +300 -0
  77. aiohomematic/metrics/observer.py +563 -0
  78. aiohomematic/metrics/stats.py +172 -0
  79. aiohomematic/model/__init__.py +189 -0
  80. aiohomematic/model/availability.py +65 -0
  81. aiohomematic/model/calculated/__init__.py +89 -0
  82. aiohomematic/model/calculated/climate.py +276 -0
  83. aiohomematic/model/calculated/data_point.py +315 -0
  84. aiohomematic/model/calculated/field.py +147 -0
  85. aiohomematic/model/calculated/operating_voltage_level.py +286 -0
  86. aiohomematic/model/calculated/support.py +232 -0
  87. aiohomematic/model/custom/__init__.py +214 -0
  88. aiohomematic/model/custom/capabilities/__init__.py +67 -0
  89. aiohomematic/model/custom/capabilities/climate.py +41 -0
  90. aiohomematic/model/custom/capabilities/light.py +87 -0
  91. aiohomematic/model/custom/capabilities/lock.py +44 -0
  92. aiohomematic/model/custom/capabilities/siren.py +63 -0
  93. aiohomematic/model/custom/climate.py +1130 -0
  94. aiohomematic/model/custom/cover.py +722 -0
  95. aiohomematic/model/custom/data_point.py +360 -0
  96. aiohomematic/model/custom/definition.py +300 -0
  97. aiohomematic/model/custom/field.py +89 -0
  98. aiohomematic/model/custom/light.py +1174 -0
  99. aiohomematic/model/custom/lock.py +322 -0
  100. aiohomematic/model/custom/mixins.py +445 -0
  101. aiohomematic/model/custom/profile.py +945 -0
  102. aiohomematic/model/custom/registry.py +251 -0
  103. aiohomematic/model/custom/siren.py +462 -0
  104. aiohomematic/model/custom/switch.py +195 -0
  105. aiohomematic/model/custom/text_display.py +289 -0
  106. aiohomematic/model/custom/valve.py +78 -0
  107. aiohomematic/model/data_point.py +1416 -0
  108. aiohomematic/model/device.py +1840 -0
  109. aiohomematic/model/event.py +216 -0
  110. aiohomematic/model/generic/__init__.py +327 -0
  111. aiohomematic/model/generic/action.py +40 -0
  112. aiohomematic/model/generic/action_select.py +62 -0
  113. aiohomematic/model/generic/binary_sensor.py +30 -0
  114. aiohomematic/model/generic/button.py +31 -0
  115. aiohomematic/model/generic/data_point.py +177 -0
  116. aiohomematic/model/generic/dummy.py +150 -0
  117. aiohomematic/model/generic/number.py +76 -0
  118. aiohomematic/model/generic/select.py +56 -0
  119. aiohomematic/model/generic/sensor.py +76 -0
  120. aiohomematic/model/generic/switch.py +54 -0
  121. aiohomematic/model/generic/text.py +33 -0
  122. aiohomematic/model/hub/__init__.py +100 -0
  123. aiohomematic/model/hub/binary_sensor.py +24 -0
  124. aiohomematic/model/hub/button.py +28 -0
  125. aiohomematic/model/hub/connectivity.py +190 -0
  126. aiohomematic/model/hub/data_point.py +342 -0
  127. aiohomematic/model/hub/hub.py +864 -0
  128. aiohomematic/model/hub/inbox.py +135 -0
  129. aiohomematic/model/hub/install_mode.py +393 -0
  130. aiohomematic/model/hub/metrics.py +208 -0
  131. aiohomematic/model/hub/number.py +42 -0
  132. aiohomematic/model/hub/select.py +52 -0
  133. aiohomematic/model/hub/sensor.py +37 -0
  134. aiohomematic/model/hub/switch.py +43 -0
  135. aiohomematic/model/hub/text.py +30 -0
  136. aiohomematic/model/hub/update.py +221 -0
  137. aiohomematic/model/support.py +592 -0
  138. aiohomematic/model/update.py +140 -0
  139. aiohomematic/model/week_profile.py +1827 -0
  140. aiohomematic/property_decorators.py +719 -0
  141. aiohomematic/py.typed +0 -0
  142. aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
  143. aiohomematic/rega_scripts/create_backup_start.fn +28 -0
  144. aiohomematic/rega_scripts/create_backup_status.fn +89 -0
  145. aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
  146. aiohomematic/rega_scripts/get_backend_info.fn +25 -0
  147. aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
  148. aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
  149. aiohomematic/rega_scripts/get_serial.fn +44 -0
  150. aiohomematic/rega_scripts/get_service_messages.fn +83 -0
  151. aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
  152. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
  153. aiohomematic/rega_scripts/set_program_state.fn +17 -0
  154. aiohomematic/rega_scripts/set_system_variable.fn +19 -0
  155. aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
  156. aiohomematic/schemas.py +256 -0
  157. aiohomematic/store/__init__.py +55 -0
  158. aiohomematic/store/dynamic/__init__.py +43 -0
  159. aiohomematic/store/dynamic/command.py +250 -0
  160. aiohomematic/store/dynamic/data.py +175 -0
  161. aiohomematic/store/dynamic/details.py +187 -0
  162. aiohomematic/store/dynamic/ping_pong.py +416 -0
  163. aiohomematic/store/persistent/__init__.py +71 -0
  164. aiohomematic/store/persistent/base.py +285 -0
  165. aiohomematic/store/persistent/device.py +233 -0
  166. aiohomematic/store/persistent/incident.py +380 -0
  167. aiohomematic/store/persistent/paramset.py +241 -0
  168. aiohomematic/store/persistent/session.py +556 -0
  169. aiohomematic/store/serialization.py +150 -0
  170. aiohomematic/store/storage.py +689 -0
  171. aiohomematic/store/types.py +526 -0
  172. aiohomematic/store/visibility/__init__.py +40 -0
  173. aiohomematic/store/visibility/parser.py +141 -0
  174. aiohomematic/store/visibility/registry.py +722 -0
  175. aiohomematic/store/visibility/rules.py +307 -0
  176. aiohomematic/strings.json +237 -0
  177. aiohomematic/support.py +706 -0
  178. aiohomematic/tracing.py +236 -0
  179. aiohomematic/translations/de.json +237 -0
  180. aiohomematic/translations/en.json +237 -0
  181. aiohomematic/type_aliases.py +51 -0
  182. aiohomematic/validator.py +128 -0
  183. aiohomematic-2026.1.29.dist-info/METADATA +296 -0
  184. aiohomematic-2026.1.29.dist-info/RECORD +188 -0
  185. aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
  186. aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
  187. aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
  188. aiohomematic-2026.1.29.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2068 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
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 TYPE_CHECKING, Any, Final
46
+ from urllib.parse import unquote
47
+
48
+ if TYPE_CHECKING:
49
+ from aiohomematic.central.events import EventBus
50
+ from aiohomematic.interfaces import IncidentRecorderProtocol
51
+
52
+ from aiohttp import (
53
+ ClientConnectorCertificateError,
54
+ ClientConnectorError,
55
+ ClientError,
56
+ ClientResponse,
57
+ ClientSession,
58
+ ClientTimeout,
59
+ ContentTypeError,
60
+ TCPConnector,
61
+ )
62
+ import orjson
63
+
64
+ from aiohomematic import central as hmcu, i18n
65
+ from aiohomematic.async_support import Looper
66
+ from aiohomematic.client import CircuitBreaker, CircuitBreakerConfig
67
+ from aiohomematic.client._rpc_errors import RpcContext, map_jsonrpc_error, sanitize_error_message
68
+ from aiohomematic.const import (
69
+ ALWAYS_ENABLE_SYSVARS_BY_ID,
70
+ DEFAULT_INCLUDE_INTERNAL_PROGRAMS,
71
+ DEFAULT_INCLUDE_INTERNAL_SYSVARS,
72
+ ISO_8859_1,
73
+ JSON_SESSION_AGE,
74
+ LOGIN_BACKOFF_MULTIPLIER,
75
+ LOGIN_INITIAL_BACKOFF_SECONDS,
76
+ LOGIN_MAX_BACKOFF_SECONDS,
77
+ LOGIN_MAX_FAILED_ATTEMPTS,
78
+ MAX_CONCURRENT_HTTP_SESSIONS,
79
+ PATH_JSON_RPC,
80
+ REGA_SCRIPT_PATH,
81
+ RENAME_SYSVAR_BY_NAME,
82
+ TIMEOUT,
83
+ UTF_8,
84
+ BackupStatus,
85
+ BackupStatusData,
86
+ CCUType,
87
+ DescriptionMarker,
88
+ DeviceDescription,
89
+ DeviceDetail,
90
+ HubValueType,
91
+ InboxDeviceData,
92
+ Interface,
93
+ ParameterData,
94
+ ParamsetKey,
95
+ ProgramData,
96
+ RegaScript,
97
+ ServiceMessageData,
98
+ ServiceMessageType,
99
+ SystemInformation,
100
+ SystemUpdateData,
101
+ SystemVariableData,
102
+ )
103
+ from aiohomematic.exceptions import (
104
+ AuthFailure,
105
+ BaseHomematicException,
106
+ CircuitBreakerOpenException,
107
+ ClientException,
108
+ InternalBackendException,
109
+ NoConnectionException,
110
+ UnsupportedException,
111
+ )
112
+ from aiohomematic.model.support import convert_value
113
+ from aiohomematic.property_decorators import DelegatedProperty
114
+ from aiohomematic.store.persistent import SessionRecorder
115
+ from aiohomematic.store.types import IncidentSeverity, IncidentType
116
+ from aiohomematic.support import (
117
+ LogContextMixin,
118
+ cleanup_script_for_session_recorder,
119
+ cleanup_text_from_html_tags,
120
+ element_matches_key,
121
+ extract_exc_args,
122
+ get_tls_context,
123
+ is_device_address,
124
+ log_boundary_error,
125
+ parse_sys_var,
126
+ )
127
+
128
+ _LOGGER: Final = logging.getLogger(__name__)
129
+
130
+
131
+ class _JsonKey(StrEnum):
132
+ """Enum for Homematic json keys."""
133
+
134
+ ACTION = "action"
135
+ ADDRESS = "address"
136
+ AVAILABLE_FIRMWARE = "available_firmware"
137
+ CHANNELS = "channels"
138
+ CHANNEL_IDS = "channelIds"
139
+ CHECK_SCRIPT_AVAILABLE = "check_script_available"
140
+ CURRENT_FIRMWARE = "current_firmware"
141
+ DESCRIPTION = "description"
142
+ DEVICE_ADDRESS = "device_address"
143
+ DEVICE_NAME = "device_name"
144
+ ERROR = "error"
145
+ FILE = "file"
146
+ FILENAME = "filename"
147
+ HOSTNAME = "hostname"
148
+ ID = "id"
149
+ INSTALL_MODE = "installMode"
150
+ INTERFACE = "interface"
151
+ IS_ACTIVE = "isActive"
152
+ IS_INTERNAL = "isInternal"
153
+ KEY = "key"
154
+ KEYMODE = "keymode"
155
+ LAST_EXECUTE_TIME = "lastExecuteTime"
156
+ MAX_VALUE = "maxValue"
157
+ MESSAGE = "message"
158
+ MIN_VALUE = "minValue"
159
+ MODE = "mode"
160
+ NAME = "name"
161
+ ON = "on"
162
+ PARAMSET_KEY = "paramsetKey"
163
+ PASSWORD = "password"
164
+ PRODUCT = "product"
165
+ RESULT = "result"
166
+ SCRIPT = "script"
167
+ SERIAL = "serial"
168
+ SESSION_ID = "_session_id_"
169
+ SET = "set"
170
+ SID = "sid"
171
+ SIZE = "size"
172
+ STATE = "state"
173
+ STATUS = "status"
174
+ SUCCESS = "success"
175
+ TIME = "time"
176
+ TIMESTAMP = "timestamp"
177
+ TYPE = "type"
178
+ UNIT = "unit"
179
+ UPDATE_AVAILABLE = "update_available"
180
+ URL = "url"
181
+ USERNAME = "username"
182
+ VALUE = "value"
183
+ VALUE_KEY = "valueKey"
184
+ VALUE_LIST = "valueList"
185
+ VERSION = "version"
186
+
187
+
188
+ class _JsonRpcMethod(StrEnum):
189
+ """Enum for Homematic json rpc methods types."""
190
+
191
+ CCU_GET_AUTH_ENABLED = "CCU.getAuthEnabled"
192
+ CCU_GET_HTTPS_REDIRECT_ENABLED = "CCU.getHttpsRedirectEnabled"
193
+ CHANNEL_HAS_PROGRAM_IDS = "Channel.hasProgramIds"
194
+ CHANNEL_SET_NAME = "Channel.setName"
195
+ DEVICE_LIST_ALL_DETAIL = "Device.listAllDetail"
196
+ DEVICE_SET_NAME = "Device.setName"
197
+ INTERFACE_GET_DEVICE_DESCRIPTION = "Interface.getDeviceDescription"
198
+ INTERFACE_GET_INSTALL_MODE = "Interface.getInstallMode"
199
+ INTERFACE_GET_MASTER_VALUE = "Interface.getMasterValue"
200
+ INTERFACE_GET_PARAMSET = "Interface.getParamset"
201
+ INTERFACE_GET_PARAMSET_DESCRIPTION = "Interface.getParamsetDescription"
202
+ INTERFACE_GET_VALUE = "Interface.getValue"
203
+ INTERFACE_IS_PRESENT = "Interface.isPresent"
204
+ INTERFACE_LIST_DEVICES = "Interface.listDevices"
205
+ INTERFACE_LIST_INTERFACES = "Interface.listInterfaces"
206
+ INTERFACE_PUT_PARAMSET = "Interface.putParamset"
207
+ INTERFACE_SET_INSTALL_MODE_HMIP = "Interface.setInstallModeHMIP"
208
+ INTERFACE_SET_VALUE = "Interface.setValue"
209
+ PROGRAM_EXECUTE = "Program.execute"
210
+ PROGRAM_GET_ALL = "Program.getAll"
211
+ REGA_RUN_SCRIPT = "ReGa.runScript"
212
+ ROOM_GET_ALL = "Room.getAll"
213
+ SESSION_LOGIN = "Session.login"
214
+ SESSION_LOGOUT = "Session.logout"
215
+ SESSION_RENEW = "Session.renew"
216
+ SUBSECTION_GET_ALL = "Subsection.getAll"
217
+ SYSTEM_LIST_METHODS = "system.listMethods"
218
+ SYSVAR_DELETE_SYSVAR_BY_NAME = "SysVar.deleteSysVarByName"
219
+ SYSVAR_GET_ALL = "SysVar.getAll"
220
+ SYSVAR_GET_VALUE_BY_NAME = "SysVar.getValueByName"
221
+ SYSVAR_SET_BOOL = "SysVar.setBool"
222
+ SYSVAR_SET_FLOAT = "SysVar.setFloat"
223
+
224
+
225
+ # Methods allowed through even when circuit breaker is open (session management)
226
+ _CIRCUIT_BREAKER_BYPASS_METHODS: Final = (
227
+ _JsonRpcMethod.SESSION_LOGIN,
228
+ _JsonRpcMethod.SESSION_LOGOUT,
229
+ _JsonRpcMethod.SESSION_RENEW,
230
+ )
231
+
232
+
233
+ class AioJsonRpcAioHttpClient(LogContextMixin):
234
+ """Connection to CCU JSON-RPC Server."""
235
+
236
+ def __init__(
237
+ self,
238
+ *,
239
+ username: str,
240
+ password: str,
241
+ device_url: str,
242
+ connection_state: hmcu.CentralConnectionState,
243
+ interface_id: str | None = None,
244
+ client_session: ClientSession | None = None,
245
+ tls: bool = False,
246
+ verify_tls: bool = False,
247
+ session_recorder: SessionRecorder | None = None,
248
+ circuit_breaker_config: CircuitBreakerConfig | None = None,
249
+ event_bus: EventBus | None = None,
250
+ incident_recorder: IncidentRecorderProtocol | None = None,
251
+ ) -> None:
252
+ """Session setup."""
253
+ self._client_session: Final = (
254
+ ClientSession(connector=TCPConnector(limit=MAX_CONCURRENT_HTTP_SESSIONS))
255
+ if client_session is None
256
+ else client_session
257
+ )
258
+ self._is_internal_session: Final = bool(client_session is None)
259
+ self._connection_state: Final = connection_state
260
+ self._username: Final = username
261
+ self._password: Final = password
262
+ self._looper = Looper()
263
+ self._tls: Final = tls
264
+ self._tls_context: Final[SSLContext | bool] = get_tls_context(verify_tls=verify_tls) if tls else False
265
+ self._url: Final = f"{device_url}{PATH_JSON_RPC}"
266
+ self._script_cache: Final[dict[str, str]] = {}
267
+ self._last_session_id_refresh: datetime | None = None
268
+ self._session_id: str | None = None
269
+ self._session_recorder: Final = session_recorder
270
+ self._supported_methods: tuple[str, ...] | None = None
271
+ self._http_session_semaphore: Final = Semaphore(value=MAX_CONCURRENT_HTTP_SESSIONS)
272
+
273
+ # Login rate limiting state
274
+ self._failed_login_attempts: int = 0
275
+ self._last_failed_login: datetime | None = None
276
+ self._current_backoff: float = LOGIN_INITIAL_BACKOFF_SECONDS
277
+
278
+ # Incident recorder for diagnostic events
279
+ self._incident_recorder = incident_recorder
280
+ self._interface_id: Final = interface_id
281
+
282
+ # Circuit breaker for preventing retry-storms during backend outages
283
+ # Use interface_id for health tracking; fall back to URL for logging only
284
+ self._circuit_breaker: Final = CircuitBreaker(
285
+ config=circuit_breaker_config,
286
+ interface_id=interface_id or self._url,
287
+ connection_state=connection_state,
288
+ issuer=self,
289
+ event_bus=event_bus,
290
+ incident_recorder=incident_recorder,
291
+ task_scheduler=self._looper,
292
+ )
293
+
294
+ @staticmethod
295
+ def _convert_device_description(*, json_data: dict[str, Any]) -> DeviceDescription:
296
+ """Convert json data to device description."""
297
+ device_description = DeviceDescription(
298
+ TYPE=json_data["type"],
299
+ ADDRESS=json_data["address"],
300
+ PARAMSETS=json_data["paramsets"],
301
+ )
302
+ if available_firmware := json_data.get("availableFirmware"):
303
+ device_description["AVAILABLE_FIRMWARE"] = available_firmware
304
+ if children := json_data.get("children"):
305
+ device_description["CHILDREN"] = children
306
+ if firmware := json_data.get("firmware"):
307
+ device_description["FIRMWARE"] = firmware
308
+ if firmware_updatable := json_data.get("firmwareUpdatable"):
309
+ device_description["FIRMWARE_UPDATABLE"] = firmware_updatable
310
+ if firmware_update_state := json_data.get("firmwareUpdateState"):
311
+ device_description["FIRMWARE_UPDATE_STATE"] = firmware_update_state
312
+ if interface := json_data.get("interface"):
313
+ device_description["INTERFACE"] = interface
314
+ if parent := json_data.get("parent"):
315
+ device_description["PARENT"] = parent
316
+ if link_source_role := json_data.get("linkSourceRole"):
317
+ device_description["LINK_SOURCE_ROLES"] = link_source_role
318
+ if link_target_role := json_data.get("linkTargetRole"):
319
+ device_description["LINK_TARGET_ROLES"] = link_target_role
320
+ if rx_mode := json_data.get("rxMode"):
321
+ device_description["RX_MODE"] = rx_mode
322
+ if subtype := json_data.get("subType"):
323
+ device_description["SUBTYPE"] = subtype
324
+ if updatable := json_data.get("updatable"):
325
+ device_description["UPDATABLE"] = updatable
326
+ return device_description
327
+
328
+ @staticmethod
329
+ def _convert_parameter_data(*, json_data: dict[str, Any]) -> ParameterData:
330
+ """Convert json data to parameter data."""
331
+ _type = json_data["TYPE"]
332
+ _value_list = json_data.get("VALUE_LIST", ())
333
+
334
+ parameter_data = ParameterData(
335
+ DEFAULT=convert_value(value=json_data["DEFAULT"], target_type=_type, value_list=_value_list),
336
+ FLAGS=int(json_data["FLAGS"]),
337
+ ID=json_data["ID"],
338
+ MAX=convert_value(value=json_data.get("MAX"), target_type=_type, value_list=_value_list),
339
+ MIN=convert_value(value=json_data.get("MIN"), target_type=_type, value_list=_value_list),
340
+ OPERATIONS=int(json_data["OPERATIONS"]),
341
+ TYPE=_type,
342
+ )
343
+ if special := json_data.get("SPECIAL"):
344
+ parameter_data["SPECIAL"] = special
345
+ if unit := json_data.get("UNIT"):
346
+ parameter_data["UNIT"] = str(unit)
347
+ if value_list := _value_list:
348
+ parameter_data["VALUE_LIST"] = value_list.split(" ")
349
+
350
+ return parameter_data
351
+
352
+ circuit_breaker: Final = DelegatedProperty[CircuitBreaker](path="_circuit_breaker")
353
+ tls: Final = DelegatedProperty[bool](path="_tls", log_context=True)
354
+ url: Final = DelegatedProperty[str | None](path="_url", log_context=True)
355
+
356
+ @property
357
+ def _has_credentials(self) -> bool:
358
+ """Return if credentials are available."""
359
+ return self._username is not None and self._username != "" and self._password is not None
360
+
361
+ @property
362
+ def _has_session_recently_refreshed(self) -> bool:
363
+ """Check if session id has been modified within 90 seconds."""
364
+ if self._last_session_id_refresh is None:
365
+ return False
366
+ delta = datetime.now() - self._last_session_id_refresh
367
+ return delta.seconds < JSON_SESSION_AGE
368
+
369
+ @property
370
+ def is_activated(self) -> bool:
371
+ """If session exists, then it is activated."""
372
+ return self._session_id is not None
373
+
374
+ async def accept_device_in_inbox(self, *, device_address: str) -> bool:
375
+ """
376
+ Accept a device from the CCU inbox.
377
+
378
+ Args:
379
+ device_address: The address of the device to accept.
380
+
381
+ Returns:
382
+ True if the device was accepted successfully.
383
+
384
+ """
385
+ try:
386
+ response = await self._post_script(
387
+ script_name=RegaScript.ACCEPT_DEVICE_IN_INBOX,
388
+ extra_params={_JsonKey.DEVICE_ADDRESS: device_address},
389
+ )
390
+
391
+ _LOGGER.debug("ACCEPT_DEVICE_IN_INBOX: Accepting device %s", device_address)
392
+ if json_result := response[_JsonKey.RESULT]:
393
+ return bool(json_result.get(_JsonKey.SUCCESS, False))
394
+ except JSONDecodeError as jderr:
395
+ _LOGGER.error(
396
+ i18n.tr(
397
+ key="log.client.json_rpc.accept_device_in_inbox.failed",
398
+ device_address=device_address,
399
+ reason=extract_exc_args(exc=jderr),
400
+ )
401
+ )
402
+
403
+ return False
404
+
405
+ def clear_session(self) -> None:
406
+ """Clear the current session."""
407
+ self._session_id = None
408
+
409
+ async def create_backup_start(self) -> bool:
410
+ """Start a system backup on the CCU in the background."""
411
+ try:
412
+ response = await self._post_script(script_name=RegaScript.CREATE_BACKUP_START)
413
+
414
+ _LOGGER.debug("CREATE_BACKUP_START: Starting system backup in background")
415
+ if json_result := response[_JsonKey.RESULT]:
416
+ return bool(json_result.get(_JsonKey.SUCCESS, False))
417
+ except JSONDecodeError as jderr:
418
+ _LOGGER.error(
419
+ i18n.tr(
420
+ key="log.client.json_rpc.create_backup_start.failed",
421
+ reason=extract_exc_args(exc=jderr),
422
+ )
423
+ )
424
+
425
+ return False
426
+
427
+ async def create_backup_status(self) -> BackupStatusData:
428
+ """Check the status of a backup started by create_backup_start."""
429
+ try:
430
+ response = await self._post_script(script_name=RegaScript.CREATE_BACKUP_STATUS)
431
+
432
+ _LOGGER.debug("CREATE_BACKUP_STATUS: Checking backup status")
433
+ if json_result := response[_JsonKey.RESULT]:
434
+ status_str = json_result.get(_JsonKey.STATUS, BackupStatus.IDLE)
435
+ try:
436
+ status = BackupStatus(status_str)
437
+ except ValueError:
438
+ status = BackupStatus.IDLE
439
+
440
+ return BackupStatusData(
441
+ status=status,
442
+ file_path=json_result.get(_JsonKey.FILE, ""),
443
+ filename=json_result.get(_JsonKey.FILENAME, ""),
444
+ size=json_result.get(_JsonKey.SIZE, 0),
445
+ )
446
+ except JSONDecodeError as jderr:
447
+ _LOGGER.error(
448
+ i18n.tr(
449
+ key="log.client.json_rpc.create_backup_status.failed",
450
+ reason=extract_exc_args(exc=jderr),
451
+ )
452
+ )
453
+
454
+ return BackupStatusData(status=BackupStatus.IDLE)
455
+
456
+ async def delete_system_variable(self, *, name: str) -> bool:
457
+ """Delete a system variable from the backend."""
458
+ params = {_JsonKey.NAME: name}
459
+ response = await self._post(
460
+ method=_JsonRpcMethod.SYSVAR_DELETE_SYSVAR_BY_NAME,
461
+ extra_params=params,
462
+ )
463
+
464
+ _LOGGER.debug("DELETE_SYSTEM_VARIABLE: Getting System variable")
465
+ if json_result := response[_JsonKey.RESULT]:
466
+ deleted = json_result
467
+ _LOGGER.debug("DELETE_SYSTEM_VARIABLE: Deleted: %s", str(deleted))
468
+
469
+ return True
470
+
471
+ async def download_backup(self) -> bytes | None:
472
+ """
473
+ Download a backup file from the CCU.
474
+
475
+ The CCU's cp_security.cgi endpoint creates and downloads a fresh backup.
476
+
477
+ Returns:
478
+ Backup file content as bytes, or None if download failed.
479
+
480
+ """
481
+ if not self._client_session:
482
+ _LOGGER.error(i18n.tr(key="exception.client.json_post.no_session"))
483
+ return None
484
+
485
+ # Get session ID for authentication
486
+ await self._login_or_renew()
487
+ if not self._session_id:
488
+ _LOGGER.error(i18n.tr(key="log.client.json_rpc.download_backup.no_session"))
489
+ return None
490
+
491
+ # Build download URL - CCU creates and serves backup via cp_security.cgi
492
+ # Session ID must be wrapped in @ symbols: sid=@SESSION_ID@
493
+ download_url = f"{self._url.replace(PATH_JSON_RPC, '')}/config/cp_security.cgi?sid=@{self._session_id}@&action=create_backup"
494
+
495
+ try:
496
+ _LOGGER.debug("DOWNLOAD_BACKUP: Downloading backup from %s", download_url)
497
+ async with self._client_session.get(
498
+ url=download_url,
499
+ timeout=ClientTimeout(total=300), # 5 minutes timeout for large backups
500
+ ssl=self._tls_context,
501
+ ) as response:
502
+ if response.status == 200:
503
+ content = await response.read()
504
+ _LOGGER.debug("DOWNLOAD_BACKUP: Downloaded %d bytes", len(content))
505
+ return content
506
+ _LOGGER.error(
507
+ i18n.tr(
508
+ key="log.client.json_rpc.download_backup.failed",
509
+ status=response.status,
510
+ )
511
+ )
512
+ except ClientError as cerr:
513
+ _LOGGER.error(
514
+ i18n.tr(
515
+ key="log.client.json_rpc.download_backup.error",
516
+ reason=extract_exc_args(exc=cerr),
517
+ )
518
+ )
519
+
520
+ return None
521
+
522
+ async def download_firmware(self, *, firmware_url: str) -> bool:
523
+ """
524
+ Download firmware to the CCU for installation.
525
+
526
+ Args:
527
+ firmware_url: URL to download the firmware from.
528
+
529
+ Returns:
530
+ True if firmware was downloaded successfully, False otherwise.
531
+
532
+ """
533
+ if not self._client_session:
534
+ _LOGGER.error(i18n.tr(key="exception.client.json_post.no_session"))
535
+ return False
536
+
537
+ # CCU downloads firmware via /config/cp_maintenance.cgi with POST
538
+ upload_url = f"{self._url.replace(PATH_JSON_RPC, '')}/config/cp_maintenance.cgi"
539
+
540
+ try:
541
+ _LOGGER.debug("DOWNLOAD_FIRMWARE: Downloading firmware from %s", firmware_url)
542
+ # Get session ID for authentication
543
+ await self._login_or_renew()
544
+ if not self._session_id:
545
+ _LOGGER.error(i18n.tr(key="log.client.json_rpc.download_firmware.no_session"))
546
+ return False
547
+
548
+ # CCU expects firmware URL to be passed to maintenance CGI
549
+ params = {
550
+ _JsonKey.SID: self._session_id,
551
+ _JsonKey.ACTION: "download_firmware",
552
+ _JsonKey.URL: firmware_url,
553
+ }
554
+
555
+ async with self._client_session.post(
556
+ url=upload_url,
557
+ data=params,
558
+ timeout=ClientTimeout(total=600), # 10 minutes timeout for large firmware
559
+ ssl=self._tls_context,
560
+ ) as response:
561
+ if response.status == 200:
562
+ _LOGGER.debug("DOWNLOAD_FIRMWARE: Firmware download initiated")
563
+ return True
564
+ _LOGGER.error(
565
+ i18n.tr(
566
+ key="log.client.json_rpc.download_firmware.failed",
567
+ status=response.status,
568
+ )
569
+ )
570
+ except ClientError as cerr:
571
+ _LOGGER.error(
572
+ i18n.tr(
573
+ key="log.client.json_rpc.download_firmware.error",
574
+ reason=extract_exc_args(exc=cerr),
575
+ )
576
+ )
577
+
578
+ return False
579
+
580
+ async def execute_program(self, *, pid: str) -> bool:
581
+ """Execute a program on the backend."""
582
+ params = {
583
+ _JsonKey.ID: pid,
584
+ }
585
+
586
+ response = await self._post(method=_JsonRpcMethod.PROGRAM_EXECUTE, extra_params=params)
587
+ _LOGGER.debug("EXECUTE_PROGRAM: Executing a program")
588
+
589
+ if json_result := response[_JsonKey.RESULT]:
590
+ _LOGGER.debug(
591
+ "EXECUTE_PROGRAM: Result while executing program: %s",
592
+ str(json_result),
593
+ )
594
+
595
+ return True
596
+
597
+ async def get_all_channel_rega_ids_function(self) -> Mapping[int, set[str]]:
598
+ """Get all rega_ids per function from the backend."""
599
+ rega_ids_function: dict[int, set[str]] = {}
600
+
601
+ response = await self._post(
602
+ method=_JsonRpcMethod.SUBSECTION_GET_ALL,
603
+ )
604
+
605
+ _LOGGER.debug("GET_ALL_CHANNEL_IDS_PER_FUNCTION: Getting all functions")
606
+ if json_result := response[_JsonKey.RESULT]:
607
+ for function in json_result:
608
+ function_id = int(function[_JsonKey.ID])
609
+ function_name = function[_JsonKey.NAME]
610
+ if function_id not in rega_ids_function:
611
+ rega_ids_function[function_id] = set()
612
+ rega_ids_function[function_id].add(function_name)
613
+ for rega_id in function[_JsonKey.CHANNEL_IDS]:
614
+ if rega_id not in rega_ids_function:
615
+ rega_ids_function[rega_id] = set()
616
+ rega_ids_function[rega_id].add(function_name)
617
+
618
+ return rega_ids_function
619
+
620
+ async def get_all_channel_rega_ids_room(self) -> Mapping[int, set[str]]:
621
+ """Get all rega_ids per room from the backend."""
622
+ rega_ids_room: dict[int, set[str]] = {}
623
+
624
+ response = await self._post(
625
+ method=_JsonRpcMethod.ROOM_GET_ALL,
626
+ )
627
+
628
+ _LOGGER.debug("GET_ALL_CHANNEL_IDS_PER_ROOM: Getting all rooms")
629
+ if json_result := response[_JsonKey.RESULT]:
630
+ for room in json_result:
631
+ room_id = int(room[_JsonKey.ID])
632
+ room_name = room[_JsonKey.NAME]
633
+ if room_id not in rega_ids_room:
634
+ rega_ids_room[room_id] = set()
635
+ rega_ids_room[room_id].add(room_name)
636
+ for rega_id in room[_JsonKey.CHANNEL_IDS]:
637
+ if rega_id not in rega_ids_room:
638
+ rega_ids_room[rega_id] = set()
639
+ rega_ids_room[rega_id].add(room_name)
640
+
641
+ return rega_ids_room
642
+
643
+ async def get_all_device_data(self, *, interface: Interface) -> Mapping[str, Any]:
644
+ """Get the all device data of the backend."""
645
+ all_device_data: dict[str, Any] = {}
646
+ params = {
647
+ _JsonKey.INTERFACE: interface,
648
+ }
649
+ try:
650
+ response = await self._post_script(script_name=RegaScript.FETCH_ALL_DEVICE_DATA, extra_params=params)
651
+
652
+ _LOGGER.debug("GET_ALL_DEVICE_DATA: Getting all device data for interface %s", interface)
653
+ if json_result := response[_JsonKey.RESULT]:
654
+ all_device_data = {
655
+ unquote(string=k, encoding=ISO_8859_1): unquote(string=v, encoding=ISO_8859_1)
656
+ if isinstance(v, str)
657
+ else v
658
+ for k, v in json_result.items()
659
+ }
660
+
661
+ except (ContentTypeError, JSONDecodeError) as cerr:
662
+ raise ClientException(
663
+ i18n.tr(
664
+ key="exception.client.get_all_device_data.failed",
665
+ interface=interface,
666
+ )
667
+ ) from cerr
668
+
669
+ return all_device_data
670
+
671
+ async def get_all_programs(self, *, markers: tuple[DescriptionMarker | str, ...]) -> tuple[ProgramData, ...]:
672
+ """Get the all programs of the backend."""
673
+ all_programs: list[ProgramData] = []
674
+
675
+ response = await self._post(
676
+ method=_JsonRpcMethod.PROGRAM_GET_ALL,
677
+ )
678
+
679
+ _LOGGER.debug("GET_ALL_PROGRAMS: Getting all programs")
680
+ if json_result := response[_JsonKey.RESULT]:
681
+ descriptions = await self._get_program_descriptions()
682
+ for prog in json_result:
683
+ enabled_default = False
684
+ if (is_internal := prog[_JsonKey.IS_INTERNAL]) is True:
685
+ if markers:
686
+ if DescriptionMarker.INTERNAL not in markers:
687
+ continue
688
+ enabled_default = True
689
+ elif DEFAULT_INCLUDE_INTERNAL_PROGRAMS is False:
690
+ continue
691
+
692
+ pid = prog[_JsonKey.ID]
693
+ description = descriptions.get(pid)
694
+ if not is_internal and markers:
695
+ if not element_matches_key(
696
+ search_elements=markers,
697
+ compare_with=description,
698
+ ignore_case=False,
699
+ do_left_wildcard_search=True,
700
+ ):
701
+ continue
702
+ enabled_default = True
703
+ if description:
704
+ # Remove default markers from description
705
+ for marker in DescriptionMarker:
706
+ description = description.replace(marker, "").strip()
707
+ name = prog[_JsonKey.NAME]
708
+ is_active = prog[_JsonKey.IS_ACTIVE]
709
+ last_execute_time = prog[_JsonKey.LAST_EXECUTE_TIME]
710
+
711
+ all_programs.append(
712
+ ProgramData(
713
+ pid=pid,
714
+ legacy_name=name,
715
+ description=description,
716
+ is_active=is_active,
717
+ is_internal=is_internal,
718
+ last_execute_time=last_execute_time,
719
+ enabled_default=enabled_default,
720
+ )
721
+ )
722
+
723
+ return tuple(all_programs)
724
+
725
+ async def get_all_system_variables(
726
+ self, *, markers: tuple[DescriptionMarker | str, ...]
727
+ ) -> tuple[SystemVariableData, ...]:
728
+ """Get all system variables from the backend."""
729
+ variables: list[SystemVariableData] = []
730
+
731
+ response = await self._post(
732
+ method=_JsonRpcMethod.SYSVAR_GET_ALL,
733
+ )
734
+
735
+ _LOGGER.debug("GET_ALL_SYSTEM_VARIABLES: Getting all system variables")
736
+ if json_result := response[_JsonKey.RESULT]:
737
+ descriptions = await self._get_system_variable_descriptions()
738
+ for var in json_result:
739
+ enabled_default = False
740
+ extended_sysvar = False
741
+ var_id = var[_JsonKey.ID]
742
+ legacy_name = var[_JsonKey.NAME]
743
+ is_internal = var[_JsonKey.IS_INTERNAL]
744
+ if new_name := RENAME_SYSVAR_BY_NAME.get(legacy_name):
745
+ legacy_name = new_name
746
+ if var_id in ALWAYS_ENABLE_SYSVARS_BY_ID:
747
+ enabled_default = True
748
+
749
+ if enabled_default is False and is_internal is True:
750
+ if var_id in ALWAYS_ENABLE_SYSVARS_BY_ID:
751
+ enabled_default = True
752
+ elif markers:
753
+ if DescriptionMarker.INTERNAL not in markers:
754
+ continue
755
+ enabled_default = True
756
+ elif DEFAULT_INCLUDE_INTERNAL_SYSVARS is False:
757
+ continue # type: ignore[unreachable]
758
+
759
+ description = descriptions.get(var_id)
760
+ if enabled_default is False and not is_internal and markers:
761
+ if not element_matches_key(
762
+ search_elements=markers,
763
+ compare_with=description,
764
+ ignore_case=False,
765
+ do_left_wildcard_search=True,
766
+ ):
767
+ continue
768
+ enabled_default = True
769
+
770
+ org_data_type = var[_JsonKey.TYPE]
771
+ raw_value = var[_JsonKey.VALUE]
772
+ if org_data_type == HubValueType.NUMBER:
773
+ data_type = HubValueType.FLOAT if "." in raw_value else HubValueType.INTEGER
774
+ else:
775
+ data_type = org_data_type
776
+
777
+ if description:
778
+ extended_sysvar = DescriptionMarker.HAHM in description
779
+ # Remove default markers from description
780
+ for marker in DescriptionMarker:
781
+ description = description.replace(marker, "").strip()
782
+ unit = var[_JsonKey.UNIT]
783
+ values: tuple[str, ...] | None = None
784
+ if val_list := var.get(_JsonKey.VALUE_LIST):
785
+ values = tuple(val_list.split(";"))
786
+ try:
787
+ value = parse_sys_var(data_type=data_type, raw_value=raw_value)
788
+ max_value = None
789
+ if raw_max_value := var.get(_JsonKey.MAX_VALUE):
790
+ max_value = parse_sys_var(data_type=data_type, raw_value=raw_max_value)
791
+ min_value = None
792
+ if raw_min_value := var.get(_JsonKey.MIN_VALUE):
793
+ min_value = parse_sys_var(data_type=data_type, raw_value=raw_min_value)
794
+ variables.append(
795
+ SystemVariableData(
796
+ vid=var_id,
797
+ legacy_name=legacy_name,
798
+ data_type=data_type,
799
+ description=description,
800
+ unit=unit,
801
+ value=value,
802
+ values=values,
803
+ max_value=max_value,
804
+ min_value=min_value,
805
+ extended_sysvar=extended_sysvar,
806
+ enabled_default=enabled_default,
807
+ )
808
+ )
809
+ except (ValueError, TypeError) as vterr:
810
+ _LOGGER.error(
811
+ i18n.tr(
812
+ key="log.client.json_rpc.get_all_system_variables.parse_failed",
813
+ exc_type=vterr.__class__.__name__,
814
+ reason=extract_exc_args(exc=vterr),
815
+ legacy_name=legacy_name,
816
+ )
817
+ )
818
+
819
+ return tuple(variables)
820
+
821
+ async def get_device_description(self, *, interface: Interface, address: str) -> DeviceDescription | None:
822
+ """Get device descriptions from the backend."""
823
+ device_description: DeviceDescription | None = None
824
+ params = {
825
+ _JsonKey.INTERFACE: interface,
826
+ _JsonKey.ADDRESS: address,
827
+ }
828
+
829
+ response = await self._post(method=_JsonRpcMethod.INTERFACE_GET_DEVICE_DESCRIPTION, extra_params=params)
830
+
831
+ _LOGGER.debug("GET_DEVICE_DESCRIPTION: Getting the device description")
832
+ if json_result := response[_JsonKey.RESULT]:
833
+ device_description = self._convert_device_description(json_data=json_result)
834
+
835
+ return device_description
836
+
837
+ async def get_device_details(self) -> tuple[DeviceDetail, ...]:
838
+ """Get the device details of the backend."""
839
+ device_details: tuple[DeviceDetail, ...] = ()
840
+
841
+ response = await self._post(
842
+ method=_JsonRpcMethod.DEVICE_LIST_ALL_DETAIL,
843
+ )
844
+
845
+ _LOGGER.debug("GET_DEVICE_DETAILS: Getting the device details")
846
+ if json_result := response[_JsonKey.RESULT]:
847
+ device_details = tuple(json_result)
848
+
849
+ return device_details
850
+
851
+ async def get_inbox_devices(self) -> tuple[InboxDeviceData, ...]:
852
+ """Get all devices in the inbox (not yet configured)."""
853
+ devices: list[InboxDeviceData] = []
854
+
855
+ try:
856
+ response = await self._post_script(script_name=RegaScript.GET_INBOX_DEVICES)
857
+
858
+ _LOGGER.debug("GET_INBOX_DEVICES: Getting inbox devices")
859
+ if json_result := response[_JsonKey.RESULT]:
860
+ devices.extend(
861
+ InboxDeviceData(
862
+ device_id=dev[_JsonKey.ID],
863
+ address=dev.get(_JsonKey.ADDRESS, ""),
864
+ name=unquote(string=dev.get(_JsonKey.NAME, ""), encoding=ISO_8859_1),
865
+ device_type=dev.get(_JsonKey.TYPE, ""),
866
+ interface=dev.get(_JsonKey.INTERFACE, ""),
867
+ )
868
+ for dev in json_result
869
+ )
870
+ except JSONDecodeError as jderr:
871
+ _LOGGER.error(
872
+ i18n.tr(
873
+ key="log.client.json_rpc.get_inbox_devices.decode_failed",
874
+ reason=extract_exc_args(exc=jderr),
875
+ )
876
+ )
877
+
878
+ return tuple(devices)
879
+
880
+ async def get_install_mode(self, *, interface: Interface) -> int:
881
+ """Get the remaining install mode time for an interface."""
882
+ params = {_JsonKey.INTERFACE: interface}
883
+
884
+ response = await self._post(method=_JsonRpcMethod.INTERFACE_GET_INSTALL_MODE, extra_params=params)
885
+
886
+ _LOGGER.debug("GET_INSTALL_MODE: Getting remaining install mode time for %s", interface)
887
+ if json_result := response[_JsonKey.RESULT]:
888
+ return int(json_result)
889
+
890
+ return 0
891
+
892
+ async def get_paramset(
893
+ self, *, interface: Interface, address: str, paramset_key: ParamsetKey | str
894
+ ) -> dict[str, Any] | None:
895
+ """Get paramset from the backend."""
896
+ paramset: dict[str, Any] = {}
897
+ params = {
898
+ _JsonKey.INTERFACE: interface,
899
+ _JsonKey.ADDRESS: address,
900
+ _JsonKey.PARAMSET_KEY: paramset_key,
901
+ }
902
+
903
+ response = await self._post(
904
+ method=_JsonRpcMethod.INTERFACE_GET_PARAMSET,
905
+ extra_params=params,
906
+ )
907
+
908
+ _LOGGER.debug("GET_PARAMSET: Getting the paramset")
909
+ if json_result := response[_JsonKey.RESULT]:
910
+ paramset = json_result
911
+
912
+ return paramset
913
+
914
+ async def get_paramset_description(
915
+ self, *, interface: Interface, address: str, paramset_key: ParamsetKey
916
+ ) -> Mapping[str, ParameterData] | None:
917
+ """Get paramset description from the backend."""
918
+ paramset_description: dict[str, ParameterData] = {}
919
+ params = {
920
+ _JsonKey.INTERFACE: interface,
921
+ _JsonKey.ADDRESS: address,
922
+ _JsonKey.PARAMSET_KEY: paramset_key,
923
+ }
924
+
925
+ response = await self._post(method=_JsonRpcMethod.INTERFACE_GET_PARAMSET_DESCRIPTION, extra_params=params)
926
+
927
+ _LOGGER.debug("GET_PARAMSET_DESCRIPTIONS: Getting the paramset descriptions")
928
+ if json_result := response[_JsonKey.RESULT]:
929
+ paramset_description = {data["NAME"]: self._convert_parameter_data(json_data=data) for data in json_result}
930
+
931
+ return paramset_description
932
+
933
+ async def get_rega_id_by_address(self, *, address: str) -> int | None:
934
+ """
935
+ Get the ReGa ID for a device or channel address.
936
+
937
+ Args:
938
+ address: The address of the device or channel.
939
+
940
+ Returns:
941
+ The ReGa ID if found, None otherwise.
942
+
943
+ """
944
+ is_dev = is_device_address(address=address)
945
+
946
+ details = await self.get_device_details()
947
+ for detail in details:
948
+ if is_dev:
949
+ if detail["address"] == address:
950
+ return detail["id"]
951
+ else:
952
+ for channel in detail["channels"]:
953
+ if channel["address"] == address:
954
+ return channel["id"]
955
+
956
+ return None
957
+
958
+ async def get_service_messages(
959
+ self,
960
+ *,
961
+ message_type: ServiceMessageType | None = None,
962
+ ) -> tuple[ServiceMessageData, ...]:
963
+ """
964
+ Get all active service messages from the backend.
965
+
966
+ Args:
967
+ message_type: Filter by message type. If None, return all messages.
968
+
969
+ """
970
+ messages: list[ServiceMessageData] = []
971
+
972
+ try:
973
+ response = await self._post_script(script_name=RegaScript.GET_SERVICE_MESSAGES)
974
+
975
+ _LOGGER.debug("GET_SERVICE_MESSAGES: Getting service messages")
976
+ if json_result := response[_JsonKey.RESULT]:
977
+ for msg in json_result:
978
+ msg_type = msg[_JsonKey.TYPE]
979
+ if message_type is not None and msg_type != message_type:
980
+ continue
981
+ messages.append(
982
+ ServiceMessageData(
983
+ msg_id=msg[_JsonKey.ID],
984
+ name=unquote(string=msg[_JsonKey.NAME], encoding=ISO_8859_1),
985
+ timestamp=msg[_JsonKey.TIMESTAMP],
986
+ msg_type=msg_type,
987
+ address=msg.get(_JsonKey.ADDRESS, ""),
988
+ device_name=unquote(string=msg.get(_JsonKey.DEVICE_NAME, ""), encoding=ISO_8859_1),
989
+ )
990
+ )
991
+ except JSONDecodeError as jderr:
992
+ _LOGGER.error(
993
+ i18n.tr(
994
+ key="log.client.json_rpc.get_service_messages.decode_failed",
995
+ reason=extract_exc_args(exc=jderr),
996
+ )
997
+ )
998
+
999
+ return tuple(messages)
1000
+
1001
+ async def get_system_information(self) -> SystemInformation:
1002
+ """Get system information of the the backend."""
1003
+ auth_enabled = await self._get_auth_enabled()
1004
+
1005
+ # Get backend info (version, product, hostname, ccu_type)
1006
+ version = ""
1007
+ hostname = ""
1008
+ ccu_type = CCUType.UNKNOWN
1009
+ try:
1010
+ response = await self._post_script(script_name=RegaScript.GET_BACKEND_INFO)
1011
+ _LOGGER.debug("GET_SYSTEM_INFORMATION: Getting backend information")
1012
+ if json_result := response[_JsonKey.RESULT]:
1013
+ version = json_result.get(_JsonKey.VERSION, "")
1014
+ ccu_type = _determine_ccu_type(product=json_result.get(_JsonKey.PRODUCT, ""))
1015
+ hostname = json_result.get(_JsonKey.HOSTNAME, "")
1016
+ except JSONDecodeError as jderr:
1017
+ _LOGGER.error(
1018
+ i18n.tr(
1019
+ key="log.client.json_rpc.get_backend_info.failed",
1020
+ reason=extract_exc_args(exc=jderr),
1021
+ )
1022
+ )
1023
+
1024
+ return SystemInformation(
1025
+ auth_enabled=auth_enabled,
1026
+ available_interfaces=await self._list_interfaces(),
1027
+ https_redirect_enabled=await self._get_https_redirect_enabled(),
1028
+ serial=await self._get_serial(),
1029
+ version=version,
1030
+ hostname=hostname,
1031
+ ccu_type=ccu_type,
1032
+ )
1033
+
1034
+ async def get_system_update_info(self) -> SystemUpdateData:
1035
+ """Get system update information from the backend."""
1036
+ try:
1037
+ response = await self._post_script(script_name=RegaScript.GET_SYSTEM_UPDATE_INFO)
1038
+
1039
+ _LOGGER.debug("GET_SYSTEM_UPDATE_INFO: Getting system update info")
1040
+ if json_result := response[_JsonKey.RESULT]:
1041
+ return SystemUpdateData(
1042
+ current_firmware=json_result.get(_JsonKey.CURRENT_FIRMWARE, ""),
1043
+ available_firmware=json_result.get(_JsonKey.AVAILABLE_FIRMWARE, ""),
1044
+ update_available=json_result.get(_JsonKey.UPDATE_AVAILABLE, False),
1045
+ check_script_available=json_result.get(_JsonKey.CHECK_SCRIPT_AVAILABLE, False),
1046
+ )
1047
+ except JSONDecodeError as jderr:
1048
+ _LOGGER.error(
1049
+ i18n.tr(
1050
+ key="log.client.json_rpc.get_system_update_info.decode_failed",
1051
+ reason=extract_exc_args(exc=jderr),
1052
+ )
1053
+ )
1054
+
1055
+ return SystemUpdateData(
1056
+ current_firmware="",
1057
+ available_firmware="",
1058
+ update_available=False,
1059
+ )
1060
+
1061
+ async def get_system_variable(self, *, name: str) -> Any:
1062
+ """Get single system variable from the backend."""
1063
+ params = {_JsonKey.NAME: name}
1064
+ response = await self._post(
1065
+ method=_JsonRpcMethod.SYSVAR_GET_VALUE_BY_NAME,
1066
+ extra_params=params,
1067
+ )
1068
+
1069
+ _LOGGER.debug("GET_SYSTEM_VARIABLE: Getting System variable")
1070
+ return response[_JsonKey.RESULT]
1071
+
1072
+ async def get_value(self, *, interface: Interface, address: str, paramset_key: ParamsetKey, parameter: str) -> Any:
1073
+ """Get value from the backend."""
1074
+ value: Any = None
1075
+ params = {
1076
+ _JsonKey.INTERFACE: interface,
1077
+ _JsonKey.ADDRESS: address,
1078
+ _JsonKey.VALUE_KEY: parameter,
1079
+ }
1080
+
1081
+ response = (
1082
+ await self._post(method=_JsonRpcMethod.INTERFACE_GET_MASTER_VALUE, extra_params=params)
1083
+ if paramset_key == ParamsetKey.MASTER
1084
+ else await self._post(method=_JsonRpcMethod.INTERFACE_GET_VALUE, extra_params=params)
1085
+ )
1086
+
1087
+ _LOGGER.debug("GET_VALUE: Getting the value")
1088
+ if json_result := response[_JsonKey.RESULT]:
1089
+ value = json_result
1090
+
1091
+ return value
1092
+
1093
+ async def has_program_ids(self, *, rega_id: int) -> bool:
1094
+ """Return if a channel has program ids."""
1095
+ params = {_JsonKey.ID: rega_id}
1096
+ response = await self._post(
1097
+ method=_JsonRpcMethod.CHANNEL_HAS_PROGRAM_IDS,
1098
+ extra_params=params,
1099
+ )
1100
+
1101
+ _LOGGER.debug("HAS_PROGRAM_IDS: Checking if channel has program ids")
1102
+ if json_result := response[_JsonKey.RESULT]:
1103
+ return bool(json_result)
1104
+
1105
+ return False
1106
+
1107
+ async def is_present(self, *, interface: Interface) -> bool:
1108
+ """Get value from the backend."""
1109
+ value: bool = False
1110
+ params = {_JsonKey.INTERFACE: interface}
1111
+
1112
+ response = await self._post(method=_JsonRpcMethod.INTERFACE_IS_PRESENT, extra_params=params)
1113
+
1114
+ _LOGGER.debug("IS_PRESENT: Getting the value")
1115
+ if json_result := response[_JsonKey.RESULT]:
1116
+ value = bool(json_result)
1117
+
1118
+ return value
1119
+
1120
+ async def is_service_available(self) -> bool:
1121
+ """
1122
+ Check if the JSON-RPC service is available.
1123
+
1124
+ This method attempts a login to verify the service is ready.
1125
+ Useful after CCU restart to ensure the service is fully available
1126
+ before attempting other operations.
1127
+
1128
+ Returns True if login succeeds, False otherwise.
1129
+ """
1130
+ try:
1131
+ session_id = await self._do_login()
1132
+ except BaseHomematicException:
1133
+ return False
1134
+ else:
1135
+ return session_id is not None
1136
+
1137
+ async def list_devices(self, *, interface: Interface) -> tuple[DeviceDescription, ...]:
1138
+ """List devices from the backend."""
1139
+ devices: tuple[DeviceDescription, ...] = ()
1140
+ _LOGGER.debug("LIST_DEVICES: Getting all available interfaces")
1141
+ params = {
1142
+ _JsonKey.INTERFACE: interface,
1143
+ }
1144
+
1145
+ response = await self._post(
1146
+ method=_JsonRpcMethod.INTERFACE_LIST_DEVICES,
1147
+ extra_params=params,
1148
+ )
1149
+
1150
+ if json_result := response[_JsonKey.RESULT]:
1151
+ devices = tuple(self._convert_device_description(json_data=data) for data in json_result)
1152
+
1153
+ return devices
1154
+
1155
+ async def logout(self) -> None:
1156
+ """Logout of the backend."""
1157
+ try:
1158
+ await self._looper.block_till_done()
1159
+ await self._do_logout(session_id=self._session_id)
1160
+ except BaseHomematicException:
1161
+ _LOGGER.debug("LOGOUT: logout failed")
1162
+
1163
+ async def put_paramset(
1164
+ self,
1165
+ *,
1166
+ interface: Interface,
1167
+ address: str,
1168
+ paramset_key: ParamsetKey | str,
1169
+ values: list[dict[str, Any]],
1170
+ ) -> None:
1171
+ """Set paramset to the backend."""
1172
+ params = {
1173
+ _JsonKey.INTERFACE: interface,
1174
+ _JsonKey.ADDRESS: address,
1175
+ _JsonKey.PARAMSET_KEY: paramset_key,
1176
+ _JsonKey.SET: values,
1177
+ }
1178
+
1179
+ response = await self._post(
1180
+ method=_JsonRpcMethod.INTERFACE_PUT_PARAMSET,
1181
+ extra_params=params,
1182
+ )
1183
+
1184
+ _LOGGER.debug("PUT_PARAMSET: Putting the paramset")
1185
+ if json_result := response[_JsonKey.RESULT]:
1186
+ _LOGGER.debug(
1187
+ "PUT_PARAMSET: Result while putting the paramset: %s",
1188
+ str(json_result),
1189
+ )
1190
+
1191
+ async def rename_channel(self, *, rega_id: int, new_name: str) -> bool:
1192
+ """
1193
+ Rename a channel on the CCU.
1194
+
1195
+ Args:
1196
+ rega_id: The ReGa ID of the channel to rename.
1197
+ new_name: The new name for the channel.
1198
+
1199
+ Returns:
1200
+ True if the channel was renamed successfully.
1201
+
1202
+ """
1203
+ params = {
1204
+ _JsonKey.ID: rega_id,
1205
+ _JsonKey.NAME: new_name,
1206
+ }
1207
+
1208
+ response = await self._post(method=_JsonRpcMethod.CHANNEL_SET_NAME, extra_params=params)
1209
+ _LOGGER.debug("RENAME_CHANNEL: Renaming channel with rega_id %s to %s", rega_id, new_name)
1210
+
1211
+ return response.get(_JsonKey.RESULT) is True
1212
+
1213
+ async def rename_device(self, *, rega_id: int, new_name: str) -> bool:
1214
+ """
1215
+ Rename a device on the CCU.
1216
+
1217
+ Args:
1218
+ rega_id: The ReGa ID of the device to rename.
1219
+ new_name: The new name for the device.
1220
+
1221
+ Returns:
1222
+ True if the device was renamed successfully.
1223
+
1224
+ """
1225
+ params = {
1226
+ _JsonKey.ID: rega_id,
1227
+ _JsonKey.NAME: new_name,
1228
+ }
1229
+
1230
+ response = await self._post(method=_JsonRpcMethod.DEVICE_SET_NAME, extra_params=params)
1231
+ _LOGGER.debug("RENAME_DEVICE: Renaming device with rega_id %s to %s", rega_id, new_name)
1232
+
1233
+ return response.get(_JsonKey.RESULT) is True
1234
+
1235
+ async def set_install_mode_hmip(
1236
+ self,
1237
+ *,
1238
+ interface: Interface,
1239
+ on: bool = True,
1240
+ time: int = 60,
1241
+ device_address: str | None = None,
1242
+ ) -> bool:
1243
+ """
1244
+ Set the install mode on the backend for HmIP-RF.
1245
+
1246
+ Args:
1247
+ interface: The interface to set install mode for.
1248
+ on: Enable or disable install mode.
1249
+ time: Duration in seconds (default 60).
1250
+ device_address: Optional device SGTIN to limit pairing.
1251
+
1252
+ Returns:
1253
+ True if successful.
1254
+
1255
+ """
1256
+ params: dict[str, Any] = {
1257
+ _JsonKey.INTERFACE: interface,
1258
+ _JsonKey.ON: "true" if on else "false",
1259
+ _JsonKey.TIME: time,
1260
+ _JsonKey.INSTALL_MODE: "ALL",
1261
+ _JsonKey.ADDRESS: device_address or "",
1262
+ _JsonKey.KEY: "",
1263
+ _JsonKey.KEYMODE: "",
1264
+ }
1265
+
1266
+ response = await self._post(method=_JsonRpcMethod.INTERFACE_SET_INSTALL_MODE_HMIP, extra_params=params)
1267
+
1268
+ _LOGGER.debug("SET_INSTALL_MODE_HMIP: Setting install mode for HmIP-RF")
1269
+ return response[_JsonKey.RESULT] is not None
1270
+
1271
+ async def set_program_state(self, *, pid: str, state: bool) -> bool:
1272
+ """Set the program state on the backend."""
1273
+ params = {
1274
+ _JsonKey.ID: pid,
1275
+ _JsonKey.STATE: "1" if state else "0",
1276
+ }
1277
+ response = await self._post_script(script_name=RegaScript.SET_PROGRAM_STATE, extra_params=params)
1278
+
1279
+ _LOGGER.debug("SET_PROGRAM_STATE: Setting program state: %s", state)
1280
+ if json_result := response[_JsonKey.RESULT]:
1281
+ _LOGGER.debug(
1282
+ "SET_PROGRAM_STATE: Result while setting program state: %s",
1283
+ str(json_result),
1284
+ )
1285
+
1286
+ return True
1287
+
1288
+ async def set_system_variable(self, *, legacy_name: str, value: Any) -> bool:
1289
+ """Set a system variable on the backend."""
1290
+ params = {_JsonKey.NAME: legacy_name, _JsonKey.VALUE: value}
1291
+ if isinstance(value, bool):
1292
+ params[_JsonKey.VALUE] = int(value)
1293
+ response = await self._post(method=_JsonRpcMethod.SYSVAR_SET_BOOL, extra_params=params)
1294
+ elif isinstance(value, str):
1295
+ if (clean_text := cleanup_text_from_html_tags(text=value)) != value:
1296
+ params[_JsonKey.VALUE] = clean_text
1297
+ _LOGGER.error(
1298
+ i18n.tr(
1299
+ key="log.client.json_rpc.set_system_variable.value_contains_html",
1300
+ value=value,
1301
+ )
1302
+ )
1303
+ response = await self._post_script(script_name=RegaScript.SET_SYSTEM_VARIABLE, extra_params=params)
1304
+ else:
1305
+ response = await self._post(method=_JsonRpcMethod.SYSVAR_SET_FLOAT, extra_params=params)
1306
+
1307
+ _LOGGER.debug("SET_SYSTEM_VARIABLE: Setting System variable")
1308
+ if json_result := response[_JsonKey.RESULT]:
1309
+ _LOGGER.debug(
1310
+ "SET_SYSTEM_VARIABLE: Result while setting variable: %s",
1311
+ str(json_result),
1312
+ )
1313
+
1314
+ return True
1315
+
1316
+ async def set_value(
1317
+ self, *, interface: Interface, address: str, parameter: str, value_type: str, value: Any
1318
+ ) -> None:
1319
+ """Set value to the backend."""
1320
+ params = {
1321
+ _JsonKey.INTERFACE: interface,
1322
+ _JsonKey.ADDRESS: address,
1323
+ _JsonKey.VALUE_KEY: parameter,
1324
+ _JsonKey.TYPE: value_type,
1325
+ _JsonKey.VALUE: value,
1326
+ }
1327
+
1328
+ response = await self._post(
1329
+ method=_JsonRpcMethod.INTERFACE_SET_VALUE,
1330
+ extra_params=params,
1331
+ )
1332
+
1333
+ _LOGGER.debug("SET_VALUE: Setting the value")
1334
+ if json_result := response[_JsonKey.RESULT]:
1335
+ _LOGGER.debug(
1336
+ "SET_VALUE: Result while setting the value: %s",
1337
+ str(json_result),
1338
+ )
1339
+
1340
+ async def stop(self) -> None:
1341
+ """Stop the json rpc client."""
1342
+ if self._is_internal_session:
1343
+ await self._client_session.close()
1344
+
1345
+ async def trigger_firmware_update(self) -> bool:
1346
+ """
1347
+ Trigger unattended firmware update.
1348
+
1349
+ Only supported on OpenCCU (uses checkFirmwareUpdate.sh).
1350
+ The script runs with nohup in the background and will download the update
1351
+ and reboot to apply. Use create_backup_and_download() before this method
1352
+ to create a backup.
1353
+
1354
+ Returns:
1355
+ True if update was successfully triggered, False otherwise.
1356
+
1357
+ """
1358
+ try:
1359
+ response = await self._post_script(script_name=RegaScript.TRIGGER_FIRMWARE_UPDATE)
1360
+
1361
+ _LOGGER.debug("TRIGGER_FIRMWARE_UPDATE: Triggering firmware update")
1362
+ if json_result := response[_JsonKey.RESULT]:
1363
+ success = bool(json_result.get(_JsonKey.SUCCESS, False))
1364
+ message = json_result.get(_JsonKey.MESSAGE, "")
1365
+
1366
+ if success:
1367
+ _LOGGER.info(
1368
+ i18n.tr(
1369
+ key="log.client.json_rpc.trigger_firmware_update.success",
1370
+ message=message,
1371
+ )
1372
+ )
1373
+ else:
1374
+ _LOGGER.warning(
1375
+ i18n.tr(
1376
+ key="log.client.json_rpc.trigger_firmware_update.not_triggered",
1377
+ message=message,
1378
+ )
1379
+ )
1380
+ return success
1381
+ except JSONDecodeError as jderr:
1382
+ _LOGGER.error(
1383
+ i18n.tr(
1384
+ key="log.client.json_rpc.trigger_firmware_update.failed",
1385
+ reason=extract_exc_args(exc=jderr),
1386
+ )
1387
+ )
1388
+
1389
+ return False
1390
+
1391
+ async def _check_supported_methods(self) -> bool:
1392
+ """Check, if all required api methods are supported by the backend."""
1393
+ if self._supported_methods is None:
1394
+ self._supported_methods = await self._get_supported_methods()
1395
+ if unsupported_methods := tuple(method for method in _JsonRpcMethod if method not in self._supported_methods):
1396
+ _LOGGER.error( # i18n-log: ignore
1397
+ "CHECK_SUPPORTED_METHODS: methods not supported by the backend: %s",
1398
+ ", ".join(unsupported_methods),
1399
+ )
1400
+ return False
1401
+ return True
1402
+
1403
+ async def _do_login(self) -> str | None:
1404
+ """Login to the backend and return session with rate limiting."""
1405
+ if not self._has_credentials:
1406
+ _LOGGER.error(i18n.tr(key="log.client.json_rpc.do_login.no_credentials"))
1407
+ return None
1408
+
1409
+ # Apply rate limiting if we've had recent failed attempts
1410
+ if (
1411
+ self._failed_login_attempts > 0
1412
+ and self._last_failed_login
1413
+ and (elapsed := (datetime.now() - self._last_failed_login).total_seconds()) < self._current_backoff
1414
+ ):
1415
+ wait_time = self._current_backoff - elapsed
1416
+ _LOGGER.warning(
1417
+ i18n.tr(
1418
+ key="log.client.json_rpc.do_login.rate_limited",
1419
+ attempts=self._failed_login_attempts,
1420
+ wait_time=wait_time,
1421
+ )
1422
+ )
1423
+ await asyncio.sleep(wait_time)
1424
+
1425
+ session_id: str | None = None
1426
+
1427
+ params = {
1428
+ _JsonKey.USERNAME: self._username,
1429
+ _JsonKey.PASSWORD: self._password,
1430
+ }
1431
+ method = _JsonRpcMethod.SESSION_LOGIN
1432
+ response = await self._do_post(
1433
+ session_id=False,
1434
+ method=method,
1435
+ extra_params=params,
1436
+ use_default_params=False,
1437
+ )
1438
+
1439
+ if result := response[_JsonKey.RESULT]:
1440
+ session_id = result
1441
+ # Reset rate limiting on successful login
1442
+ self._failed_login_attempts = 0
1443
+ self._current_backoff = LOGIN_INITIAL_BACKOFF_SECONDS
1444
+ self._last_failed_login = None
1445
+ else:
1446
+ # Track failed login attempt
1447
+ self._failed_login_attempts += 1
1448
+ self._last_failed_login = datetime.now()
1449
+ # Apply exponential backoff up to max
1450
+ self._current_backoff = min(
1451
+ self._current_backoff * LOGIN_BACKOFF_MULTIPLIER,
1452
+ LOGIN_MAX_BACKOFF_SECONDS,
1453
+ )
1454
+ if self._failed_login_attempts >= LOGIN_MAX_FAILED_ATTEMPTS:
1455
+ _LOGGER.error(
1456
+ i18n.tr(
1457
+ key="log.client.json_rpc.do_login.max_attempts_reached",
1458
+ max_attempts=LOGIN_MAX_FAILED_ATTEMPTS,
1459
+ )
1460
+ )
1461
+
1462
+ _LOGGER.debug("DO_LOGIN: method: %s [%s]", method, session_id)
1463
+
1464
+ return session_id
1465
+
1466
+ async def _do_logout(self, *, session_id: str | None) -> None:
1467
+ """Logout of the backend."""
1468
+ if not session_id:
1469
+ _LOGGER.debug("DO_LOGOUT: Not logged in. Not logging out.")
1470
+ return
1471
+
1472
+ method = _JsonRpcMethod.SESSION_LOGOUT
1473
+ params = {_JsonKey.SESSION_ID: session_id}
1474
+ try:
1475
+ await self._do_post(
1476
+ session_id=session_id,
1477
+ method=method,
1478
+ extra_params=params,
1479
+ )
1480
+ _LOGGER.debug("DO_LOGOUT: method: %s [%s]", method, session_id)
1481
+ finally:
1482
+ self.clear_session()
1483
+
1484
+ async def _do_post(
1485
+ self,
1486
+ *,
1487
+ session_id: bool | str,
1488
+ method: _JsonRpcMethod,
1489
+ extra_params: Mapping[Any, Any] | None = None,
1490
+ use_default_params: bool = True,
1491
+ ) -> dict[str, Any] | Any:
1492
+ """Reusable JSON-RPC POST function."""
1493
+ if not self._client_session:
1494
+ raise ClientException(i18n.tr(key="exception.client.json_post.no_session"))
1495
+ if not self._has_credentials:
1496
+ raise ClientException(i18n.tr(key="exception.client.json_post.no_credentials"))
1497
+ if self._supported_methods and method not in self._supported_methods:
1498
+ raise UnsupportedException(i18n.tr(key="exception.client.json_post.method_unsupported", method=method))
1499
+
1500
+ # Check circuit breaker state (allow session management methods through)
1501
+ if method not in _CIRCUIT_BREAKER_BYPASS_METHODS and not self._circuit_breaker.is_available:
1502
+ self._circuit_breaker.record_rejection()
1503
+ raise CircuitBreakerOpenException(i18n.tr(key="exception.client.json_rpc.circuit_open", url=self._url))
1504
+
1505
+ params = _get_params(session_id=session_id, extra_params=extra_params, use_default_params=use_default_params)
1506
+
1507
+ try:
1508
+ payload = orjson.dumps({"method": method, "params": params, "jsonrpc": "1.1", "id": 0})
1509
+
1510
+ headers = {
1511
+ "Content-Type": "application/json",
1512
+ "Content-Length": str(len(payload)),
1513
+ }
1514
+
1515
+ post_call = partial(
1516
+ self._client_session.post,
1517
+ url=self._url,
1518
+ data=payload,
1519
+ headers=headers,
1520
+ timeout=ClientTimeout(total=TIMEOUT),
1521
+ ssl=self._tls_context,
1522
+ )
1523
+ # Limit all JSON-RPC requests to prevent CCU session overload
1524
+ async with self._http_session_semaphore:
1525
+ if (response := await asyncio.shield(post_call())) is None:
1526
+ raise ClientException(i18n.tr(key="exception.client.json_post.no_response"))
1527
+
1528
+ if response.status == 200:
1529
+ json_response = await asyncio.shield(self._get_json_reponse(response=response))
1530
+ self._record_session(method=method, params=params, response=json_response)
1531
+ if error := json_response[_JsonKey.ERROR]:
1532
+ # Map JSON-RPC error to actionable exception with context
1533
+ ctx = RpcContext(protocol="json-rpc", method=str(method), host=self._url)
1534
+ exc = map_jsonrpc_error(error=error, ctx=ctx)
1535
+ # For session management methods (login, renew), use DEBUG level
1536
+ # as these may fail during CCU restart polling and are expected
1537
+ # For other methods, use WARNING level
1538
+ level = logging.DEBUG if method in _CIRCUIT_BREAKER_BYPASS_METHODS else logging.WARNING
1539
+ log_boundary_error(
1540
+ logger=_LOGGER,
1541
+ boundary="json-rpc",
1542
+ action=str(method),
1543
+ err=exc,
1544
+ level=level,
1545
+ log_context=self.log_context,
1546
+ )
1547
+ _LOGGER.debug("POST: %s", exc)
1548
+ # Record incident only for non-session methods
1549
+ if method not in _CIRCUIT_BREAKER_BYPASS_METHODS:
1550
+ self._record_rpc_error_incident(
1551
+ method=str(method),
1552
+ error_type="JSONRPCError",
1553
+ error_message=str(error.get("message", "")),
1554
+ )
1555
+ raise exc
1556
+
1557
+ self._connection_state.remove_issue(issuer=self, iid=self._url)
1558
+ self._circuit_breaker.record_success()
1559
+ return json_response
1560
+
1561
+ message = i18n.tr(key="exception.client.json_post.http_status", status=response.status)
1562
+ json_response = await asyncio.shield(self._get_json_reponse(response=response))
1563
+ if error := json_response[_JsonKey.ERROR]:
1564
+ ctx = RpcContext(protocol="json-rpc", method=str(method), host=self._url)
1565
+ exc = map_jsonrpc_error(error=error, ctx=ctx)
1566
+ # Use DEBUG level for session management methods during CCU restart polling
1567
+ level = logging.DEBUG if method in _CIRCUIT_BREAKER_BYPASS_METHODS else logging.WARNING
1568
+ log_boundary_error(
1569
+ logger=_LOGGER,
1570
+ boundary="json-rpc",
1571
+ action=str(method),
1572
+ err=exc,
1573
+ level=level,
1574
+ log_context=dict(self.log_context) | {"status": response.status},
1575
+ )
1576
+ # Record incident only for non-session methods
1577
+ if method not in _CIRCUIT_BREAKER_BYPASS_METHODS:
1578
+ self._record_rpc_error_incident(
1579
+ method=str(method),
1580
+ error_type="HTTPError",
1581
+ error_message=f"HTTP {response.status}: {error.get('message', '')}",
1582
+ )
1583
+ raise exc
1584
+ raise ClientException(message)
1585
+ except BaseHomematicException as bhe:
1586
+ self._record_session(method=method, params=params, exc=bhe)
1587
+ if method in _CIRCUIT_BREAKER_BYPASS_METHODS:
1588
+ self.clear_session()
1589
+ # Note: Don't log here - the exception was already logged at its source
1590
+ # (either by map_jsonrpc_error handler above or by the caller)
1591
+ raise
1592
+
1593
+ except ClientConnectorCertificateError as cccerr:
1594
+ self.clear_session()
1595
+ self._circuit_breaker.record_failure()
1596
+ message = f"ClientConnectorCertificateError[{cccerr}]"
1597
+ if self._tls is False and cccerr.ssl is True:
1598
+ message = (
1599
+ f"{message}. Possible reason: 'Automatic forwarding to HTTPS' is enabled in the backend, "
1600
+ f"but this integration is not configured to use TLS"
1601
+ )
1602
+ # Log ERROR only on first occurrence, DEBUG for subsequent failures
1603
+ level = logging.ERROR if self._connection_state.add_issue(issuer=self, iid=self._url) else logging.DEBUG
1604
+ log_boundary_error(
1605
+ logger=_LOGGER,
1606
+ boundary="json-rpc",
1607
+ action=str(method),
1608
+ err=cccerr,
1609
+ level=level,
1610
+ log_context=self.log_context,
1611
+ )
1612
+ self._record_rpc_error_incident(
1613
+ method=str(method),
1614
+ error_type="ClientConnectorCertificateError",
1615
+ error_message=message,
1616
+ )
1617
+ raise ClientException(
1618
+ i18n.tr(key="exception.client.json_post.connector_certificate_error", reason=message)
1619
+ ) from cccerr
1620
+ except ClientConnectorError as cceerr:
1621
+ self.clear_session()
1622
+ self._circuit_breaker.record_failure()
1623
+ message = f"ClientConnectorError[{cceerr}]"
1624
+ # Log ERROR only on first occurrence, DEBUG for subsequent failures
1625
+ level = logging.ERROR if self._connection_state.add_issue(issuer=self, iid=self._url) else logging.DEBUG
1626
+ log_boundary_error(
1627
+ logger=_LOGGER,
1628
+ boundary="json-rpc",
1629
+ action=str(method),
1630
+ err=cceerr,
1631
+ level=level,
1632
+ log_context=self.log_context,
1633
+ )
1634
+ self._record_rpc_error_incident(
1635
+ method=str(method),
1636
+ error_type="ClientConnectorError",
1637
+ error_message=message,
1638
+ )
1639
+ raise ClientException(i18n.tr(key="exception.client.json_post.connector_error", reason=message)) from cceerr
1640
+ except (ClientError, OSError) as err:
1641
+ self.clear_session()
1642
+ self._circuit_breaker.record_failure()
1643
+ # Log ERROR only on first occurrence, DEBUG for subsequent failures
1644
+ level = logging.ERROR if self._connection_state.add_issue(issuer=self, iid=self._url) else logging.DEBUG
1645
+ log_boundary_error(
1646
+ logger=_LOGGER,
1647
+ boundary="json-rpc",
1648
+ action=str(method),
1649
+ err=err,
1650
+ level=level,
1651
+ log_context=self.log_context,
1652
+ )
1653
+ self._record_rpc_error_incident(
1654
+ method=str(method),
1655
+ error_type=type(err).__name__,
1656
+ error_message=str(err),
1657
+ )
1658
+ raise NoConnectionException(err) from err
1659
+ except (TypeError, Exception) as exc:
1660
+ self.clear_session()
1661
+ log_boundary_error(
1662
+ logger=_LOGGER,
1663
+ boundary="json-rpc",
1664
+ action=str(method),
1665
+ err=exc,
1666
+ level=logging.ERROR,
1667
+ log_context=self.log_context,
1668
+ )
1669
+ self._record_rpc_error_incident(
1670
+ method=str(method),
1671
+ error_type=type(exc).__name__,
1672
+ error_message=str(exc),
1673
+ )
1674
+ raise ClientException(exc) from exc
1675
+
1676
+ async def _do_renew_login(self, *, session_id: str) -> str | None:
1677
+ """Renew JSON-RPC session or perform login."""
1678
+ if self._has_session_recently_refreshed:
1679
+ return session_id
1680
+ method = _JsonRpcMethod.SESSION_RENEW
1681
+ try:
1682
+ response = await self._do_post(
1683
+ session_id=session_id,
1684
+ method=method,
1685
+ extra_params={_JsonKey.SESSION_ID: session_id},
1686
+ )
1687
+ if response[_JsonKey.RESULT] is True:
1688
+ self._last_session_id_refresh = datetime.now()
1689
+ _LOGGER.debug("DO_RENEW_LOGIN: method: %s [%s]", method, session_id)
1690
+ return session_id
1691
+ except AuthFailure:
1692
+ # Session is invalid (e.g., after CCU restart)
1693
+ # Try to logout old session before creating new one to prevent session leaks
1694
+ _LOGGER.debug("DO_RENEW_LOGIN: Session expired, attempting logout before fresh login")
1695
+ try:
1696
+ await self._do_logout(session_id=session_id)
1697
+ except BaseHomematicException:
1698
+ # Logout may fail if CCU was restarted, but that's okay
1699
+ # The CCU will eventually clean up expired sessions
1700
+ _LOGGER.debug("DO_RENEW_LOGIN: Logout of expired session failed (expected after CCU restart)")
1701
+
1702
+ return await self._do_login()
1703
+
1704
+ async def _get_auth_enabled(self) -> bool:
1705
+ """Get the auth_enabled flag of the backend."""
1706
+ _LOGGER.debug("GET_AUTH_ENABLED: Getting the flag auth_enabled")
1707
+ try:
1708
+ response = await self._post(method=_JsonRpcMethod.CCU_GET_AUTH_ENABLED)
1709
+ if (json_result := response[_JsonKey.RESULT]) is not None:
1710
+ return bool(json_result)
1711
+ except InternalBackendException:
1712
+ return True
1713
+
1714
+ return True
1715
+
1716
+ async def _get_https_redirect_enabled(self) -> bool | None:
1717
+ """Get the auth_enabled flag of the backend."""
1718
+ _LOGGER.debug("GET_HTTPS_REDIRECT_ENABLED: Getting the flag https_redirect_enabled")
1719
+
1720
+ response = await self._post(method=_JsonRpcMethod.CCU_GET_HTTPS_REDIRECT_ENABLED)
1721
+ if (json_result := response[_JsonKey.RESULT]) is not None:
1722
+ return bool(json_result)
1723
+ return None
1724
+
1725
+ async def _get_json_reponse(self, *, response: ClientResponse) -> dict[str, Any] | Any:
1726
+ """Return the json object from response."""
1727
+ try:
1728
+ return await response.json(encoding=UTF_8)
1729
+ except ValueError as verr:
1730
+ _LOGGER.debug(
1731
+ "DO_POST: ValueError [%s] Unable to parse JSON. Trying workaround",
1732
+ extract_exc_args(exc=verr),
1733
+ )
1734
+ # Workaround for bug in CCU
1735
+ return orjson.loads((await response.read()).decode(encoding=UTF_8))
1736
+
1737
+ async def _get_program_descriptions(self) -> Mapping[str, str]:
1738
+ """Get all program descriptions from the backend via script."""
1739
+ descriptions: dict[str, str] = {}
1740
+ try:
1741
+ response = await self._post_script(script_name=RegaScript.GET_PROGRAM_DESCRIPTIONS)
1742
+
1743
+ _LOGGER.debug("GET_PROGRAM_DESCRIPTIONS: Getting program descriptions")
1744
+ if json_result := response[_JsonKey.RESULT]:
1745
+ for data in json_result:
1746
+ descriptions[data[_JsonKey.ID]] = cleanup_text_from_html_tags(
1747
+ text=unquote(string=data[_JsonKey.DESCRIPTION], encoding=ISO_8859_1)
1748
+ )
1749
+ except JSONDecodeError as jderr:
1750
+ _LOGGER.error(
1751
+ i18n.tr(
1752
+ key="log.client.json_rpc.get_program_descriptions.decode_failed",
1753
+ reason=extract_exc_args(exc=jderr),
1754
+ )
1755
+ )
1756
+ return descriptions
1757
+
1758
+ async def _get_script(self, *, script_name: str) -> str | None:
1759
+ """Return a script from the script cache. Load if required."""
1760
+ if script_name in self._script_cache:
1761
+ return self._script_cache[script_name]
1762
+
1763
+ def _load_script(script_name: str) -> str | None:
1764
+ """Load script from file system."""
1765
+ script_file = os.path.join(Path(__file__).resolve().parent, REGA_SCRIPT_PATH, script_name)
1766
+ try:
1767
+ if script := Path(script_file).read_text(encoding=UTF_8):
1768
+ self._script_cache[script_name] = script
1769
+ return script
1770
+ except FileNotFoundError:
1771
+ return None
1772
+ return None
1773
+
1774
+ return await self._looper.async_add_executor_job(_load_script, script_name, name=f"load_script-{script_name}")
1775
+
1776
+ async def _get_serial(self) -> str | None:
1777
+ """Get the serial of the backend."""
1778
+ _LOGGER.debug("GET_SERIAL: Getting the backend serial")
1779
+ try:
1780
+ response = await self._post_script(script_name=RegaScript.GET_SERIAL)
1781
+
1782
+ if json_result := response[_JsonKey.RESULT]:
1783
+ # The backend may return a JSON string which needs to be decoded first
1784
+ # or an already-parsed dict. Support both.
1785
+ if isinstance(json_result, str):
1786
+ try:
1787
+ json_result = orjson.loads(json_result)
1788
+ except Exception:
1789
+ # Fall back to plain string handling; return last 10 chars
1790
+ serial_exc = str(json_result)
1791
+ return serial_exc[-10:] if len(serial_exc) > 10 else serial_exc
1792
+ serial: str = str(json_result.get(_JsonKey.SERIAL) if isinstance(json_result, dict) else json_result)
1793
+ if len(serial) > 10:
1794
+ serial = serial[-10:]
1795
+ return serial
1796
+ except JSONDecodeError as jderr:
1797
+ raise ClientException(jderr) from jderr
1798
+ return None
1799
+
1800
+ async def _get_supported_methods(self) -> tuple[str, ...]:
1801
+ """Get the supported methods of the backend."""
1802
+ supported_methods: tuple[str, ...] = ()
1803
+
1804
+ try:
1805
+ await self._login_or_renew()
1806
+ if not (session_id := self._session_id):
1807
+ raise ClientException(i18n.tr(key="exception.client.json_post.login_failed"))
1808
+
1809
+ response = await self._do_post(
1810
+ session_id=session_id,
1811
+ method=_JsonRpcMethod.SYSTEM_LIST_METHODS,
1812
+ )
1813
+
1814
+ _LOGGER.debug("GET_SUPPORTED_METHODS: Getting the supported methods")
1815
+ if json_result := response[_JsonKey.RESULT]:
1816
+ supported_methods = tuple(method_description[_JsonKey.NAME] for method_description in json_result)
1817
+ except BaseHomematicException:
1818
+ return ()
1819
+
1820
+ return supported_methods
1821
+
1822
+ async def _get_system_variable_descriptions(self) -> Mapping[str, str]:
1823
+ """Get all system variable descriptions from the backend via script."""
1824
+ descriptions: dict[str, str] = {}
1825
+ try:
1826
+ response = await self._post_script(script_name=RegaScript.GET_SYSTEM_VARIABLE_DESCRIPTIONS)
1827
+
1828
+ _LOGGER.debug("GET_SYSTEM_VARIABLE_DESCRIPTIONS: Getting system variable descriptions")
1829
+ if json_result := response[_JsonKey.RESULT]:
1830
+ for data in json_result:
1831
+ descriptions[data[_JsonKey.ID]] = cleanup_text_from_html_tags(
1832
+ text=unquote(string=data[_JsonKey.DESCRIPTION], encoding=ISO_8859_1)
1833
+ )
1834
+ except JSONDecodeError as jderr:
1835
+ _LOGGER.error(
1836
+ i18n.tr(
1837
+ key="log.client.json_rpc.get_system_variable_descriptions.decode_failed",
1838
+ reason=extract_exc_args(exc=jderr),
1839
+ )
1840
+ )
1841
+ return descriptions
1842
+
1843
+ async def _list_interfaces(self) -> tuple[str, ...]:
1844
+ """List all available interfaces from the backend."""
1845
+ _LOGGER.debug("LIST_INTERFACES: Getting all available interfaces")
1846
+
1847
+ response = await self._post(
1848
+ method=_JsonRpcMethod.INTERFACE_LIST_INTERFACES,
1849
+ )
1850
+
1851
+ if json_result := response[_JsonKey.RESULT]:
1852
+ return tuple(interface[_JsonKey.NAME] for interface in json_result)
1853
+ return ()
1854
+
1855
+ async def _login_or_renew(self) -> bool:
1856
+ """Renew JSON-RPC session or perform login."""
1857
+ if not self.is_activated:
1858
+ self._session_id = await self._do_login()
1859
+ self._last_session_id_refresh = datetime.now()
1860
+ return self._session_id is not None
1861
+ if self._session_id:
1862
+ self._session_id = await self._do_renew_login(session_id=self._session_id)
1863
+ return self._session_id is not None
1864
+
1865
+ async def _post(
1866
+ self,
1867
+ *,
1868
+ method: _JsonRpcMethod,
1869
+ extra_params: Mapping[Any, Any] | None = None,
1870
+ use_default_params: bool = True,
1871
+ keep_session: bool = True,
1872
+ ) -> dict[str, Any] | Any:
1873
+ """Reusable JSON-RPC POST function."""
1874
+ if keep_session:
1875
+ await self._login_or_renew()
1876
+ session_id = self._session_id
1877
+ else:
1878
+ session_id = await self._do_login()
1879
+
1880
+ if not session_id:
1881
+ raise ClientException(i18n.tr(key="exception.client.json_post.login_failed"))
1882
+
1883
+ if self._supported_methods is None:
1884
+ await self._check_supported_methods()
1885
+
1886
+ response = await self._do_post(
1887
+ session_id=session_id,
1888
+ method=method,
1889
+ extra_params=extra_params,
1890
+ use_default_params=use_default_params,
1891
+ )
1892
+
1893
+ if extra_params:
1894
+ _LOGGER.debug("POST method: %s [%s]", method, extra_params)
1895
+ else:
1896
+ _LOGGER.debug("POST method: %s", method)
1897
+
1898
+ if not keep_session:
1899
+ await self._do_logout(session_id=session_id)
1900
+
1901
+ return response
1902
+
1903
+ async def _post_script(
1904
+ self,
1905
+ *,
1906
+ script_name: str,
1907
+ extra_params: dict[_JsonKey, Any] | None = None,
1908
+ keep_session: bool = True,
1909
+ ) -> dict[str, Any] | Any:
1910
+ """Reusable JSON-RPC POST_SCRIPT function."""
1911
+ # Load and validate script first to avoid any network when script is missing
1912
+ if (script := await self._get_script(script_name=script_name)) is None:
1913
+ raise ClientException(i18n.tr(key="exception.client.script.missing", script=script_name))
1914
+
1915
+ # Prepare session only after we know we have a script to run
1916
+ if keep_session:
1917
+ await self._login_or_renew()
1918
+ session_id = self._session_id
1919
+ else:
1920
+ session_id = await self._do_login()
1921
+
1922
+ if not session_id:
1923
+ raise ClientException(i18n.tr(key="exception.client.json_post.login_failed"))
1924
+
1925
+ if self._supported_methods is None:
1926
+ await self._check_supported_methods()
1927
+
1928
+ if extra_params:
1929
+ for variable, value in extra_params.items():
1930
+ script = script.replace(f"##{variable}##", value)
1931
+
1932
+ method = _JsonRpcMethod.REGA_RUN_SCRIPT
1933
+ response = await self._do_post(
1934
+ session_id=session_id,
1935
+ method=method,
1936
+ extra_params={_JsonKey.SCRIPT: script},
1937
+ )
1938
+
1939
+ _LOGGER.debug("POST_SCRIPT: method: %s [%s]", method, script_name)
1940
+
1941
+ try:
1942
+ if not response[_JsonKey.ERROR] and (resp := response[_JsonKey.RESULT]) and isinstance(resp, str):
1943
+ response[_JsonKey.RESULT] = orjson.loads(resp)
1944
+ finally:
1945
+ if not keep_session:
1946
+ await self._do_logout(session_id=session_id)
1947
+
1948
+ return response
1949
+
1950
+ def _record_rpc_error_incident(
1951
+ self,
1952
+ *,
1953
+ method: str,
1954
+ error_type: str,
1955
+ error_message: str,
1956
+ is_expected: bool = False,
1957
+ ) -> None:
1958
+ """
1959
+ Record an RPC_ERROR incident for diagnostics.
1960
+
1961
+ Args:
1962
+ method: RPC method that failed.
1963
+ error_type: Type of error (e.g., JSONRPCError, HTTPError).
1964
+ error_message: Error message from the exception.
1965
+ is_expected: If True, use WARNING severity instead of ERROR.
1966
+ Expected errors are common during data loading and should
1967
+ not clutter logs.
1968
+
1969
+ """
1970
+ if (incident_recorder := self._incident_recorder) is None:
1971
+ return
1972
+
1973
+ # Sanitize error message to remove sensitive information
1974
+ sanitized_message = sanitize_error_message(message=error_message)
1975
+
1976
+ interface_id = self._interface_id or self._url
1977
+
1978
+ # Use WARNING for expected errors to reduce log noise
1979
+ severity = IncidentSeverity.WARNING if is_expected else IncidentSeverity.ERROR
1980
+
1981
+ context = {
1982
+ "protocol": "json-rpc",
1983
+ "method": method,
1984
+ "error_type": error_type,
1985
+ "error_message": sanitized_message,
1986
+ "tls_enabled": self._tls,
1987
+ }
1988
+
1989
+ async def _record() -> None:
1990
+ try:
1991
+ await incident_recorder.record_incident(
1992
+ incident_type=IncidentType.RPC_ERROR,
1993
+ severity=severity,
1994
+ message=f"RPC error on {interface_id}: {error_type} during {method}",
1995
+ interface_id=interface_id,
1996
+ context=context,
1997
+ )
1998
+ except Exception as err: # pragma: no cover
1999
+ _LOGGER.debug(
2000
+ "JSON_RPC: Failed to record RPC error incident for %s: %s",
2001
+ interface_id,
2002
+ err,
2003
+ )
2004
+
2005
+ # Schedule the async recording via looper
2006
+ self._looper.create_task(
2007
+ target=_record(),
2008
+ name=f"record_rpc_error_incident_{interface_id}",
2009
+ )
2010
+
2011
+ def _record_session(
2012
+ self,
2013
+ *,
2014
+ method: str,
2015
+ params: Mapping[str, Any],
2016
+ response: dict[str, Any] | None = None,
2017
+ exc: Exception | None = None,
2018
+ ) -> bool:
2019
+ """Record the session."""
2020
+ params = dict(params)
2021
+ if method == _JsonRpcMethod.SESSION_LOGIN and isinstance(params, dict):
2022
+ if params.get(_JsonKey.USERNAME):
2023
+ params[_JsonKey.USERNAME] = "********"
2024
+ if params.get(_JsonKey.PASSWORD):
2025
+ params[_JsonKey.PASSWORD] = "********"
2026
+
2027
+ if script := params.get(_JsonKey.SCRIPT):
2028
+ params[_JsonKey.SCRIPT] = cleanup_script_for_session_recorder(script=script)
2029
+
2030
+ if self._session_recorder and self._session_recorder.active:
2031
+ self._session_recorder.add_json_rpc_session(
2032
+ method=method, params=params, response=response, session_exc=exc
2033
+ )
2034
+ return True
2035
+ return False
2036
+
2037
+
2038
+ def _determine_ccu_type(*, product: str) -> CCUType:
2039
+ """
2040
+ Determine the CCU type.
2041
+
2042
+ CCU types:
2043
+ - CCU: Original CCU2/CCU3 hardware and debmatic (CCU clone)
2044
+ - OPENCCU: OpenCCU (modern variants with online update check)
2045
+
2046
+ """
2047
+ # Check for original CCU hardware and debmatic
2048
+ if (product_lower := product.lower()) in ("ccu"):
2049
+ return CCUType.CCU
2050
+
2051
+ if product_lower in ("openccu"):
2052
+ return CCUType.OPENCCU
2053
+
2054
+ return CCUType.UNKNOWN
2055
+
2056
+
2057
+ def _get_params(
2058
+ *,
2059
+ session_id: bool | str,
2060
+ extra_params: Mapping[Any, Any] | None,
2061
+ use_default_params: bool,
2062
+ ) -> Mapping[str, Any]:
2063
+ """Add additional params to default prams."""
2064
+ params: dict[Any, Any] = {_JsonKey.SESSION_ID: session_id} if use_default_params else {}
2065
+ if extra_params:
2066
+ params.update(extra_params)
2067
+
2068
+ return {str(key): str(value) for key, value in params.items()}