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,1857 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Client implementations for Homematic CCU and compatible backends.
5
+
6
+ This module provides concrete client classes that handle communication with
7
+ Homematic backends via XML-RPC and JSON-RPC protocols.
8
+
9
+ Public API
10
+ ----------
11
+ - ClientCCU: Primary client for CCU-compatible backends using XML-RPC for
12
+ device operations and optional JSON-RPC for metadata/program/sysvar access.
13
+ - ClientJsonCCU: Specialized client for CCU-Jack that prefers JSON-RPC
14
+ endpoints for all operations where available.
15
+ - ClientHomegear: Client for Homegear backend using XML-RPC exclusively.
16
+ - ClientConfig: Factory class that creates appropriate client instances
17
+ based on interface configuration and backend type.
18
+
19
+ Key features
20
+ ------------
21
+ - Automatic protocol selection based on backend capabilities
22
+ - Connection health tracking via circuit breaker pattern
23
+ - Request coalescing for duplicate concurrent requests
24
+ - Paramset caching and lazy loading
25
+ - Program and system variable management (CCU backends)
26
+ - Firmware update support (where available)
27
+
28
+ Usage
29
+ -----
30
+ Clients are typically created through CentralUnit, but can be instantiated
31
+ directly via ClientConfig:
32
+
33
+ config = ClientConfig(client_deps=deps, interface_config=iface_cfg)
34
+ client = await config.create_client()
35
+ await client.init_client()
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import asyncio
41
+ from dataclasses import replace
42
+ from datetime import datetime
43
+ import logging
44
+ from typing import Any, Final, cast
45
+
46
+ from aiohomematic import i18n
47
+ from aiohomematic.central.events import ClientStateChangedEvent, SystemStatusChangedEvent
48
+ from aiohomematic.client._rpc_errors import exception_to_failure_reason
49
+ from aiohomematic.client.backends.capabilities import (
50
+ CCU_CAPABILITIES,
51
+ HOMEGEAR_CAPABILITIES,
52
+ JSON_CCU_CAPABILITIES,
53
+ BackendCapabilities,
54
+ )
55
+ from aiohomematic.client.circuit_breaker import CircuitBreaker
56
+ from aiohomematic.client.config import InterfaceConfig
57
+ from aiohomematic.client.handlers import (
58
+ BackupHandler,
59
+ DeviceHandler,
60
+ FirmwareHandler,
61
+ LinkHandler,
62
+ MetadataHandler,
63
+ ProgramHandler,
64
+ SystemVariableHandler,
65
+ _wait_for_state_change_or_timeout,
66
+ )
67
+ from aiohomematic.client.request_coalescer import RequestCoalescer
68
+ from aiohomematic.client.rpc_proxy import AioXmlRpcProxy, BaseRpcProxy, NullRpcProxy
69
+ from aiohomematic.client.state_machine import ClientStateMachine
70
+ from aiohomematic.const import (
71
+ DATETIME_FORMAT_MILLIS,
72
+ DEFAULT_MAX_WORKERS,
73
+ DP_KEY_VALUE,
74
+ DUMMY_SERIAL,
75
+ INIT_DATETIME,
76
+ INTERFACES_REQUIRING_JSON_RPC_CLIENT,
77
+ INTERFACES_SUPPORTING_FIRMWARE_UPDATES,
78
+ INTERFACES_SUPPORTING_RPC_CALLBACK,
79
+ LINKABLE_INTERFACES,
80
+ VIRTUAL_REMOTE_MODELS,
81
+ WAIT_FOR_CALLBACK,
82
+ Backend,
83
+ BackupData,
84
+ CallSource,
85
+ CircuitState,
86
+ ClientState,
87
+ CommandRxMode,
88
+ DescriptionMarker,
89
+ DeviceDescription,
90
+ FailureReason,
91
+ ForcedDeviceAvailability,
92
+ InboxDeviceData,
93
+ Interface,
94
+ ParameterData,
95
+ ParameterType,
96
+ ParamsetKey,
97
+ ProductGroup,
98
+ ProgramData,
99
+ ProxyInitState,
100
+ ServiceMessageData,
101
+ ServiceMessageType,
102
+ SystemInformation,
103
+ SystemUpdateData,
104
+ SystemVariableData,
105
+ )
106
+ from aiohomematic.decorators import inspector
107
+ from aiohomematic.exceptions import BaseHomematicException, ClientException, NoConnectionException
108
+ from aiohomematic.interfaces.client import ClientDependenciesProtocol, ClientProtocol
109
+ from aiohomematic.interfaces.model import DeviceProtocol
110
+ from aiohomematic.property_decorators import DelegatedProperty
111
+ from aiohomematic.store.dynamic import CommandTracker, PingPongTracker
112
+ from aiohomematic.store.types import IncidentSeverity, IncidentType
113
+ from aiohomematic.support import (
114
+ LogContextMixin,
115
+ build_xml_rpc_headers,
116
+ build_xml_rpc_uri,
117
+ extract_exc_args,
118
+ get_device_address,
119
+ supports_rx_mode,
120
+ )
121
+
122
+ _LOGGER: Final = logging.getLogger(__name__)
123
+
124
+ _NAME: Final = "NAME"
125
+
126
+ _CCU_JSON_VALUE_TYPE: Final = {
127
+ "ACTION": "bool",
128
+ "BOOL": "bool",
129
+ "ENUM": "list",
130
+ "FLOAT": "double",
131
+ "INTEGER": "int",
132
+ "STRING": "string",
133
+ }
134
+
135
+
136
+ class ClientCCU(ClientProtocol, LogContextMixin):
137
+ """
138
+ Client object to access the backends via XML-RPC or JSON-RPC.
139
+
140
+ This class acts as a facade over specialized handler classes:
141
+ - DeviceHandler: Value read/write, paramset operations
142
+ - LinkHandler: Device linking operations
143
+ - FirmwareHandler: Firmware update operations
144
+ - SystemVariableHandler: System variable CRUD
145
+ - ProgramHandler: Program execution and state
146
+ - BackupHandler: Backup creation and download
147
+ - MetadataHandler: Metadata, renaming, rooms, functions, install mode
148
+ """
149
+
150
+ __slots__ = (
151
+ "_available",
152
+ "_backup_handler",
153
+ "_capabilities",
154
+ "_config",
155
+ "_connection_error_count",
156
+ "_device_ops_handler",
157
+ "_firmware_handler",
158
+ "_is_callback_alive",
159
+ "_is_initialized",
160
+ "_json_rpc_client",
161
+ "_last_value_send_tracker",
162
+ "_link_handler",
163
+ "_metadata_handler",
164
+ "_modified_at",
165
+ "_ping_pong_tracker",
166
+ "_program_handler",
167
+ "_proxy",
168
+ "_proxy_read",
169
+ "_reconnect_attempts",
170
+ "_state_machine",
171
+ "_sysvar_handler",
172
+ "_system_information",
173
+ "_unsubscribe_state_change",
174
+ "_unsubscribe_system_status",
175
+ )
176
+
177
+ def __init__(self, *, client_config: ClientConfig) -> None:
178
+ """Initialize the Client."""
179
+ self._config: Final = client_config
180
+ # Initialize capabilities based on config (backup updated in init_client)
181
+ self._capabilities: BackendCapabilities = replace(
182
+ CCU_CAPABILITIES,
183
+ firmware_updates=client_config.has_firmware_updates,
184
+ linking=client_config.has_linking,
185
+ ping_pong=client_config.has_ping_pong,
186
+ push_updates=client_config.has_push_updates,
187
+ rpc_callback=client_config.has_rpc_callback,
188
+ )
189
+ self._json_rpc_client: Final = client_config.client_deps.json_rpc_client
190
+ self._last_value_send_tracker: Final = CommandTracker(
191
+ interface_id=client_config.interface_id,
192
+ )
193
+ self._state_machine: Final = ClientStateMachine(
194
+ interface_id=client_config.interface_id,
195
+ event_bus=client_config.client_deps.event_bus,
196
+ )
197
+ # Subscribe to state changes to emit SystemStatusChangedEvent for integration compatibility
198
+ self._unsubscribe_state_change = client_config.client_deps.event_bus.subscribe(
199
+ event_type=ClientStateChangedEvent,
200
+ event_key=client_config.interface_id,
201
+ handler=self._on_client_state_changed_event,
202
+ )
203
+ self._connection_error_count: int = 0
204
+ self._is_callback_alive: bool = True
205
+ self._reconnect_attempts: int = 0
206
+ self._ping_pong_tracker: Final = PingPongTracker(
207
+ event_bus_provider=client_config.client_deps,
208
+ central_info=client_config.client_deps,
209
+ interface_id=client_config.interface_id,
210
+ connection_state=client_config.client_deps.connection_state,
211
+ incident_recorder=client_config.client_deps.cache_coordinator.incident_store,
212
+ )
213
+ self._proxy: BaseRpcProxy
214
+ self._proxy_read: BaseRpcProxy
215
+ self._system_information: SystemInformation
216
+ self._modified_at: datetime = INIT_DATETIME
217
+
218
+ # Subscribe to connection state changes to clear ping/pong cache on reconnect.
219
+ # This prevents stale pending pongs from causing false mismatch alarms
220
+ # after CCU restart when PINGs sent during downtime cannot be answered.
221
+ self._unsubscribe_system_status = client_config.client_deps.event_bus.subscribe(
222
+ event_type=SystemStatusChangedEvent,
223
+ event_key=None,
224
+ handler=self._on_system_status_event,
225
+ )
226
+
227
+ # Handler instances (initialized after proxy setup in init_client)
228
+ self._device_ops_handler: DeviceHandler
229
+ self._link_handler: LinkHandler
230
+ self._firmware_handler: FirmwareHandler
231
+ self._sysvar_handler: SystemVariableHandler
232
+ self._program_handler: ProgramHandler
233
+ self._backup_handler: BackupHandler
234
+ self._metadata_handler: MetadataHandler
235
+
236
+ def __str__(self) -> str:
237
+ """Provide some useful information."""
238
+ return f"interface_id: {self.interface_id}"
239
+
240
+ available: Final = DelegatedProperty[bool](path="_state_machine.is_available")
241
+ central: Final = DelegatedProperty[ClientDependenciesProtocol](path="_config.client_deps")
242
+ interface: Final = DelegatedProperty[Interface](path="_config.interface")
243
+ interface_id: Final = DelegatedProperty[str](path="_config.interface_id", log_context=True)
244
+ last_value_send_tracker: Final = DelegatedProperty[CommandTracker](path="_last_value_send_tracker")
245
+ ping_pong_tracker: Final = DelegatedProperty[PingPongTracker](path="_ping_pong_tracker")
246
+ state: Final = DelegatedProperty[ClientState](path="_state_machine.state")
247
+ state_machine: Final = DelegatedProperty[ClientStateMachine](path="_state_machine")
248
+ system_information: Final = DelegatedProperty[SystemInformation](path="_system_information")
249
+ version: Final = DelegatedProperty[str](path="_config.version")
250
+
251
+ @property
252
+ def all_circuit_breakers_closed(self) -> bool:
253
+ """Return True if all circuit breakers are in closed state."""
254
+ if self._proxy.circuit_breaker.state != CircuitState.CLOSED:
255
+ return False
256
+ if (
257
+ hasattr(self, "_proxy_read")
258
+ and self._proxy_read is not self._proxy
259
+ and self._proxy_read.circuit_breaker.state != CircuitState.CLOSED
260
+ ):
261
+ return False
262
+ return self._json_rpc_client.circuit_breaker.state == CircuitState.CLOSED
263
+
264
+ @property
265
+ def capabilities(self) -> BackendCapabilities:
266
+ """Return the capability flags for this backend."""
267
+ return self._capabilities
268
+
269
+ @property
270
+ def circuit_breaker(self) -> CircuitBreaker:
271
+ """Return the primary circuit breaker for metrics access."""
272
+ return self._proxy.circuit_breaker
273
+
274
+ @property
275
+ def is_initialized(self) -> bool:
276
+ """Return if interface is initialized."""
277
+ return self._state_machine.state in (
278
+ ClientState.CONNECTED,
279
+ ClientState.DISCONNECTED,
280
+ ClientState.RECONNECTING,
281
+ )
282
+
283
+ @property
284
+ def model(self) -> str:
285
+ """Return the model of the backend."""
286
+ return Backend.CCU
287
+
288
+ @property
289
+ def modified_at(self) -> datetime:
290
+ """Return the last update datetime value."""
291
+ return self._modified_at
292
+
293
+ @modified_at.setter
294
+ def modified_at(self, value: datetime) -> None:
295
+ """Write the last update datetime value."""
296
+ self._modified_at = value
297
+
298
+ @property
299
+ def request_coalescer(self) -> RequestCoalescer | None:
300
+ """Return the request coalescer for metrics access."""
301
+ if hasattr(self, "_device_ops_handler"):
302
+ return self._device_ops_handler.paramset_description_coalescer
303
+ return None
304
+
305
+ async def accept_device_in_inbox(self, *, device_address: str) -> bool:
306
+ """Accept a device from the CCU inbox."""
307
+ return await self._metadata_handler.accept_device_in_inbox(device_address=device_address)
308
+
309
+ async def add_link(
310
+ self,
311
+ *,
312
+ sender_address: str,
313
+ receiver_address: str,
314
+ name: str,
315
+ description: str,
316
+ ) -> None:
317
+ """Add a link between two devices."""
318
+ return await self._link_handler.add_link(
319
+ sender_address=sender_address,
320
+ receiver_address=receiver_address,
321
+ name=name,
322
+ description=description,
323
+ )
324
+
325
+ @inspector(re_raise=False, no_raise_return=False)
326
+ async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
327
+ """Check if _proxy is still initialized."""
328
+ ping_timeout = self._config.client_deps.config.timeout_config.ping_timeout
329
+ try:
330
+ dt_now = datetime.now()
331
+ if handle_ping_pong and self._capabilities.ping_pong and self.is_initialized:
332
+ token = dt_now.strftime(format=DATETIME_FORMAT_MILLIS)
333
+ callerId = f"{self.interface_id}#{token}"
334
+ # Register token BEFORE sending ping to avoid race condition:
335
+ # CCU may respond with PONG before await returns
336
+ self._ping_pong_tracker.handle_send_ping(ping_token=token)
337
+ async with asyncio.timeout(ping_timeout):
338
+ await self._proxy.ping(callerId)
339
+ elif not self.is_initialized:
340
+ async with asyncio.timeout(ping_timeout):
341
+ await self._proxy.ping(self.interface_id)
342
+ self.modified_at = dt_now
343
+ except TimeoutError:
344
+ _LOGGER.debug(
345
+ "CHECK_CONNECTION_AVAILABILITY: Ping timeout after %.1fs for %s",
346
+ ping_timeout,
347
+ self.interface_id,
348
+ )
349
+ except BaseHomematicException as bhexc:
350
+ _LOGGER.debug(
351
+ "CHECK_CONNECTION_AVAILABILITY failed: %s [%s]",
352
+ bhexc.name,
353
+ extract_exc_args(exc=bhexc),
354
+ )
355
+ else:
356
+ return True
357
+ self.modified_at = INIT_DATETIME
358
+ return False
359
+
360
+ def clear_json_rpc_session(self) -> None:
361
+ """Clear the JSON-RPC session to force re-authentication on next request."""
362
+ self._json_rpc_client.clear_session()
363
+ _LOGGER.debug(
364
+ "CLEAR_JSON_RPC_SESSION: Session cleared for %s",
365
+ self.interface_id,
366
+ )
367
+
368
+ async def create_backup_and_download(
369
+ self,
370
+ *,
371
+ max_wait_time: float = 300.0,
372
+ poll_interval: float = 5.0,
373
+ ) -> BackupData | None:
374
+ """Create a backup on the CCU and download it."""
375
+ return await self._backup_handler.create_backup_and_download(
376
+ max_wait_time=max_wait_time,
377
+ poll_interval=poll_interval,
378
+ )
379
+
380
+ async def deinitialize_proxy(self) -> ProxyInitState:
381
+ """De-init to stop the backend from sending events for this remote."""
382
+ if not self._capabilities.rpc_callback:
383
+ self._state_machine.transition_to(target=ClientState.DISCONNECTED, reason="no callback support")
384
+ return ProxyInitState.DE_INIT_SUCCESS
385
+
386
+ if self.modified_at == INIT_DATETIME:
387
+ _LOGGER.debug(
388
+ "PROXY_DE_INIT: Skipping de-init for %s (not initialized)",
389
+ self.interface_id,
390
+ )
391
+ return ProxyInitState.DE_INIT_SKIPPED
392
+ try:
393
+ _LOGGER.debug("PROXY_DE_INIT: init('%s')", self._config.init_url)
394
+ await self._proxy.init(self._config.init_url)
395
+ self._state_machine.transition_to(target=ClientState.DISCONNECTED, reason="proxy de-initialized")
396
+ except BaseHomematicException as bhexc:
397
+ _LOGGER.warning( # i18n-log: ignore
398
+ "PROXY_DE_INIT failed: %s [%s] Unable to de-initialize proxy for %s",
399
+ bhexc.name,
400
+ extract_exc_args(exc=bhexc),
401
+ self.interface_id,
402
+ )
403
+ return ProxyInitState.DE_INIT_FAILED
404
+
405
+ self.modified_at = INIT_DATETIME
406
+ return ProxyInitState.DE_INIT_SUCCESS
407
+
408
+ async def delete_system_variable(self, *, name: str) -> bool:
409
+ """Delete a system variable from the backend."""
410
+ return await self._sysvar_handler.delete_system_variable(name=name)
411
+
412
+ async def execute_program(self, *, pid: str) -> bool:
413
+ """Execute a program on the backend."""
414
+ return await self._program_handler.execute_program(pid=pid)
415
+
416
+ async def fetch_all_device_data(self) -> None:
417
+ """Fetch all device data from the backend."""
418
+ return await self._device_ops_handler.fetch_all_device_data()
419
+
420
+ async def fetch_device_details(self) -> None:
421
+ """Get all names via JSON-RPS and store in data.NAMES."""
422
+ return await self._device_ops_handler.fetch_device_details()
423
+
424
+ async def fetch_paramset_description(self, *, channel_address: str, paramset_key: ParamsetKey) -> None:
425
+ """Fetch a specific paramset and add it to the known ones."""
426
+ return await self._device_ops_handler.fetch_paramset_description(
427
+ channel_address=channel_address,
428
+ paramset_key=paramset_key,
429
+ )
430
+
431
+ async def fetch_paramset_descriptions(self, *, device_description: DeviceDescription) -> None:
432
+ """Fetch paramsets for provided device description."""
433
+ return await self._device_ops_handler.fetch_paramset_descriptions(device_description=device_description)
434
+
435
+ async def get_all_device_descriptions(self, *, device_address: str) -> tuple[DeviceDescription, ...]:
436
+ """Get all device descriptions from the backend."""
437
+ return await self._device_ops_handler.get_all_device_descriptions(device_address=device_address)
438
+
439
+ async def get_all_functions(self) -> dict[str, set[str]]:
440
+ """Get all functions from the backend."""
441
+ return await self._metadata_handler.get_all_functions()
442
+
443
+ async def get_all_paramset_descriptions(
444
+ self, *, device_descriptions: tuple[DeviceDescription, ...]
445
+ ) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
446
+ """Get all paramset descriptions for provided device descriptions."""
447
+ return await self._device_ops_handler.get_all_paramset_descriptions(device_descriptions=device_descriptions)
448
+
449
+ async def get_all_programs(
450
+ self,
451
+ *,
452
+ markers: tuple[DescriptionMarker | str, ...],
453
+ ) -> tuple[ProgramData, ...]:
454
+ """Get all programs, if available."""
455
+ return await self._program_handler.get_all_programs(markers=markers)
456
+
457
+ async def get_all_rooms(self) -> dict[str, set[str]]:
458
+ """Get all rooms from the backend."""
459
+ return await self._metadata_handler.get_all_rooms()
460
+
461
+ async def get_all_system_variables(
462
+ self,
463
+ *,
464
+ markers: tuple[DescriptionMarker | str, ...],
465
+ ) -> tuple[SystemVariableData, ...] | None:
466
+ """Get all system variables from the backend."""
467
+ return await self._sysvar_handler.get_all_system_variables(markers=markers)
468
+
469
+ async def get_device_description(self, *, address: str) -> DeviceDescription | None:
470
+ """Get device descriptions from the backend."""
471
+ return await self._device_ops_handler.get_device_description(address=address)
472
+
473
+ async def get_inbox_devices(self) -> tuple[InboxDeviceData, ...]:
474
+ """Get all devices in the inbox (not yet configured)."""
475
+ return await self._metadata_handler.get_inbox_devices()
476
+
477
+ async def get_install_mode(self) -> int:
478
+ """Return the remaining time in install mode."""
479
+ return await self._metadata_handler.get_install_mode()
480
+
481
+ async def get_link_peers(self, *, address: str) -> tuple[str, ...]:
482
+ """Return a list of link peers."""
483
+ return await self._link_handler.get_link_peers(address=address)
484
+
485
+ async def get_links(self, *, address: str, flags: int) -> dict[str, Any]:
486
+ """Return a list of links."""
487
+ return await self._link_handler.get_links(address=address, flags=flags)
488
+
489
+ async def get_metadata(self, *, address: str, data_id: str) -> dict[str, Any]:
490
+ """Return the metadata for an object."""
491
+ return await self._metadata_handler.get_metadata(address=address, data_id=data_id)
492
+
493
+ async def get_paramset(
494
+ self,
495
+ *,
496
+ address: str,
497
+ paramset_key: ParamsetKey | str,
498
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
499
+ ) -> dict[str, Any]:
500
+ """Return a paramset from the backend."""
501
+ return await self._device_ops_handler.get_paramset(
502
+ address=address,
503
+ paramset_key=paramset_key,
504
+ call_source=call_source,
505
+ )
506
+
507
+ async def get_paramset_descriptions(
508
+ self, *, device_description: DeviceDescription
509
+ ) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
510
+ """Get paramsets for provided device description."""
511
+ return await self._device_ops_handler.get_paramset_descriptions(device_description=device_description)
512
+
513
+ def get_product_group(self, *, model: str) -> ProductGroup:
514
+ """Return the product group."""
515
+ l_model = model.lower()
516
+ if l_model.startswith("hmipw-"):
517
+ return ProductGroup.HMIPW
518
+ if l_model.startswith("hmip-"):
519
+ return ProductGroup.HMIP
520
+ if l_model.startswith("hmw-"):
521
+ return ProductGroup.HMW
522
+ if l_model.startswith("hm-"):
523
+ return ProductGroup.HM
524
+ if self.interface == Interface.HMIP_RF:
525
+ return ProductGroup.HMIP
526
+ if self.interface == Interface.BIDCOS_WIRED:
527
+ return ProductGroup.HMW
528
+ if self.interface == Interface.BIDCOS_RF:
529
+ return ProductGroup.HM
530
+ if self.interface == Interface.VIRTUAL_DEVICES:
531
+ return ProductGroup.VIRTUAL
532
+ return ProductGroup.UNKNOWN
533
+
534
+ async def get_rega_id_by_address(self, *, address: str) -> int | None:
535
+ """Get the ReGa ID for a device or channel address."""
536
+ return await self._metadata_handler.get_rega_id_by_address(address=address)
537
+
538
+ async def get_service_messages(
539
+ self,
540
+ *,
541
+ message_type: ServiceMessageType | None = None,
542
+ ) -> tuple[ServiceMessageData, ...]:
543
+ """Get all active service messages from the backend."""
544
+ return await self._metadata_handler.get_service_messages(message_type=message_type)
545
+
546
+ async def get_system_update_info(self) -> SystemUpdateData | None:
547
+ """Get system update information from the backend."""
548
+ return await self._metadata_handler.get_system_update_info()
549
+
550
+ async def get_system_variable(self, *, name: str) -> Any:
551
+ """Get single system variable from the backend."""
552
+ return await self._sysvar_handler.get_system_variable(name=name)
553
+
554
+ async def get_value(
555
+ self,
556
+ *,
557
+ channel_address: str,
558
+ paramset_key: ParamsetKey,
559
+ parameter: str,
560
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
561
+ ) -> Any:
562
+ """Return a value from the backend."""
563
+ return await self._device_ops_handler.get_value(
564
+ channel_address=channel_address,
565
+ paramset_key=paramset_key,
566
+ parameter=parameter,
567
+ call_source=call_source,
568
+ )
569
+
570
+ def get_virtual_remote(self) -> DeviceProtocol | None:
571
+ """Get the virtual remote for the Client."""
572
+ for model in VIRTUAL_REMOTE_MODELS:
573
+ for device in self.central.device_registry.devices:
574
+ if device.interface_id == self.interface_id and device.model == model:
575
+ return device
576
+ return None
577
+
578
+ async def has_program_ids(self, *, rega_id: int) -> bool:
579
+ """Return if a channel has program ids."""
580
+ return await self._program_handler.has_program_ids(rega_id=rega_id)
581
+
582
+ @inspector
583
+ async def init_client(self) -> None:
584
+ """Initialize the client."""
585
+ self._state_machine.transition_to(target=ClientState.INITIALIZING)
586
+ try:
587
+ self._system_information = await self._get_system_information()
588
+ # Update capabilities with backup from system information
589
+ if not self._system_information.has_backup:
590
+ self._capabilities = replace(self._capabilities, backup=False)
591
+ if self._capabilities.rpc_callback:
592
+ self._proxy = await self._config.create_rpc_proxy(
593
+ interface=self.interface,
594
+ auth_enabled=self.system_information.auth_enabled,
595
+ )
596
+ self._proxy_read = await self._config.create_rpc_proxy(
597
+ interface=self.interface,
598
+ auth_enabled=self.system_information.auth_enabled,
599
+ max_workers=self._config.max_read_workers,
600
+ )
601
+ self._init_handlers()
602
+ self._state_machine.transition_to(target=ClientState.INITIALIZED)
603
+ except Exception as exc:
604
+ self._state_machine.transition_to(
605
+ target=ClientState.FAILED,
606
+ reason=str(exc),
607
+ failure_reason=exception_to_failure_reason(exc=exc),
608
+ )
609
+ raise
610
+
611
+ async def initialize_proxy(self) -> ProxyInitState:
612
+ """Initialize the proxy has to tell the backend where to send the events."""
613
+ self._state_machine.transition_to(target=ClientState.CONNECTING)
614
+ if not self._capabilities.rpc_callback:
615
+ if (device_descriptions := await self.list_devices()) is not None:
616
+ await self.central.device_coordinator.add_new_devices(
617
+ interface_id=self.interface_id, device_descriptions=device_descriptions
618
+ )
619
+ self._state_machine.transition_to(
620
+ target=ClientState.CONNECTED, reason="proxy initialized (no callback)"
621
+ )
622
+ return ProxyInitState.INIT_SUCCESS
623
+ self._state_machine.transition_to(
624
+ target=ClientState.FAILED,
625
+ reason="device listing failed",
626
+ failure_reason=FailureReason.NETWORK,
627
+ )
628
+ # Mark devices as unavailable when device listing fails
629
+ self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
630
+ return ProxyInitState.INIT_FAILED
631
+ # Record modified_at before init to detect callback during init
632
+ # This is used to work around VirtualDevices service bug where init()
633
+ # times out but listDevices callback was successfully received
634
+ modified_at_before_init = self.modified_at
635
+ init_success = False
636
+ try:
637
+ _LOGGER.debug("PROXY_INIT: init('%s', '%s')", self._config.init_url, self.interface_id)
638
+ self._ping_pong_tracker.clear()
639
+ await self._proxy.init(self._config.init_url, self.interface_id)
640
+ init_success = True
641
+ except BaseHomematicException as bhexc:
642
+ # Check if we received a callback during init (modified_at was updated)
643
+ # This happens when init() times out but the CCU successfully processed it
644
+ # and called back listDevices. Common with VirtualDevices service bug.
645
+ if self.modified_at > modified_at_before_init:
646
+ _LOGGER.info( # i18n-log: ignore
647
+ "PROXY_INIT: init() failed but callback received for %s - treating as success",
648
+ self.interface_id,
649
+ )
650
+ init_success = True
651
+ else:
652
+ _LOGGER.error( # i18n-log: ignore
653
+ "PROXY_INIT failed: %s [%s] Unable to initialize proxy for %s",
654
+ bhexc.name,
655
+ extract_exc_args(exc=bhexc),
656
+ self.interface_id,
657
+ )
658
+ self.modified_at = INIT_DATETIME
659
+ self._state_machine.transition_to(
660
+ target=ClientState.FAILED,
661
+ reason="proxy init failed",
662
+ failure_reason=exception_to_failure_reason(exc=bhexc),
663
+ )
664
+ # Mark devices as unavailable when proxy init fails
665
+ # This ensures data points show unavailable during CCU restart/recovery
666
+ self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
667
+ return ProxyInitState.INIT_FAILED
668
+
669
+ if init_success:
670
+ self._state_machine.transition_to(target=ClientState.CONNECTED, reason="proxy initialized")
671
+ self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.NOT_SET)
672
+ # Clear any stale connection issues from failed attempts during reconnection
673
+ # This ensures subsequent RPC calls are not blocked
674
+ self._proxy.clear_connection_issue()
675
+ _LOGGER.debug("PROXY_INIT: Proxy for %s initialized", self.interface_id)
676
+
677
+ # Recreate proxies AFTER successful init to get fresh HTTP transport
678
+ # This prevents "ResponseNotReady" errors on subsequent requests that occur
679
+ # when the HTTP connection is in an inconsistent state after reconnection.
680
+ # The callback URL remains unchanged (XML-RPC server port stays the same).
681
+ try:
682
+ _LOGGER.debug(
683
+ "PROXY_INIT: Recreating proxy objects for %s to get fresh HTTP transport",
684
+ self.interface_id,
685
+ )
686
+ self._proxy = await self._config.create_rpc_proxy(
687
+ interface=self.interface,
688
+ auth_enabled=self.system_information.auth_enabled,
689
+ )
690
+ self._proxy_read = await self._config.create_rpc_proxy(
691
+ interface=self.interface,
692
+ auth_enabled=self.system_information.auth_enabled,
693
+ max_workers=self._config.max_read_workers,
694
+ )
695
+ self._init_handlers()
696
+ _LOGGER.debug("PROXY_INIT: Proxies recreated with fresh transport for %s", self.interface_id)
697
+ except Exception as exc:
698
+ _LOGGER.warning( # i18n-log: ignore
699
+ "PROXY_INIT: Failed to recreate proxies for %s: %s - continuing with existing proxies",
700
+ self.interface_id,
701
+ exc,
702
+ )
703
+ self.modified_at = datetime.now()
704
+ return ProxyInitState.INIT_SUCCESS
705
+
706
+ def is_callback_alive(self) -> bool:
707
+ """Return if XmlRPC-Server is alive based on received events for this client."""
708
+ if not self._capabilities.ping_pong:
709
+ return True
710
+
711
+ # If client is in RECONNECTING or FAILED state, callback is definitely not alive
712
+ # This ensures reconnection continues after CCU restart until init() succeeds
713
+ if self._state_machine.is_failed or self._state_machine.state == ClientState.RECONNECTING:
714
+ return False
715
+
716
+ # Check event timestamp for all other states (including startup states)
717
+ if (
718
+ last_events_dt := self.central.event_coordinator.get_last_event_seen_for_interface(
719
+ interface_id=self.interface_id
720
+ )
721
+ ) is not None:
722
+ callback_warn = self._config.client_deps.config.timeout_config.callback_warn_interval
723
+ if (seconds_since_last_event := (datetime.now() - last_events_dt).total_seconds()) > callback_warn:
724
+ if self._is_callback_alive:
725
+ self.central.event_bus.publish_sync(
726
+ event=SystemStatusChangedEvent(
727
+ timestamp=datetime.now(),
728
+ callback_state=(self.interface_id, False),
729
+ )
730
+ )
731
+ self._is_callback_alive = False
732
+ self._record_callback_timeout_incident(
733
+ seconds_since_last_event=seconds_since_last_event,
734
+ callback_warn_interval=callback_warn,
735
+ last_event_time=last_events_dt,
736
+ )
737
+ _LOGGER.error(
738
+ i18n.tr(
739
+ key="log.client.is_callback_alive.no_events",
740
+ interface_id=self.interface_id,
741
+ seconds=int(seconds_since_last_event),
742
+ )
743
+ )
744
+ return False
745
+
746
+ if not self._is_callback_alive:
747
+ self.central.event_bus.publish_sync(
748
+ event=SystemStatusChangedEvent(
749
+ timestamp=datetime.now(),
750
+ callback_state=(self.interface_id, True),
751
+ )
752
+ )
753
+ self._is_callback_alive = True
754
+ return True
755
+
756
+ @inspector(re_raise=False, no_raise_return=False)
757
+ async def is_connected(self) -> bool:
758
+ """
759
+ Perform actions required for connectivity check.
760
+
761
+ Connection is not connected if consecutive checks exceed threshold.
762
+ Return connectivity state.
763
+ """
764
+ if await self.check_connection_availability(handle_ping_pong=True) is True:
765
+ self._connection_error_count = 0
766
+ else:
767
+ self._connection_error_count += 1
768
+
769
+ error_threshold = self._config.client_deps.config.timeout_config.connectivity_error_threshold
770
+ if self._connection_error_count > error_threshold:
771
+ self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
772
+ # Update state machine to reflect connection loss
773
+ if self._state_machine.state == ClientState.CONNECTED:
774
+ self._state_machine.transition_to(
775
+ target=ClientState.DISCONNECTED,
776
+ reason=f"connection check failed (>{error_threshold} errors)",
777
+ )
778
+ return False
779
+ if not self._capabilities.push_updates:
780
+ return True
781
+
782
+ callback_warn = self._config.client_deps.config.timeout_config.callback_warn_interval
783
+ return (datetime.now() - self.modified_at).total_seconds() < callback_warn
784
+
785
+ async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
786
+ """List devices of the backend."""
787
+ return await self._device_ops_handler.list_devices()
788
+
789
+ async def put_paramset(
790
+ self,
791
+ *,
792
+ channel_address: str,
793
+ paramset_key_or_link_address: ParamsetKey | str,
794
+ values: dict[str, Any],
795
+ wait_for_callback: int | None = WAIT_FOR_CALLBACK,
796
+ rx_mode: CommandRxMode | None = None,
797
+ check_against_pd: bool = False,
798
+ ) -> set[DP_KEY_VALUE]:
799
+ """Set paramsets manually."""
800
+ return await self._device_ops_handler.put_paramset(
801
+ channel_address=channel_address,
802
+ paramset_key_or_link_address=paramset_key_or_link_address,
803
+ values=values,
804
+ wait_for_callback=wait_for_callback,
805
+ rx_mode=rx_mode,
806
+ check_against_pd=check_against_pd,
807
+ )
808
+
809
+ async def reconnect(self) -> bool:
810
+ """Re-init all RPC clients with exponential backoff."""
811
+ if self._state_machine.can_reconnect:
812
+ self._state_machine.transition_to(target=ClientState.RECONNECTING)
813
+
814
+ # Calculate exponential backoff delay using timeout_config
815
+ timeout_cfg = self._config.client_deps.config.timeout_config
816
+ delay = min(
817
+ timeout_cfg.reconnect_initial_delay * (timeout_cfg.reconnect_backoff_factor**self._reconnect_attempts),
818
+ timeout_cfg.reconnect_max_delay,
819
+ )
820
+ _LOGGER.debug(
821
+ "RECONNECT: waiting to re-connect client %s for %.1fs (attempt %d)",
822
+ self.interface_id,
823
+ delay,
824
+ self._reconnect_attempts + 1,
825
+ )
826
+ await asyncio.sleep(delay)
827
+
828
+ if await self.reinitialize_proxy() == ProxyInitState.INIT_SUCCESS:
829
+ # Reset circuit breakers after successful reconnect to allow
830
+ # immediate data refresh without waiting for recovery timeout
831
+ self.reset_circuit_breakers()
832
+ self._reconnect_attempts = 0 # Reset on success
833
+ self._connection_error_count = 0 # Reset error count on success
834
+ _LOGGER.info(
835
+ i18n.tr(
836
+ key="log.client.reconnect.reconnected",
837
+ interface_id=self.interface_id,
838
+ )
839
+ )
840
+ return True
841
+ # Increment attempt counter for next reconnect try
842
+ self._reconnect_attempts += 1
843
+ # State machine already transitioned in reinitialize_proxy
844
+ return False
845
+
846
+ async def reinitialize_proxy(self) -> ProxyInitState:
847
+ """Reinit Proxy."""
848
+ await self.deinitialize_proxy()
849
+ return await self.initialize_proxy()
850
+
851
+ async def remove_link(self, *, sender_address: str, receiver_address: str) -> None:
852
+ """Remove a link between two devices."""
853
+ return await self._link_handler.remove_link(
854
+ sender_address=sender_address,
855
+ receiver_address=receiver_address,
856
+ )
857
+
858
+ async def rename_channel(self, *, rega_id: int, new_name: str) -> bool:
859
+ """Rename a channel on the CCU."""
860
+ return await self._metadata_handler.rename_channel(rega_id=rega_id, new_name=new_name)
861
+
862
+ async def rename_device(self, *, rega_id: int, new_name: str) -> bool:
863
+ """Rename a device on the CCU."""
864
+ return await self._metadata_handler.rename_device(rega_id=rega_id, new_name=new_name)
865
+
866
+ async def report_value_usage(self, *, address: str, value_id: str, ref_counter: int) -> bool:
867
+ """Report value usage."""
868
+ return await self._device_ops_handler.report_value_usage(
869
+ address=address,
870
+ value_id=value_id,
871
+ ref_counter=ref_counter,
872
+ supports=self._capabilities.value_usage_reporting,
873
+ )
874
+
875
+ def reset_circuit_breakers(self) -> None:
876
+ """Reset all circuit breakers to closed state."""
877
+ self._proxy.circuit_breaker.reset()
878
+ if hasattr(self, "_proxy_read") and self._proxy_read is not self._proxy:
879
+ self._proxy_read.circuit_breaker.reset()
880
+ self._json_rpc_client.circuit_breaker.reset()
881
+ _LOGGER.debug(
882
+ "RESET_CIRCUIT_BREAKERS: All circuit breakers reset for %s",
883
+ self.interface_id,
884
+ )
885
+
886
+ async def set_install_mode(
887
+ self,
888
+ *,
889
+ on: bool = True,
890
+ time: int = 60,
891
+ mode: int = 1,
892
+ device_address: str | None = None,
893
+ ) -> bool:
894
+ """Set the install mode on the backend."""
895
+ return await self._metadata_handler.set_install_mode(
896
+ on=on,
897
+ time=time,
898
+ mode=mode,
899
+ device_address=device_address,
900
+ )
901
+
902
+ async def set_metadata(self, *, address: str, data_id: str, value: dict[str, Any]) -> dict[str, Any]:
903
+ """Write the metadata for an object."""
904
+ return await self._metadata_handler.set_metadata(address=address, data_id=data_id, value=value)
905
+
906
+ async def set_program_state(self, *, pid: str, state: bool) -> bool:
907
+ """Set the program state on the backend."""
908
+ return await self._program_handler.set_program_state(pid=pid, state=state)
909
+
910
+ async def set_system_variable(self, *, legacy_name: str, value: Any) -> bool:
911
+ """Set a system variable on the backend."""
912
+ return await self._sysvar_handler.set_system_variable(legacy_name=legacy_name, value=value)
913
+
914
+ async def set_value(
915
+ self,
916
+ *,
917
+ channel_address: str,
918
+ paramset_key: ParamsetKey,
919
+ parameter: str,
920
+ value: Any,
921
+ wait_for_callback: int | None = WAIT_FOR_CALLBACK,
922
+ rx_mode: CommandRxMode | None = None,
923
+ check_against_pd: bool = False,
924
+ ) -> set[DP_KEY_VALUE]:
925
+ """Set single value on paramset VALUES."""
926
+ return await self._device_ops_handler.set_value(
927
+ channel_address=channel_address,
928
+ paramset_key=paramset_key,
929
+ parameter=parameter,
930
+ value=value,
931
+ wait_for_callback=wait_for_callback,
932
+ rx_mode=rx_mode,
933
+ check_against_pd=check_against_pd,
934
+ )
935
+
936
+ async def stop(self) -> None:
937
+ """Stop depending services."""
938
+ # Unsubscribe from state change events before stopping
939
+ self._unsubscribe_state_change()
940
+ self._unsubscribe_system_status()
941
+ self._state_machine.transition_to(target=ClientState.STOPPING, reason="stop() called")
942
+ if self._capabilities.rpc_callback:
943
+ await self._proxy.stop()
944
+ await self._proxy_read.stop()
945
+ self._state_machine.transition_to(target=ClientState.STOPPED, reason="services stopped")
946
+
947
+ async def trigger_firmware_update(self) -> bool:
948
+ """Trigger the CCU firmware update process."""
949
+ return await self._firmware_handler.trigger_firmware_update()
950
+
951
+ async def update_device_firmware(self, *, device_address: str) -> bool:
952
+ """Update the firmware of a Homematic device."""
953
+ return await self._firmware_handler.update_device_firmware(device_address=device_address)
954
+
955
+ async def update_paramset_descriptions(self, *, device_address: str) -> None:
956
+ """Update paramsets descriptions for provided device_address."""
957
+ return await self._device_ops_handler.update_paramset_descriptions(device_address=device_address)
958
+
959
+ async def _get_system_information(self) -> SystemInformation:
960
+ """Get system information of the backend."""
961
+ return await self._json_rpc_client.get_system_information()
962
+
963
+ def _init_handlers(self) -> None:
964
+ """Initialize all handler instances."""
965
+ self._device_ops_handler = DeviceHandler(
966
+ client_deps=self._config.client_deps,
967
+ interface=self._config.interface,
968
+ interface_id=self._config.interface_id,
969
+ json_rpc_client=self._json_rpc_client,
970
+ proxy=self._proxy,
971
+ proxy_read=self._proxy_read,
972
+ last_value_send_tracker=self._last_value_send_tracker,
973
+ )
974
+
975
+ self._link_handler = LinkHandler(
976
+ client_deps=self._config.client_deps,
977
+ interface=self._config.interface,
978
+ interface_id=self._config.interface_id,
979
+ json_rpc_client=self._json_rpc_client,
980
+ proxy=self._proxy,
981
+ proxy_read=self._proxy_read,
982
+ has_linking=self._capabilities.linking,
983
+ )
984
+
985
+ self._firmware_handler = FirmwareHandler(
986
+ client_deps=self._config.client_deps,
987
+ interface=self._config.interface,
988
+ interface_id=self._config.interface_id,
989
+ json_rpc_client=self._json_rpc_client,
990
+ proxy=self._proxy,
991
+ proxy_read=self._proxy_read,
992
+ has_device_firmware_update=self._capabilities.device_firmware_update,
993
+ has_firmware_update_trigger=self._capabilities.firmware_update_trigger,
994
+ )
995
+
996
+ self._sysvar_handler = SystemVariableHandler(
997
+ client_deps=self._config.client_deps,
998
+ interface=self._config.interface,
999
+ interface_id=self._config.interface_id,
1000
+ json_rpc_client=self._json_rpc_client,
1001
+ proxy=self._proxy,
1002
+ proxy_read=self._proxy_read,
1003
+ )
1004
+
1005
+ self._program_handler = ProgramHandler(
1006
+ client_deps=self._config.client_deps,
1007
+ interface=self._config.interface,
1008
+ interface_id=self._config.interface_id,
1009
+ json_rpc_client=self._json_rpc_client,
1010
+ proxy=self._proxy,
1011
+ proxy_read=self._proxy_read,
1012
+ has_programs=self._capabilities.programs,
1013
+ )
1014
+
1015
+ self._backup_handler = BackupHandler(
1016
+ client_deps=self._config.client_deps,
1017
+ interface=self._config.interface,
1018
+ interface_id=self._config.interface_id,
1019
+ json_rpc_client=self._json_rpc_client,
1020
+ proxy=self._proxy,
1021
+ proxy_read=self._proxy_read,
1022
+ has_backup=self._capabilities.backup,
1023
+ system_information=self._system_information,
1024
+ )
1025
+
1026
+ self._metadata_handler = MetadataHandler(
1027
+ client_deps=self._config.client_deps,
1028
+ interface=self._config.interface,
1029
+ interface_id=self._config.interface_id,
1030
+ json_rpc_client=self._json_rpc_client,
1031
+ proxy=self._proxy,
1032
+ proxy_read=self._proxy_read,
1033
+ has_functions=self._capabilities.functions,
1034
+ has_inbox_devices=self._capabilities.inbox_devices,
1035
+ has_install_mode=self._capabilities.install_mode,
1036
+ has_metadata=self._capabilities.metadata,
1037
+ has_rega_id_lookup=self._capabilities.rega_id_lookup,
1038
+ has_rename=self._capabilities.rename,
1039
+ has_rooms=self._capabilities.rooms,
1040
+ has_service_messages=self._capabilities.service_messages,
1041
+ has_system_update_info=self._capabilities.system_update_info,
1042
+ )
1043
+
1044
+ def _mark_all_devices_forced_availability(self, *, forced_availability: ForcedDeviceAvailability) -> None:
1045
+ """Mark device's availability state for this interface."""
1046
+ available = forced_availability != ForcedDeviceAvailability.FORCE_FALSE
1047
+ # Always update devices when marking unavailable (FORCE_FALSE) to ensure
1048
+ # data points show unavailable during connection failures.
1049
+ # Only skip updates when already in matching available state.
1050
+ if not available or self._state_machine.is_available != available:
1051
+ for device in self.central.device_registry.devices:
1052
+ if device.interface_id == self.interface_id:
1053
+ device.set_forced_availability(forced_availability=forced_availability)
1054
+ _LOGGER.debug(
1055
+ "MARK_ALL_DEVICES_FORCED_AVAILABILITY: marked all devices %s for %s",
1056
+ "available" if available else "unavailable",
1057
+ self.interface_id,
1058
+ )
1059
+
1060
+ def _on_client_state_changed_event(self, *, event: ClientStateChangedEvent) -> None:
1061
+ """Handle client state machine transitions by emitting SystemStatusChangedEvent for integration compatibility."""
1062
+ self._config.client_deps.event_bus.publish_sync(
1063
+ event=SystemStatusChangedEvent(
1064
+ timestamp=datetime.now(),
1065
+ client_state=(event.interface_id, ClientState(event.old_state), ClientState(event.new_state)),
1066
+ )
1067
+ )
1068
+
1069
+ def _on_system_status_event(self, *, event: SystemStatusChangedEvent) -> None:
1070
+ """Handle system status events to clear ping/pong cache on reconnect."""
1071
+ if event.connection_state and event.connection_state[0] == self.interface_id and event.connection_state[1]:
1072
+ # Clear stale ping/pong state when connection is restored.
1073
+ # PINGs sent during CCU downtime cannot receive PONGs, so the cache
1074
+ # would contain stale entries that cause false mismatch alarms.
1075
+ self._ping_pong_tracker.clear()
1076
+ _LOGGER.debug(
1077
+ "PING PONG CACHE: Cleared on connection restored: %s",
1078
+ self.interface_id,
1079
+ )
1080
+
1081
+ def _record_callback_timeout_incident(
1082
+ self,
1083
+ *,
1084
+ seconds_since_last_event: float,
1085
+ callback_warn_interval: float,
1086
+ last_event_time: datetime,
1087
+ ) -> None:
1088
+ """Record a CALLBACK_TIMEOUT incident for diagnostics."""
1089
+ incident_recorder = self._config.client_deps.cache_coordinator.incident_store
1090
+
1091
+ # Get circuit breaker state safely (_proxy may not be set during early startup)
1092
+ circuit_breaker_state: str | None = None
1093
+ if hasattr(self, "_proxy") and hasattr(self._proxy, "circuit_breaker"):
1094
+ circuit_breaker_state = self._proxy.circuit_breaker.state.value
1095
+
1096
+ context = {
1097
+ "seconds_since_last_event": round(seconds_since_last_event, 2),
1098
+ "callback_warn_interval": callback_warn_interval,
1099
+ "last_event_time": last_event_time.strftime(DATETIME_FORMAT_MILLIS),
1100
+ "client_state": self._state_machine.state.value,
1101
+ "circuit_breaker_state": circuit_breaker_state,
1102
+ }
1103
+
1104
+ async def _record() -> None:
1105
+ try:
1106
+ await incident_recorder.record_incident(
1107
+ incident_type=IncidentType.CALLBACK_TIMEOUT,
1108
+ severity=IncidentSeverity.WARNING,
1109
+ message=f"No callback received for {self.interface_id} in {int(seconds_since_last_event)} seconds",
1110
+ interface_id=self.interface_id,
1111
+ context=context,
1112
+ )
1113
+ except Exception as err:
1114
+ _LOGGER.debug("Failed to record CALLBACK_TIMEOUT incident: %s", err)
1115
+
1116
+ self._config.client_deps.looper.create_task(
1117
+ target=_record(),
1118
+ name=f"record_callback_timeout_incident_{self.interface_id}",
1119
+ )
1120
+
1121
+ async def _set_value(
1122
+ self,
1123
+ *,
1124
+ channel_address: str,
1125
+ parameter: str,
1126
+ value: Any,
1127
+ wait_for_callback: int | None,
1128
+ rx_mode: CommandRxMode | None = None,
1129
+ check_against_pd: bool = False,
1130
+ ) -> set[DP_KEY_VALUE]:
1131
+ """Set single value on paramset VALUES (internal implementation)."""
1132
+ return await self._device_ops_handler.set_value_internal(
1133
+ channel_address=channel_address,
1134
+ parameter=parameter,
1135
+ value=value,
1136
+ wait_for_callback=wait_for_callback,
1137
+ rx_mode=rx_mode,
1138
+ check_against_pd=check_against_pd,
1139
+ )
1140
+
1141
+
1142
+ class ClientJsonCCU(ClientCCU):
1143
+ """Client implementation for CCU-like backend (CCU-Jack)."""
1144
+
1145
+ def __init__(self, *, client_config: ClientConfig) -> None:
1146
+ """Initialize the Client."""
1147
+ super().__init__(client_config=client_config)
1148
+ # Override capabilities with JSON_CCU_CAPABILITIES
1149
+ self._capabilities = replace(
1150
+ JSON_CCU_CAPABILITIES,
1151
+ push_updates=client_config.has_push_updates,
1152
+ )
1153
+
1154
+ @inspector(re_raise=False, no_raise_return=False)
1155
+ async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
1156
+ """Check if proxy is still initialized."""
1157
+ ping_timeout = self._config.client_deps.config.timeout_config.ping_timeout
1158
+ try:
1159
+ async with asyncio.timeout(ping_timeout):
1160
+ return await self._json_rpc_client.is_present(interface=self.interface)
1161
+ except TimeoutError:
1162
+ _LOGGER.debug(
1163
+ "CHECK_CONNECTION_AVAILABILITY: Timeout after %.1fs for %s",
1164
+ ping_timeout,
1165
+ self.interface_id,
1166
+ )
1167
+ return False
1168
+
1169
+ async def fetch_paramset_description(self, *, channel_address: str, paramset_key: ParamsetKey) -> None:
1170
+ """Fetch a specific paramset and add it to the known ones."""
1171
+ _LOGGER.debug("FETCH_PARAMSET_DESCRIPTION for %s/%s", channel_address, paramset_key)
1172
+ if paramset_description := await self._get_paramset_description(
1173
+ address=channel_address, paramset_key=paramset_key
1174
+ ):
1175
+ self.central.cache_coordinator.paramset_descriptions.add(
1176
+ interface_id=self.interface_id,
1177
+ channel_address=channel_address,
1178
+ paramset_key=paramset_key,
1179
+ paramset_description=paramset_description,
1180
+ )
1181
+
1182
+ async def fetch_paramset_descriptions(self, *, device_description: DeviceDescription) -> None:
1183
+ """Fetch paramsets for provided device description."""
1184
+ data = await self.get_paramset_descriptions(device_description=device_description)
1185
+ for address, paramsets in data.items():
1186
+ _LOGGER.debug("FETCH_PARAMSET_DESCRIPTIONS for %s", address)
1187
+ for paramset_key, paramset_description in paramsets.items():
1188
+ self.central.cache_coordinator.paramset_descriptions.add(
1189
+ interface_id=self.interface_id,
1190
+ channel_address=address,
1191
+ paramset_key=paramset_key,
1192
+ paramset_description=paramset_description,
1193
+ )
1194
+
1195
+ @inspector(re_raise=False)
1196
+ async def get_all_device_descriptions(self, *, device_address: str) -> tuple[DeviceDescription, ...]:
1197
+ """Return device description and all child channel descriptions."""
1198
+ all_device_description: list[DeviceDescription] = []
1199
+ if main_dd := await self.get_device_description(address=device_address):
1200
+ all_device_description.append(main_dd)
1201
+ else:
1202
+ _LOGGER.warning( # i18n-log: ignore
1203
+ "GET_ALL_DEVICE_DESCRIPTIONS: No device description for %s",
1204
+ device_address,
1205
+ )
1206
+
1207
+ if main_dd:
1208
+ for channel_address in main_dd.get("CHILDREN", []):
1209
+ if channel_dd := await self.get_device_description(address=channel_address):
1210
+ all_device_description.append(channel_dd)
1211
+ else:
1212
+ _LOGGER.warning( # i18n-log: ignore
1213
+ "GET_ALL_DEVICE_DESCRIPTIONS: No channel description for %s",
1214
+ channel_address,
1215
+ )
1216
+ return tuple(all_device_description)
1217
+
1218
+ async def get_all_paramset_descriptions(
1219
+ self, *, device_descriptions: tuple[DeviceDescription, ...]
1220
+ ) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
1221
+ """Get all paramset descriptions for provided device descriptions."""
1222
+ all_paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
1223
+ for device_description in device_descriptions:
1224
+ all_paramsets.update(await self.get_paramset_descriptions(device_description=device_description))
1225
+ return all_paramsets
1226
+
1227
+ @inspector(re_raise=False)
1228
+ async def get_device_description(self, *, address: str) -> DeviceDescription | None:
1229
+ """Get device descriptions from the backend."""
1230
+ try:
1231
+ if device_description := await self._json_rpc_client.get_device_description(
1232
+ interface=self.interface, address=address
1233
+ ):
1234
+ return device_description
1235
+ except BaseHomematicException as bhexc:
1236
+ _LOGGER.warning( # i18n-log: ignore
1237
+ "GET_DEVICE_DESCRIPTIONS failed: %s [%s]", bhexc.name, extract_exc_args(exc=bhexc)
1238
+ )
1239
+ return None
1240
+
1241
+ @inspector
1242
+ async def get_paramset(
1243
+ self,
1244
+ *,
1245
+ address: str,
1246
+ paramset_key: ParamsetKey | str,
1247
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
1248
+ ) -> dict[str, Any]:
1249
+ """Return a paramset from the backend."""
1250
+ try:
1251
+ _LOGGER.debug(
1252
+ "GET_PARAMSET: address %s, paramset_key %s, source %s",
1253
+ address,
1254
+ paramset_key,
1255
+ call_source,
1256
+ )
1257
+ return (
1258
+ await self._json_rpc_client.get_paramset(
1259
+ interface=self.interface, address=address, paramset_key=paramset_key
1260
+ )
1261
+ or {}
1262
+ )
1263
+ except BaseHomematicException as bhexc:
1264
+ raise ClientException(
1265
+ i18n.tr(
1266
+ key="exception.client.json_ccu.get_paramset.failed",
1267
+ address=address,
1268
+ paramset_key=paramset_key,
1269
+ reason=extract_exc_args(exc=bhexc),
1270
+ )
1271
+ ) from bhexc
1272
+
1273
+ @inspector(re_raise=False, no_raise_return={})
1274
+ async def get_paramset_descriptions(
1275
+ self, *, device_description: DeviceDescription
1276
+ ) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
1277
+ """Get paramsets for provided device description."""
1278
+ paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
1279
+ address = device_description["ADDRESS"]
1280
+ paramsets[address] = {}
1281
+ _LOGGER.debug("GET_PARAMSET_DESCRIPTIONS for %s", address)
1282
+ for p_key in device_description["PARAMSETS"]:
1283
+ paramset_key = ParamsetKey(p_key)
1284
+ if paramset_description := await self._get_paramset_description(address=address, paramset_key=paramset_key):
1285
+ paramsets[address][paramset_key] = paramset_description
1286
+ return paramsets
1287
+
1288
+ @inspector(log_level=logging.NOTSET)
1289
+ async def get_value(
1290
+ self,
1291
+ *,
1292
+ channel_address: str,
1293
+ paramset_key: ParamsetKey,
1294
+ parameter: str,
1295
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
1296
+ ) -> Any:
1297
+ """Return a value from the backend."""
1298
+ try:
1299
+ _LOGGER.debug(
1300
+ "GET_VALUE: channel_address %s, parameter %s, paramset_key, %s, source:%s",
1301
+ channel_address,
1302
+ parameter,
1303
+ paramset_key,
1304
+ call_source,
1305
+ )
1306
+ if paramset_key == ParamsetKey.VALUES:
1307
+ return await self._json_rpc_client.get_value(
1308
+ interface=self.interface,
1309
+ address=channel_address,
1310
+ paramset_key=paramset_key,
1311
+ parameter=parameter,
1312
+ )
1313
+ paramset = (
1314
+ await self._json_rpc_client.get_paramset(
1315
+ interface=self.interface,
1316
+ address=channel_address,
1317
+ paramset_key=ParamsetKey.MASTER,
1318
+ )
1319
+ or {}
1320
+ )
1321
+ return paramset.get(parameter)
1322
+ except BaseHomematicException as bhexc:
1323
+ raise ClientException(
1324
+ i18n.tr(
1325
+ key="exception.client.json_ccu.get_value.failed",
1326
+ channel_address=channel_address,
1327
+ parameter=parameter,
1328
+ paramset_key=paramset_key,
1329
+ reason=extract_exc_args(exc=bhexc),
1330
+ )
1331
+ ) from bhexc
1332
+
1333
+ @inspector
1334
+ async def init_client(self) -> None:
1335
+ """Initialize the client."""
1336
+ self._state_machine.transition_to(target=ClientState.INITIALIZING)
1337
+ try:
1338
+ self._system_information = await self._get_system_information()
1339
+ # Use NullRpcProxy since ClientJsonCCU uses JSON-RPC exclusively.
1340
+ # The handlers are needed for JSON-RPC operations but don't use proxies.
1341
+ self._proxy = NullRpcProxy(
1342
+ interface_id=self.interface_id,
1343
+ connection_state=self._config.client_deps.connection_state,
1344
+ event_bus=self._config.client_deps.event_bus,
1345
+ )
1346
+ self._proxy_read = self._proxy
1347
+ self._init_handlers()
1348
+ self._state_machine.transition_to(target=ClientState.INITIALIZED)
1349
+ except Exception as exc:
1350
+ self._state_machine.transition_to(
1351
+ target=ClientState.FAILED,
1352
+ reason=str(exc),
1353
+ failure_reason=exception_to_failure_reason(exc=exc),
1354
+ )
1355
+ raise
1356
+
1357
+ @inspector(re_raise=False, measure_performance=True)
1358
+ async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
1359
+ """List devices of Homematic backend."""
1360
+ try:
1361
+ return await self._json_rpc_client.list_devices(interface=self.interface)
1362
+ except BaseHomematicException as bhexc:
1363
+ _LOGGER.debug(
1364
+ "LIST_DEVICES failed with %s [%s]",
1365
+ bhexc.name,
1366
+ extract_exc_args(exc=bhexc),
1367
+ )
1368
+ return None
1369
+
1370
+ @inspector(re_raise=False, no_raise_return=set())
1371
+ async def put_paramset(
1372
+ self,
1373
+ *,
1374
+ channel_address: str,
1375
+ paramset_key_or_link_address: ParamsetKey | str,
1376
+ values: dict[str, Any],
1377
+ wait_for_callback: int | None = WAIT_FOR_CALLBACK,
1378
+ rx_mode: CommandRxMode | None = None,
1379
+ check_against_pd: bool = False,
1380
+ ) -> set[DP_KEY_VALUE]:
1381
+ """
1382
+ Set paramsets manually via JSON-RPC.
1383
+
1384
+ Overrides the base class to use JSON-RPC instead of XML-RPC proxy.
1385
+ """
1386
+ try:
1387
+ await self._exec_put_paramset(
1388
+ channel_address=channel_address,
1389
+ paramset_key=paramset_key_or_link_address,
1390
+ values=values,
1391
+ rx_mode=rx_mode,
1392
+ )
1393
+ # store the send value in the last_value_send_tracker
1394
+ dpk_values = self._last_value_send_tracker.add_put_paramset(
1395
+ channel_address=channel_address,
1396
+ paramset_key=ParamsetKey(paramset_key_or_link_address),
1397
+ values=values,
1398
+ )
1399
+ self._write_temporary_value(dpk_values=dpk_values)
1400
+ except BaseHomematicException as bhexc:
1401
+ raise ClientException(
1402
+ i18n.tr(
1403
+ key="exception.client.put_paramset.failed",
1404
+ channel_address=channel_address,
1405
+ paramset_key=paramset_key_or_link_address,
1406
+ values=values,
1407
+ reason=extract_exc_args(exc=bhexc),
1408
+ )
1409
+ ) from bhexc
1410
+ else:
1411
+ return dpk_values
1412
+
1413
+ @inspector(re_raise=False, no_raise_return=set())
1414
+ async def set_value(
1415
+ self,
1416
+ *,
1417
+ channel_address: str,
1418
+ paramset_key: ParamsetKey,
1419
+ parameter: str,
1420
+ value: Any,
1421
+ wait_for_callback: int | None = WAIT_FOR_CALLBACK,
1422
+ rx_mode: CommandRxMode | None = None,
1423
+ check_against_pd: bool = False,
1424
+ ) -> set[DP_KEY_VALUE]:
1425
+ """
1426
+ Set single value on paramset VALUES via JSON-RPC.
1427
+
1428
+ Overrides the base class to use JSON-RPC instead of XML-RPC proxy.
1429
+ """
1430
+ if paramset_key != ParamsetKey.VALUES:
1431
+ return await self.put_paramset(
1432
+ channel_address=channel_address,
1433
+ paramset_key_or_link_address=paramset_key,
1434
+ values={parameter: value},
1435
+ wait_for_callback=wait_for_callback,
1436
+ rx_mode=rx_mode,
1437
+ check_against_pd=check_against_pd,
1438
+ )
1439
+
1440
+ try:
1441
+ _LOGGER.debug("SET_VALUE: %s, %s, %s", channel_address, parameter, value)
1442
+ if rx_mode and (device := self.central.device_coordinator.get_device(address=channel_address)):
1443
+ if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
1444
+ await self._exec_set_value(
1445
+ channel_address=channel_address,
1446
+ parameter=parameter,
1447
+ value=value,
1448
+ rx_mode=rx_mode,
1449
+ )
1450
+ else:
1451
+ raise ClientException(i18n.tr(key="exception.client.rx_mode.unsupported", rx_mode=rx_mode))
1452
+ else:
1453
+ await self._exec_set_value(channel_address=channel_address, parameter=parameter, value=value)
1454
+
1455
+ # store the send value in the last_value_send_tracker
1456
+ dpk_values = self._last_value_send_tracker.add_set_value(
1457
+ channel_address=channel_address, parameter=parameter, value=value
1458
+ )
1459
+ self._write_temporary_value(dpk_values=dpk_values)
1460
+
1461
+ if wait_for_callback is not None and (
1462
+ device := self.central.device_coordinator.get_device(
1463
+ address=get_device_address(address=channel_address)
1464
+ )
1465
+ ):
1466
+ await _wait_for_state_change_or_timeout(
1467
+ device=device,
1468
+ dpk_values=dpk_values,
1469
+ wait_for_callback=wait_for_callback,
1470
+ )
1471
+ except BaseHomematicException as bhexc:
1472
+ raise ClientException(
1473
+ i18n.tr(
1474
+ key="exception.client.set_value.failed",
1475
+ channel_address=channel_address,
1476
+ parameter=parameter,
1477
+ value=value,
1478
+ reason=extract_exc_args(exc=bhexc),
1479
+ )
1480
+ ) from bhexc
1481
+ else:
1482
+ return dpk_values
1483
+
1484
+ @inspector(re_raise=False)
1485
+ async def update_paramset_descriptions(self, *, device_address: str) -> None:
1486
+ """Re-fetch and update paramset descriptions for a device."""
1487
+ if not self.central.cache_coordinator.device_descriptions.get_device_descriptions(
1488
+ interface_id=self.interface_id
1489
+ ):
1490
+ _LOGGER.warning( # i18n-log: ignore
1491
+ "UPDATE_PARAMSET_DESCRIPTIONS failed: Interface missing in central cache. Not updating paramsets for %s",
1492
+ device_address,
1493
+ )
1494
+ return
1495
+
1496
+ if device_description := self.central.cache_coordinator.device_descriptions.find_device_description(
1497
+ interface_id=self.interface_id, device_address=device_address
1498
+ ):
1499
+ await self.fetch_paramset_descriptions(device_description=device_description)
1500
+ else:
1501
+ _LOGGER.warning( # i18n-log: ignore
1502
+ "UPDATE_PARAMSET_DESCRIPTIONS failed: Channel missing in central.cache. Not updating paramsets for %s",
1503
+ device_address,
1504
+ )
1505
+ return
1506
+ await self.central.save_files(save_paramset_descriptions=True)
1507
+
1508
+ async def _exec_put_paramset(
1509
+ self,
1510
+ *,
1511
+ channel_address: str,
1512
+ paramset_key: ParamsetKey | str,
1513
+ values: dict[str, Any],
1514
+ rx_mode: CommandRxMode | None = None,
1515
+ ) -> None:
1516
+ """Put paramset into the backend."""
1517
+ for parameter, value in values.items():
1518
+ await self._exec_set_value(
1519
+ channel_address=channel_address, parameter=parameter, value=value, rx_mode=rx_mode
1520
+ )
1521
+
1522
+ async def _exec_set_value(
1523
+ self,
1524
+ *,
1525
+ channel_address: str,
1526
+ parameter: str,
1527
+ value: Any,
1528
+ rx_mode: CommandRxMode | None = None,
1529
+ ) -> None:
1530
+ """Set single value on paramset VALUES."""
1531
+ if (
1532
+ value_type := self._get_parameter_type(
1533
+ channel_address=channel_address,
1534
+ paramset_key=ParamsetKey.VALUES,
1535
+ parameter=parameter,
1536
+ )
1537
+ ) is None:
1538
+ raise ClientException(
1539
+ i18n.tr(
1540
+ key="exception.client.json_ccu.set_value.unknown_type",
1541
+ channel_address=channel_address,
1542
+ paramset_key=ParamsetKey.VALUES,
1543
+ parameter=parameter,
1544
+ )
1545
+ )
1546
+
1547
+ _type = _CCU_JSON_VALUE_TYPE.get(value_type, "string")
1548
+ await self._json_rpc_client.set_value(
1549
+ interface=self.interface,
1550
+ address=channel_address,
1551
+ parameter=parameter,
1552
+ value_type=_type,
1553
+ value=value,
1554
+ )
1555
+
1556
+ def _get_parameter_type(
1557
+ self,
1558
+ *,
1559
+ channel_address: str,
1560
+ paramset_key: ParamsetKey,
1561
+ parameter: str,
1562
+ ) -> ParameterType | None:
1563
+ """Return the parameter type for a given parameter."""
1564
+ if parameter_data := self.central.cache_coordinator.paramset_descriptions.get_parameter_data(
1565
+ interface_id=self.interface_id,
1566
+ channel_address=channel_address,
1567
+ paramset_key=paramset_key,
1568
+ parameter=parameter,
1569
+ ):
1570
+ return parameter_data["TYPE"]
1571
+ return None
1572
+
1573
+ async def _get_paramset_description(
1574
+ self, *, address: str, paramset_key: ParamsetKey
1575
+ ) -> dict[str, ParameterData] | None:
1576
+ """Get paramset description from the backend."""
1577
+ try:
1578
+ return cast(
1579
+ dict[str, ParameterData],
1580
+ await self._json_rpc_client.get_paramset_description(
1581
+ interface=self.interface, address=address, paramset_key=paramset_key
1582
+ ),
1583
+ )
1584
+ except BaseHomematicException as bhexc:
1585
+ _LOGGER.debug(
1586
+ "GET_PARAMSET_DESCRIPTIONS failed with %s [%s] for %s address %s",
1587
+ bhexc.name,
1588
+ extract_exc_args(exc=bhexc),
1589
+ paramset_key,
1590
+ address,
1591
+ )
1592
+ return None
1593
+
1594
+ async def _get_system_information(self) -> SystemInformation:
1595
+ """Get system information of the backend."""
1596
+ return SystemInformation(
1597
+ available_interfaces=(self.interface,),
1598
+ serial=f"{self.interface}_{DUMMY_SERIAL}",
1599
+ )
1600
+
1601
+ def _write_temporary_value(self, *, dpk_values: set[DP_KEY_VALUE]) -> None:
1602
+ """Write temporary values to polling data points for immediate UI feedback."""
1603
+ for dpk, value in dpk_values:
1604
+ if (
1605
+ data_point := self.central.get_generic_data_point(
1606
+ channel_address=dpk.channel_address,
1607
+ parameter=dpk.parameter,
1608
+ paramset_key=dpk.paramset_key,
1609
+ )
1610
+ ) and data_point.requires_polling:
1611
+ data_point.write_temporary_value(value=value, write_at=datetime.now())
1612
+
1613
+
1614
+ class ClientHomegear(ClientCCU):
1615
+ """
1616
+ Client implementation for Homegear backend.
1617
+
1618
+ Inherit from ClientCCU to share common behavior used by tests and code paths
1619
+ that expect a CCU-like client interface for Homegear selections.
1620
+ """
1621
+
1622
+ def __init__(self, *, client_config: ClientConfig) -> None:
1623
+ """Initialize the Client."""
1624
+ super().__init__(client_config=client_config)
1625
+ # Override capabilities with HOMEGEAR_CAPABILITIES
1626
+ self._capabilities = replace(
1627
+ HOMEGEAR_CAPABILITIES,
1628
+ push_updates=client_config.has_push_updates,
1629
+ )
1630
+
1631
+ @property
1632
+ def model(self) -> str:
1633
+ """Return the model of the backend."""
1634
+ if self._config.version:
1635
+ return Backend.PYDEVCCU if Backend.PYDEVCCU.lower() in self._config.version else Backend.HOMEGEAR
1636
+ return Backend.CCU
1637
+
1638
+ @inspector(re_raise=False, no_raise_return=False)
1639
+ async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
1640
+ """Check if proxy is still initialized."""
1641
+ ping_timeout = self._config.client_deps.config.timeout_config.ping_timeout
1642
+ try:
1643
+ async with asyncio.timeout(ping_timeout):
1644
+ await self._proxy.clientServerInitialized(self.interface_id)
1645
+ self.modified_at = datetime.now()
1646
+ except TimeoutError:
1647
+ _LOGGER.debug(
1648
+ "CHECK_CONNECTION_AVAILABILITY: Timeout after %.1fs for %s",
1649
+ ping_timeout,
1650
+ self.interface_id,
1651
+ )
1652
+ except BaseHomematicException as bhexc:
1653
+ _LOGGER.debug(
1654
+ "CHECK_CONNECTION_AVAILABILITY failed: %s [%s]",
1655
+ bhexc.name,
1656
+ extract_exc_args(exc=bhexc),
1657
+ )
1658
+ else:
1659
+ return True
1660
+ self.modified_at = INIT_DATETIME
1661
+ return False
1662
+
1663
+ @inspector
1664
+ async def delete_system_variable(self, *, name: str) -> bool:
1665
+ """Delete a system variable from the backend."""
1666
+ await self._proxy.deleteSystemVariable(name)
1667
+ return True
1668
+
1669
+ @inspector(re_raise=False, measure_performance=True)
1670
+ async def fetch_all_device_data(self) -> None:
1671
+ """Fetch all device data from the backend."""
1672
+ return
1673
+
1674
+ @inspector(re_raise=False, measure_performance=True)
1675
+ async def fetch_device_details(self) -> None:
1676
+ """Get all names from metadata (Homegear)."""
1677
+ _LOGGER.debug("FETCH_DEVICE_DETAILS: Fetching names via Metadata")
1678
+ for address in self.central.cache_coordinator.device_descriptions.get_device_descriptions(
1679
+ interface_id=self.interface_id
1680
+ ):
1681
+ try:
1682
+ self.central.cache_coordinator.device_details.add_name(
1683
+ address=address,
1684
+ name=await self._proxy_read.getMetadata(address, _NAME),
1685
+ )
1686
+ except BaseHomematicException as bhexc:
1687
+ _LOGGER.warning( # i18n-log: ignore
1688
+ "%s [%s] Failed to fetch name for device %s",
1689
+ bhexc.name,
1690
+ extract_exc_args(exc=bhexc),
1691
+ address,
1692
+ )
1693
+
1694
+ @inspector(re_raise=False)
1695
+ async def get_all_system_variables(
1696
+ self, *, markers: tuple[DescriptionMarker | str, ...]
1697
+ ) -> tuple[SystemVariableData, ...] | None:
1698
+ """Get all system variables from the backend."""
1699
+ variables: list[SystemVariableData] = []
1700
+ if hg_variables := await self._proxy.getAllSystemVariables():
1701
+ for name, value in hg_variables.items():
1702
+ variables.append(SystemVariableData(vid=name, legacy_name=name, value=value))
1703
+ return tuple(variables)
1704
+
1705
+ @inspector
1706
+ async def get_system_variable(self, *, name: str) -> Any:
1707
+ """Get single system variable from the backend."""
1708
+ return await self._proxy.getSystemVariable(name)
1709
+
1710
+ @inspector(measure_performance=True)
1711
+ async def set_system_variable(self, *, legacy_name: str, value: Any) -> bool:
1712
+ """Set a system variable on the backend."""
1713
+ await self._proxy.setSystemVariable(legacy_name, value)
1714
+ return True
1715
+
1716
+ async def _get_system_information(self) -> SystemInformation:
1717
+ """Get system information of the backend."""
1718
+ return SystemInformation(available_interfaces=(Interface.BIDCOS_RF,), serial=f"{self.interface}_{DUMMY_SERIAL}")
1719
+
1720
+
1721
+ class ClientConfig:
1722
+ """Config for a Client."""
1723
+
1724
+ def __init__(
1725
+ self,
1726
+ *,
1727
+ client_deps: ClientDependenciesProtocol,
1728
+ interface_config: InterfaceConfig,
1729
+ ) -> None:
1730
+ """Initialize the config."""
1731
+ self.client_deps: Final[ClientDependenciesProtocol] = client_deps
1732
+ self.version: str = "0"
1733
+ self.system_information = SystemInformation()
1734
+ self.interface_config: Final = interface_config
1735
+ self.interface: Final = interface_config.interface
1736
+ self.interface_id: Final = interface_config.interface_id
1737
+ self.max_read_workers: Final[int] = client_deps.config.max_read_workers
1738
+ self.has_credentials: Final[bool] = (
1739
+ client_deps.config.username is not None and client_deps.config.password is not None
1740
+ )
1741
+ self.has_linking: Final = self.interface in LINKABLE_INTERFACES
1742
+ self.has_firmware_updates: Final = self.interface in INTERFACES_SUPPORTING_FIRMWARE_UPDATES
1743
+ self.has_ping_pong: Final = self.interface in INTERFACES_SUPPORTING_RPC_CALLBACK
1744
+ self.has_push_updates: Final = self.interface not in client_deps.config.interfaces_requiring_periodic_refresh
1745
+ self.has_rpc_callback: Final = self.interface in INTERFACES_SUPPORTING_RPC_CALLBACK
1746
+ callback_host: Final = (
1747
+ client_deps.config.callback_host if client_deps.config.callback_host else client_deps.callback_ip_addr
1748
+ )
1749
+ callback_port = (
1750
+ client_deps.config.callback_port_xml_rpc
1751
+ if client_deps.config.callback_port_xml_rpc
1752
+ else client_deps.listen_port_xml_rpc
1753
+ )
1754
+ init_url = f"{callback_host}:{callback_port}"
1755
+ self.init_url: Final = f"http://{init_url}"
1756
+
1757
+ self.xml_rpc_uri: Final = build_xml_rpc_uri(
1758
+ host=client_deps.config.host,
1759
+ port=interface_config.port,
1760
+ path=interface_config.remote_path,
1761
+ tls=client_deps.config.tls,
1762
+ )
1763
+
1764
+ async def create_client(self) -> ClientProtocol:
1765
+ """Identify the used client."""
1766
+ try:
1767
+ self.version = await self._get_version()
1768
+ client: ClientProtocol | None
1769
+ if self.interface == Interface.BIDCOS_RF and ("Homegear" in self.version or "pydevccu" in self.version):
1770
+ client = ClientHomegear(client_config=self)
1771
+ elif self.interface in INTERFACES_REQUIRING_JSON_RPC_CLIENT:
1772
+ client = ClientJsonCCU(client_config=self)
1773
+ else:
1774
+ client = ClientCCU(client_config=self)
1775
+
1776
+ if client:
1777
+ await client.init_client()
1778
+ if await client.check_connection_availability(handle_ping_pong=False):
1779
+ return client
1780
+ raise NoConnectionException(
1781
+ i18n.tr(key="exception.client.client_config.no_connection", interface_id=self.interface_id)
1782
+ )
1783
+ except BaseHomematicException:
1784
+ raise
1785
+ except Exception as exc:
1786
+ raise NoConnectionException(
1787
+ i18n.tr(
1788
+ key="exception.client.client_config.unable_to_connect",
1789
+ reason=extract_exc_args(exc=exc),
1790
+ )
1791
+ ) from exc
1792
+
1793
+ async def create_rpc_proxy(
1794
+ self,
1795
+ *,
1796
+ interface: Interface,
1797
+ auth_enabled: bool | None = None,
1798
+ max_workers: int = DEFAULT_MAX_WORKERS,
1799
+ ) -> BaseRpcProxy:
1800
+ """Return a RPC proxy for the backend communication."""
1801
+ return await self._create_xml_rpc_proxy(
1802
+ auth_enabled=auth_enabled,
1803
+ max_workers=max_workers,
1804
+ )
1805
+
1806
+ async def _create_simple_rpc_proxy(self, *, interface: Interface) -> BaseRpcProxy:
1807
+ """Return a RPC proxy for the backend communication."""
1808
+ return await self._create_xml_rpc_proxy(auth_enabled=True, max_workers=0)
1809
+
1810
+ async def _create_xml_rpc_proxy(
1811
+ self,
1812
+ *,
1813
+ auth_enabled: bool | None = None,
1814
+ max_workers: int = DEFAULT_MAX_WORKERS,
1815
+ ) -> AioXmlRpcProxy:
1816
+ """Return a XmlRPC proxy for the backend communication."""
1817
+ config = self.client_deps.config
1818
+ xml_rpc_headers = (
1819
+ build_xml_rpc_headers(
1820
+ username=config.username,
1821
+ password=config.password,
1822
+ )
1823
+ if auth_enabled
1824
+ else []
1825
+ )
1826
+ xml_proxy = AioXmlRpcProxy(
1827
+ max_workers=max_workers,
1828
+ interface_id=self.interface_id,
1829
+ connection_state=self.client_deps.connection_state,
1830
+ uri=self.xml_rpc_uri,
1831
+ headers=xml_rpc_headers,
1832
+ tls=config.tls,
1833
+ verify_tls=config.verify_tls,
1834
+ session_recorder=self.client_deps.cache_coordinator.recorder,
1835
+ event_bus=self.client_deps.event_bus,
1836
+ incident_recorder=self.client_deps.cache_coordinator.incident_store,
1837
+ )
1838
+ await xml_proxy.do_init()
1839
+ return xml_proxy
1840
+
1841
+ async def _get_version(self) -> str:
1842
+ """Return the version of the the backend."""
1843
+ if self.interface in INTERFACES_REQUIRING_JSON_RPC_CLIENT:
1844
+ return "0"
1845
+ check_proxy = await self._create_simple_rpc_proxy(interface=self.interface)
1846
+ try:
1847
+ if (methods := check_proxy.supported_methods) and "getVersion" in methods:
1848
+ # BidCos-Wired does not support getVersion()
1849
+ return cast(str, await check_proxy.getVersion())
1850
+ except Exception as exc:
1851
+ raise NoConnectionException(
1852
+ i18n.tr(
1853
+ key="exception.client.client_config.unable_to_connect",
1854
+ reason=extract_exc_args(exc=exc),
1855
+ )
1856
+ ) from exc
1857
+ return "0"