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,1304 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Unified InterfaceClient implementation.
5
+
6
+ Uses the Backend Strategy Pattern to abstract transport differences
7
+ (CCU, CCU-Jack, Homegear) behind a common interface.
8
+
9
+ Public API
10
+ ----------
11
+ - InterfaceClient: Unified client for all Homematic backend types
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ from datetime import datetime
18
+ import logging
19
+ from typing import TYPE_CHECKING, Any, Final
20
+
21
+ from aiohomematic import i18n
22
+ from aiohomematic.central.events import ClientStateChangedEvent, SystemStatusChangedEvent
23
+ from aiohomematic.client._rpc_errors import exception_to_failure_reason
24
+ from aiohomematic.client.backends.protocol import BackendOperationsProtocol
25
+ from aiohomematic.client.handlers.device_ops import _wait_for_state_change_or_timeout
26
+ from aiohomematic.client.request_coalescer import RequestCoalescer, make_coalesce_key
27
+ from aiohomematic.client.state_machine import ClientStateMachine
28
+ from aiohomematic.const import (
29
+ DATETIME_FORMAT_MILLIS,
30
+ DP_KEY_VALUE,
31
+ INIT_DATETIME,
32
+ VIRTUAL_REMOTE_MODELS,
33
+ WAIT_FOR_CALLBACK,
34
+ BackupData,
35
+ CallSource,
36
+ ClientState,
37
+ CommandRxMode,
38
+ DescriptionMarker,
39
+ DeviceDescription,
40
+ FailureReason,
41
+ ForcedDeviceAvailability,
42
+ InboxDeviceData,
43
+ Interface,
44
+ Operations,
45
+ ParameterData,
46
+ ParameterType,
47
+ ParamsetKey,
48
+ ProductGroup,
49
+ ProgramData,
50
+ ProxyInitState,
51
+ ServiceMessageData,
52
+ ServiceMessageType,
53
+ SystemInformation,
54
+ SystemUpdateData,
55
+ SystemVariableData,
56
+ )
57
+ from aiohomematic.decorators import inspector
58
+ from aiohomematic.exceptions import BaseHomematicException, ClientException, ValidationException
59
+ from aiohomematic.interfaces.client import ClientDependenciesProtocol, ClientProtocol
60
+ from aiohomematic.model.support import convert_value
61
+ from aiohomematic.property_decorators import DelegatedProperty
62
+ from aiohomematic.store.dynamic import CommandTracker, PingPongTracker
63
+ from aiohomematic.store.types import IncidentSeverity, IncidentType
64
+ from aiohomematic.support import (
65
+ LogContextMixin,
66
+ extract_exc_args,
67
+ get_device_address,
68
+ is_channel_address,
69
+ is_paramset_key,
70
+ supports_rx_mode,
71
+ )
72
+
73
+ if TYPE_CHECKING:
74
+ from aiohomematic.client.backends.capabilities import BackendCapabilities
75
+ from aiohomematic.client.circuit_breaker import CircuitBreaker
76
+ from aiohomematic.client.config import InterfaceConfig
77
+ from aiohomematic.interfaces.model import ChannelProtocol, DeviceProtocol
78
+
79
+ __all__ = ["InterfaceClient"]
80
+
81
+ _LOGGER: Final = logging.getLogger(__name__)
82
+
83
+
84
+ class InterfaceClient(ClientProtocol, LogContextMixin):
85
+ """
86
+ Unified client for all Homematic backend types.
87
+
88
+ Uses BackendOperationsProtocol to abstract transport differences:
89
+ - CCU: XML-RPC for device ops, JSON-RPC for metadata
90
+ - CCU-Jack: JSON-RPC exclusively
91
+ - Homegear: XML-RPC with Homegear-specific methods
92
+ """
93
+
94
+ __slots__ = (
95
+ "_backend",
96
+ "_central",
97
+ "_connection_error_count",
98
+ "_device_description_coalescer",
99
+ "_interface_config",
100
+ "_is_callback_alive",
101
+ "_last_value_send_tracker",
102
+ "_modified_at",
103
+ "_paramset_description_coalescer",
104
+ "_ping_pong_tracker",
105
+ "_reconnect_attempts",
106
+ "_state_machine",
107
+ "_unsubscribe_state_change",
108
+ "_unsubscribe_system_status",
109
+ "_version",
110
+ )
111
+
112
+ def __init__(
113
+ self,
114
+ *,
115
+ backend: BackendOperationsProtocol,
116
+ central: ClientDependenciesProtocol,
117
+ interface_config: InterfaceConfig,
118
+ version: str,
119
+ ) -> None:
120
+ """Initialize InterfaceClient."""
121
+ self._backend: Final = backend
122
+ self._central: Final = central
123
+ self._interface_config: Final = interface_config
124
+ self._version: Final = version
125
+ self._last_value_send_tracker: Final = CommandTracker(
126
+ interface_id=backend.interface_id,
127
+ )
128
+ self._state_machine: Final = ClientStateMachine(
129
+ interface_id=backend.interface_id,
130
+ event_bus=central.event_bus,
131
+ )
132
+ # Subscribe to state changes for integration compatibility
133
+ self._unsubscribe_state_change = central.event_bus.subscribe(
134
+ event_type=ClientStateChangedEvent,
135
+ event_key=backend.interface_id,
136
+ handler=self._on_client_state_changed_event,
137
+ )
138
+ self._connection_error_count: int = 0
139
+ self._is_callback_alive: bool = True
140
+ self._reconnect_attempts: int = 0
141
+ self._ping_pong_tracker: Final = PingPongTracker(
142
+ event_bus_provider=central,
143
+ central_info=central,
144
+ interface_id=backend.interface_id,
145
+ connection_state=central.connection_state,
146
+ incident_recorder=central.cache_coordinator.incident_store,
147
+ )
148
+ self._device_description_coalescer: Final = RequestCoalescer(
149
+ name=f"device_desc:{backend.interface_id}",
150
+ event_bus=central.event_bus,
151
+ interface_id=backend.interface_id,
152
+ )
153
+ self._paramset_description_coalescer: Final = RequestCoalescer(
154
+ name=f"paramset:{backend.interface_id}",
155
+ event_bus=central.event_bus,
156
+ interface_id=backend.interface_id,
157
+ )
158
+ self._modified_at: datetime = INIT_DATETIME
159
+
160
+ # Subscribe to connection state changes
161
+ self._unsubscribe_system_status = central.event_bus.subscribe(
162
+ event_type=SystemStatusChangedEvent,
163
+ event_key=None,
164
+ handler=self._on_system_status_event,
165
+ )
166
+
167
+ def __str__(self) -> str:
168
+ """Provide information."""
169
+ return f"interface_id: {self.interface_id}"
170
+
171
+ available: Final = DelegatedProperty[bool](path="_state_machine.is_available")
172
+ central: Final = DelegatedProperty[ClientDependenciesProtocol](path="_central")
173
+ last_value_send_tracker: Final = DelegatedProperty[CommandTracker](path="_last_value_send_tracker")
174
+ ping_pong_tracker: Final = DelegatedProperty[PingPongTracker](path="_ping_pong_tracker")
175
+ state: Final = DelegatedProperty[ClientState](path="_state_machine.state")
176
+ state_machine: Final = DelegatedProperty[ClientStateMachine](path="_state_machine")
177
+
178
+ @property
179
+ def all_circuit_breakers_closed(self) -> bool:
180
+ """Return True if all circuit breakers are in closed state."""
181
+ return self._backend.all_circuit_breakers_closed
182
+
183
+ @property
184
+ def capabilities(self) -> BackendCapabilities:
185
+ """Return the capability flags for this backend."""
186
+ return self._backend.capabilities
187
+
188
+ @property
189
+ def circuit_breaker(self) -> CircuitBreaker | None:
190
+ """Return the primary circuit breaker for metrics access."""
191
+ return self._backend.circuit_breaker
192
+
193
+ @property
194
+ def interface(self) -> Interface:
195
+ """Return the interface type."""
196
+ return self._backend.interface
197
+
198
+ @property
199
+ def interface_id(self) -> str:
200
+ """Return the interface identifier."""
201
+ return self._backend.interface_id
202
+
203
+ @property
204
+ def is_initialized(self) -> bool:
205
+ """Return if interface is initialized."""
206
+ return self._state_machine.state in (
207
+ ClientState.CONNECTED,
208
+ ClientState.DISCONNECTED,
209
+ ClientState.RECONNECTING,
210
+ )
211
+
212
+ @property
213
+ def model(self) -> str:
214
+ """Return the backend model."""
215
+ return self._backend.model
216
+
217
+ @property
218
+ def modified_at(self) -> datetime:
219
+ """Return the last update datetime value."""
220
+ return self._modified_at
221
+
222
+ @modified_at.setter
223
+ def modified_at(self, value: datetime) -> None:
224
+ """Write the last update datetime value."""
225
+ self._modified_at = value
226
+
227
+ @property
228
+ def request_coalescer(self) -> RequestCoalescer | None:
229
+ """Return the request coalescer for metrics access."""
230
+ return self._paramset_description_coalescer
231
+
232
+ @property
233
+ def system_information(self) -> SystemInformation:
234
+ """Return system information."""
235
+ return self._backend.system_information
236
+
237
+ @property
238
+ def version(self) -> str:
239
+ """Return the version."""
240
+ return self._version
241
+
242
+ async def accept_device_in_inbox(self, *, device_address: str) -> bool:
243
+ """Accept a device from the CCU inbox."""
244
+ if not self._backend.capabilities.inbox_devices:
245
+ return False
246
+ return await self._backend.accept_device_in_inbox(device_address=device_address)
247
+
248
+ async def add_link(
249
+ self,
250
+ *,
251
+ sender_address: str,
252
+ receiver_address: str,
253
+ name: str,
254
+ description: str,
255
+ ) -> None:
256
+ """Add a link between two devices."""
257
+ if not self._backend.capabilities.linking:
258
+ return
259
+ await self._backend.add_link(
260
+ sender_address=sender_address,
261
+ receiver_address=receiver_address,
262
+ name=name,
263
+ description=description,
264
+ )
265
+
266
+ @inspector(re_raise=False, no_raise_return=False)
267
+ async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
268
+ """Check if proxy is still initialized."""
269
+ try:
270
+ dt_now = datetime.now()
271
+ caller_id: str | None = None
272
+ if handle_ping_pong and self._backend.capabilities.ping_pong and self.is_initialized:
273
+ token = dt_now.strftime(format=DATETIME_FORMAT_MILLIS)
274
+ caller_id = f"{self.interface_id}#{token}"
275
+ # Register token BEFORE sending ping to avoid race condition:
276
+ # CCU may respond with PONG before await returns
277
+ self._ping_pong_tracker.handle_send_ping(ping_token=token)
278
+ if await self._backend.check_connection(handle_ping_pong=handle_ping_pong, caller_id=caller_id):
279
+ self.modified_at = dt_now
280
+ return True
281
+ except BaseHomematicException as bhexc:
282
+ _LOGGER.debug(
283
+ "CHECK_CONNECTION_AVAILABILITY failed: %s [%s]",
284
+ bhexc.name,
285
+ extract_exc_args(exc=bhexc),
286
+ )
287
+ self.modified_at = INIT_DATETIME
288
+ return False
289
+
290
+ def clear_json_rpc_session(self) -> None:
291
+ """Clear the JSON-RPC session."""
292
+ self._central.json_rpc_client.clear_session()
293
+ _LOGGER.debug(
294
+ "CLEAR_JSON_RPC_SESSION: Session cleared for %s",
295
+ self.interface_id,
296
+ )
297
+
298
+ async def create_backup_and_download(
299
+ self,
300
+ *,
301
+ max_wait_time: float = 300.0,
302
+ poll_interval: float = 5.0,
303
+ ) -> BackupData | None:
304
+ """Create a backup on the CCU and download it."""
305
+ if not self._backend.capabilities.backup:
306
+ return None
307
+ return await self._backend.create_backup_and_download(max_wait_time=max_wait_time, poll_interval=poll_interval)
308
+
309
+ async def deinitialize_proxy(self) -> ProxyInitState:
310
+ """De-initialize the proxy."""
311
+ if not self._backend.capabilities.rpc_callback:
312
+ self._state_machine.transition_to(target=ClientState.DISCONNECTED, reason="no callback support")
313
+ return ProxyInitState.DE_INIT_SUCCESS
314
+
315
+ if self.modified_at == INIT_DATETIME:
316
+ return ProxyInitState.DE_INIT_SKIPPED
317
+
318
+ try:
319
+ init_url = self._get_init_url()
320
+ _LOGGER.debug("PROXY_DE_INIT: init('%s')", init_url)
321
+ await self._backend.deinit_proxy(init_url=init_url)
322
+ self._state_machine.transition_to(target=ClientState.DISCONNECTED, reason="proxy de-initialized")
323
+ except BaseHomematicException as bhexc:
324
+ _LOGGER.warning( # i18n-log: ignore
325
+ "PROXY_DE_INIT failed: %s [%s] Unable to de-initialize proxy for %s",
326
+ bhexc.name,
327
+ extract_exc_args(exc=bhexc),
328
+ self.interface_id,
329
+ )
330
+ return ProxyInitState.DE_INIT_FAILED
331
+
332
+ self.modified_at = INIT_DATETIME
333
+ return ProxyInitState.DE_INIT_SUCCESS
334
+
335
+ async def delete_system_variable(self, *, name: str) -> bool:
336
+ """Delete a system variable from the backend."""
337
+ return await self._backend.delete_system_variable(name=name)
338
+
339
+ async def execute_program(self, *, pid: str) -> bool:
340
+ """Execute a program on the backend."""
341
+ if not self._backend.capabilities.programs:
342
+ return False
343
+ return await self._backend.execute_program(pid=pid)
344
+
345
+ async def fetch_all_device_data(self) -> None:
346
+ """Fetch all device data from the backend."""
347
+ if all_device_data := await self._backend.get_all_device_data(interface=self.interface):
348
+ self._central.cache_coordinator.data_cache.add_data(
349
+ interface=self.interface, all_device_data=all_device_data
350
+ )
351
+
352
+ async def fetch_device_details(self) -> None:
353
+ """
354
+ Fetch device names and details from the backend.
355
+
356
+ For CCU: Uses JSON-RPC to fetch all details in one call.
357
+ For Homegear: Uses getMetadata to fetch names for each known address.
358
+ """
359
+ # Get known addresses for backends that need them (e.g., Homegear)
360
+ addresses = tuple(
361
+ self._central.cache_coordinator.device_descriptions.get_device_descriptions(
362
+ interface_id=self.interface_id
363
+ ).keys()
364
+ )
365
+
366
+ if device_details := await self._backend.get_device_details(addresses=addresses):
367
+ for device in device_details:
368
+ device_address = device["address"]
369
+ self._central.cache_coordinator.device_details.add_name(address=device_address, name=device["name"])
370
+ # Only add rega_id if it's meaningful (non-zero for CCU, 0 for Homegear)
371
+ if device["id"]:
372
+ self._central.cache_coordinator.device_details.add_address_rega_id(
373
+ address=device_address, rega_id=device["id"]
374
+ )
375
+ self._central.cache_coordinator.device_details.add_interface(
376
+ address=device_address, interface=self.interface
377
+ )
378
+ # Process nested channels array (CCU provides these, Homegear doesn't)
379
+ for channel in device["channels"]:
380
+ channel_address = channel["address"]
381
+ self._central.cache_coordinator.device_details.add_name(
382
+ address=channel_address, name=channel["name"]
383
+ )
384
+ if channel["id"]:
385
+ self._central.cache_coordinator.device_details.add_address_rega_id(
386
+ address=channel_address, rega_id=channel["id"]
387
+ )
388
+
389
+ async def fetch_paramset_description(self, *, channel_address: str, paramset_key: ParamsetKey) -> None:
390
+ """Fetch a specific paramset and add it to the known ones."""
391
+ if paramset_description := await self._get_paramset_description(
392
+ address=channel_address, paramset_key=paramset_key
393
+ ):
394
+ self._central.cache_coordinator.paramset_descriptions.add(
395
+ interface_id=self.interface_id,
396
+ channel_address=channel_address,
397
+ paramset_key=paramset_key,
398
+ paramset_description=paramset_description,
399
+ )
400
+
401
+ async def fetch_paramset_descriptions(self, *, device_description: DeviceDescription) -> None:
402
+ """Fetch paramsets for provided device description."""
403
+ data = await self.get_paramset_descriptions(device_description=device_description)
404
+ for address, paramsets in data.items():
405
+ for paramset_key, paramset_description in paramsets.items():
406
+ self._central.cache_coordinator.paramset_descriptions.add(
407
+ interface_id=self.interface_id,
408
+ channel_address=address,
409
+ paramset_key=paramset_key,
410
+ paramset_description=paramset_description,
411
+ )
412
+
413
+ async def get_all_device_descriptions(self, *, device_address: str) -> tuple[DeviceDescription, ...]:
414
+ """Get all device descriptions from the backend."""
415
+ all_device_description: list[DeviceDescription] = []
416
+ if main_dd := await self.get_device_description(address=device_address):
417
+ all_device_description.append(main_dd)
418
+ channel_descriptions = [
419
+ channel_dd
420
+ for channel_address in main_dd.get("CHILDREN", [])
421
+ if (channel_dd := await self.get_device_description(address=channel_address))
422
+ ]
423
+ all_device_description.extend(channel_descriptions)
424
+ return tuple(all_device_description)
425
+
426
+ async def get_all_functions(self) -> dict[str, set[str]]:
427
+ """Get all functions from the backend."""
428
+ if not self._backend.capabilities.functions:
429
+ return {}
430
+ return await self._backend.get_all_functions()
431
+
432
+ async def get_all_paramset_descriptions(
433
+ self, *, device_descriptions: tuple[DeviceDescription, ...]
434
+ ) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
435
+ """Get all paramset descriptions for provided device descriptions."""
436
+ all_paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
437
+ for device_description in device_descriptions:
438
+ all_paramsets.update(await self.get_paramset_descriptions(device_description=device_description))
439
+ return all_paramsets
440
+
441
+ async def get_all_programs(
442
+ self,
443
+ *,
444
+ markers: tuple[DescriptionMarker | str, ...],
445
+ ) -> tuple[ProgramData, ...]:
446
+ """Get all programs, if available."""
447
+ if not self._backend.capabilities.programs:
448
+ return ()
449
+ return await self._backend.get_all_programs(markers=markers)
450
+
451
+ async def get_all_rooms(self) -> dict[str, set[str]]:
452
+ """Get all rooms from the backend."""
453
+ if not self._backend.capabilities.rooms:
454
+ return {}
455
+ return await self._backend.get_all_rooms()
456
+
457
+ async def get_all_system_variables(
458
+ self,
459
+ *,
460
+ markers: tuple[DescriptionMarker | str, ...],
461
+ ) -> tuple[SystemVariableData, ...] | None:
462
+ """Get all system variables from the backend."""
463
+ return await self._backend.get_all_system_variables(markers=markers)
464
+
465
+ async def get_device_description(self, *, address: str) -> DeviceDescription | None:
466
+ """Get device description from the backend with request coalescing."""
467
+ key = make_coalesce_key(method="getDeviceDescription", args=(address,))
468
+
469
+ async def _fetch() -> DeviceDescription | None:
470
+ try:
471
+ return await self._backend.get_device_description(address=address)
472
+ except BaseHomematicException as bhexc:
473
+ _LOGGER.warning( # i18n-log: ignore
474
+ "GET_DEVICE_DESCRIPTION failed: %s [%s]", bhexc.name, extract_exc_args(exc=bhexc)
475
+ )
476
+ return None
477
+
478
+ return await self._device_description_coalescer.execute(key=key, executor=_fetch)
479
+
480
+ async def get_inbox_devices(self) -> tuple[InboxDeviceData, ...]:
481
+ """Get all devices in the inbox (not yet configured)."""
482
+ if not self._backend.capabilities.inbox_devices:
483
+ return ()
484
+ return await self._backend.get_inbox_devices()
485
+
486
+ async def get_install_mode(self) -> int:
487
+ """Return the remaining time in install mode."""
488
+ if not self._backend.capabilities.install_mode:
489
+ return 0
490
+ return await self._backend.get_install_mode()
491
+
492
+ async def get_link_peers(self, *, address: str) -> tuple[str, ...]:
493
+ """Return a list of link peers."""
494
+ if not self._backend.capabilities.linking:
495
+ return ()
496
+ return await self._backend.get_link_peers(address=address)
497
+
498
+ async def get_links(self, *, address: str, flags: int) -> dict[str, Any]:
499
+ """Return a list of links."""
500
+ if not self._backend.capabilities.linking:
501
+ return {}
502
+ return await self._backend.get_links(address=address, flags=flags)
503
+
504
+ async def get_metadata(self, *, address: str, data_id: str) -> dict[str, Any]:
505
+ """Return the metadata for an object."""
506
+ if not self._backend.capabilities.metadata:
507
+ return {}
508
+ return await self._backend.get_metadata(address=address, data_id=data_id)
509
+
510
+ async def get_paramset(
511
+ self,
512
+ *,
513
+ address: str,
514
+ paramset_key: ParamsetKey | str,
515
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
516
+ ) -> dict[str, Any]:
517
+ """Return a paramset from the backend."""
518
+ return await self._backend.get_paramset(address=address, paramset_key=paramset_key)
519
+
520
+ async def get_paramset_descriptions(
521
+ self, *, device_description: DeviceDescription
522
+ ) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
523
+ """Get paramsets for provided device description."""
524
+ paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
525
+ address = device_description["ADDRESS"]
526
+ paramsets[address] = {}
527
+ for p_key in device_description["PARAMSETS"]:
528
+ paramset_key = ParamsetKey(p_key)
529
+ if paramset_description := await self._get_paramset_description(address=address, paramset_key=paramset_key):
530
+ paramsets[address][paramset_key] = paramset_description
531
+ return paramsets
532
+
533
+ def get_product_group(self, *, model: str) -> ProductGroup:
534
+ """Return the product group."""
535
+ l_model = model.lower()
536
+ if l_model.startswith("hmipw-"):
537
+ return ProductGroup.HMIPW
538
+ if l_model.startswith("hmip-"):
539
+ return ProductGroup.HMIP
540
+ if l_model.startswith("hmw-"):
541
+ return ProductGroup.HMW
542
+ if l_model.startswith("hm-"):
543
+ return ProductGroup.HM
544
+ if self.interface == Interface.HMIP_RF:
545
+ return ProductGroup.HMIP
546
+ if self.interface == Interface.BIDCOS_WIRED:
547
+ return ProductGroup.HMW
548
+ if self.interface == Interface.BIDCOS_RF:
549
+ return ProductGroup.HM
550
+ if self.interface == Interface.VIRTUAL_DEVICES:
551
+ return ProductGroup.VIRTUAL
552
+ return ProductGroup.UNKNOWN
553
+
554
+ async def get_rega_id_by_address(self, *, address: str) -> int | None:
555
+ """Get the ReGa ID for a device or channel address."""
556
+ if not self._backend.capabilities.rega_id_lookup:
557
+ return None
558
+ return await self._backend.get_rega_id_by_address(address=address)
559
+
560
+ async def get_service_messages(
561
+ self,
562
+ *,
563
+ message_type: ServiceMessageType | None = None,
564
+ ) -> tuple[ServiceMessageData, ...]:
565
+ """Get all active service messages from the backend."""
566
+ if not self._backend.capabilities.service_messages:
567
+ return ()
568
+ return await self._backend.get_service_messages(message_type=message_type)
569
+
570
+ async def get_system_update_info(self) -> SystemUpdateData | None:
571
+ """Get system update information from the backend."""
572
+ if not self._backend.capabilities.system_update_info:
573
+ return None
574
+ return await self._backend.get_system_update_info()
575
+
576
+ async def get_system_variable(self, *, name: str) -> Any:
577
+ """Get single system variable from the backend."""
578
+ return await self._backend.get_system_variable(name=name)
579
+
580
+ async def get_value(
581
+ self,
582
+ *,
583
+ channel_address: str,
584
+ paramset_key: ParamsetKey,
585
+ parameter: str,
586
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
587
+ ) -> Any:
588
+ """Return a value from the backend."""
589
+ return await self._backend.get_value(address=channel_address, parameter=parameter)
590
+
591
+ def get_virtual_remote(self) -> DeviceProtocol | None:
592
+ """Get the virtual remote for the Client."""
593
+ for model in VIRTUAL_REMOTE_MODELS:
594
+ for device in self._central.device_registry.devices:
595
+ if device.interface_id == self.interface_id and device.model == model:
596
+ return device
597
+ return None
598
+
599
+ async def has_program_ids(self, *, rega_id: int) -> bool:
600
+ """Return if a channel has program ids."""
601
+ if not self._backend.capabilities.programs:
602
+ return False
603
+ return await self._backend.has_program_ids(rega_id=rega_id)
604
+
605
+ @inspector
606
+ async def init_client(self) -> None:
607
+ """Initialize the client."""
608
+ self._state_machine.transition_to(target=ClientState.INITIALIZING)
609
+ try:
610
+ self._state_machine.transition_to(target=ClientState.INITIALIZED)
611
+ except Exception as exc:
612
+ self._state_machine.transition_to(
613
+ target=ClientState.FAILED,
614
+ reason=str(exc),
615
+ failure_reason=exception_to_failure_reason(exc=exc),
616
+ )
617
+ raise
618
+
619
+ async def initialize_proxy(self) -> ProxyInitState:
620
+ """Initialize the proxy."""
621
+ self._state_machine.transition_to(target=ClientState.CONNECTING)
622
+ if not self._backend.capabilities.rpc_callback:
623
+ if (device_descriptions := await self.list_devices()) is not None:
624
+ await self._central.device_coordinator.add_new_devices(
625
+ interface_id=self.interface_id, device_descriptions=device_descriptions
626
+ )
627
+ self._state_machine.transition_to(
628
+ target=ClientState.CONNECTED, reason="proxy initialized (no callback)"
629
+ )
630
+ return ProxyInitState.INIT_SUCCESS
631
+ self._state_machine.transition_to(
632
+ target=ClientState.FAILED,
633
+ reason="device listing failed",
634
+ failure_reason=FailureReason.NETWORK,
635
+ )
636
+ self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
637
+ return ProxyInitState.INIT_FAILED
638
+
639
+ # Record modified_at before init to detect callback during init
640
+ # This is used to work around VirtualDevices service bug where init()
641
+ # times out but listDevices callback was successfully received
642
+ modified_at_before_init = self.modified_at
643
+ init_success = False
644
+ try:
645
+ self._ping_pong_tracker.clear()
646
+ init_url = self._get_init_url()
647
+ _LOGGER.debug("PROXY_INIT: init('%s', '%s')", init_url, self.interface_id)
648
+ await self._backend.init_proxy(init_url=init_url, interface_id=self.interface_id)
649
+ init_success = True
650
+ except BaseHomematicException as bhexc:
651
+ # Check if we received a callback during init (modified_at was updated)
652
+ # This happens when init() times out but the CCU successfully processed it
653
+ # and called back listDevices. Common with VirtualDevices service bug.
654
+ if self.modified_at > modified_at_before_init:
655
+ _LOGGER.info( # i18n-log: ignore
656
+ "PROXY_INIT: init() failed but callback received for %s - treating as success",
657
+ self.interface_id,
658
+ )
659
+ init_success = True
660
+ else:
661
+ _LOGGER.error( # i18n-log: ignore
662
+ "PROXY_INIT failed: %s [%s] Unable to initialize proxy for %s",
663
+ bhexc.name,
664
+ extract_exc_args(exc=bhexc),
665
+ self.interface_id,
666
+ )
667
+ self.modified_at = INIT_DATETIME
668
+ self._state_machine.transition_to(
669
+ target=ClientState.FAILED,
670
+ reason="proxy init failed",
671
+ failure_reason=exception_to_failure_reason(exc=bhexc),
672
+ )
673
+ self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
674
+ return ProxyInitState.INIT_FAILED
675
+
676
+ if init_success:
677
+ self._state_machine.transition_to(target=ClientState.CONNECTED, reason="proxy initialized")
678
+ self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.NOT_SET)
679
+ _LOGGER.debug("PROXY_INIT: Proxy for %s initialized", self.interface_id)
680
+
681
+ self.modified_at = datetime.now()
682
+ return ProxyInitState.INIT_SUCCESS
683
+
684
+ def is_callback_alive(self) -> bool:
685
+ """Return if XmlRPC-Server is alive based on received events."""
686
+ if not self._backend.capabilities.ping_pong:
687
+ return True
688
+
689
+ if self._state_machine.is_failed or self._state_machine.state == ClientState.RECONNECTING:
690
+ return False
691
+
692
+ if (
693
+ last_events_dt := self._central.event_coordinator.get_last_event_seen_for_interface(
694
+ interface_id=self.interface_id
695
+ )
696
+ ) is not None:
697
+ callback_warn = self._central.config.timeout_config.callback_warn_interval
698
+ if (seconds_since_last_event := (datetime.now() - last_events_dt).total_seconds()) > callback_warn:
699
+ if self._is_callback_alive:
700
+ self._central.event_bus.publish_sync(
701
+ event=SystemStatusChangedEvent(
702
+ timestamp=datetime.now(),
703
+ callback_state=(self.interface_id, False),
704
+ )
705
+ )
706
+ self._is_callback_alive = False
707
+ self._record_callback_timeout_incident(
708
+ seconds_since_last_event=seconds_since_last_event,
709
+ callback_warn_interval=callback_warn,
710
+ last_event_time=last_events_dt,
711
+ )
712
+ _LOGGER.error(
713
+ i18n.tr(
714
+ key="log.client.is_callback_alive.no_events",
715
+ interface_id=self.interface_id,
716
+ seconds=int(seconds_since_last_event),
717
+ )
718
+ )
719
+ return False
720
+
721
+ if not self._is_callback_alive:
722
+ self._central.event_bus.publish_sync(
723
+ event=SystemStatusChangedEvent(
724
+ timestamp=datetime.now(),
725
+ callback_state=(self.interface_id, True),
726
+ )
727
+ )
728
+ self._is_callback_alive = True
729
+ return True
730
+
731
+ @inspector(re_raise=False, no_raise_return=False)
732
+ async def is_connected(self) -> bool:
733
+ """Perform connectivity check."""
734
+ if await self.check_connection_availability(handle_ping_pong=True) is True:
735
+ self._connection_error_count = 0
736
+ else:
737
+ self._connection_error_count += 1
738
+
739
+ error_threshold = self._central.config.timeout_config.connectivity_error_threshold
740
+ if self._connection_error_count > error_threshold:
741
+ self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
742
+ if self._state_machine.state == ClientState.CONNECTED:
743
+ self._state_machine.transition_to(
744
+ target=ClientState.DISCONNECTED,
745
+ reason=f"connection check failed (>{error_threshold} errors)",
746
+ )
747
+ return False
748
+ if not self._backend.capabilities.push_updates:
749
+ return True
750
+
751
+ callback_warn = self._central.config.timeout_config.callback_warn_interval
752
+ return (datetime.now() - self.modified_at).total_seconds() < callback_warn
753
+
754
+ async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
755
+ """List devices of the backend."""
756
+ return await self._backend.list_devices()
757
+
758
+ @inspector(re_raise=False, no_raise_return=set())
759
+ async def put_paramset(
760
+ self,
761
+ *,
762
+ channel_address: str,
763
+ paramset_key_or_link_address: ParamsetKey | str,
764
+ values: dict[str, Any],
765
+ wait_for_callback: int | None = WAIT_FOR_CALLBACK,
766
+ rx_mode: CommandRxMode | None = None,
767
+ check_against_pd: bool = False,
768
+ ) -> set[DP_KEY_VALUE]:
769
+ """Set paramsets manually."""
770
+ is_link_call: bool = False
771
+ checked_values = values
772
+ try:
773
+ # Validate values if requested
774
+ if check_against_pd:
775
+ check_paramset_key = (
776
+ ParamsetKey(paramset_key_or_link_address)
777
+ if is_paramset_key(paramset_key=paramset_key_or_link_address)
778
+ else ParamsetKey.LINK
779
+ if (is_link_call := is_channel_address(address=paramset_key_or_link_address))
780
+ else None
781
+ )
782
+ if check_paramset_key:
783
+ checked_values = self._check_put_paramset(
784
+ channel_address=channel_address,
785
+ paramset_key=check_paramset_key,
786
+ values=values,
787
+ )
788
+ else:
789
+ raise ClientException(i18n.tr(key="exception.client.paramset_key.invalid"))
790
+
791
+ if rx_mode and (device := self._central.device_coordinator.get_device(address=channel_address)):
792
+ if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
793
+ await self._backend.put_paramset(
794
+ address=channel_address,
795
+ paramset_key=paramset_key_or_link_address,
796
+ values=checked_values,
797
+ rx_mode=rx_mode,
798
+ )
799
+ else:
800
+ raise ClientException(i18n.tr(key="exception.client.rx_mode.unsupported", rx_mode=rx_mode))
801
+ else:
802
+ await self._backend.put_paramset(
803
+ address=channel_address,
804
+ paramset_key=paramset_key_or_link_address,
805
+ values=checked_values,
806
+ rx_mode=rx_mode,
807
+ )
808
+
809
+ # If a call is related to a link then no further action is needed
810
+ if is_link_call:
811
+ return set()
812
+
813
+ # Store the sent values and write temporary values for UI feedback
814
+ dpk_values = self._last_value_send_tracker.add_put_paramset(
815
+ channel_address=channel_address,
816
+ paramset_key=ParamsetKey(paramset_key_or_link_address),
817
+ values=checked_values,
818
+ )
819
+ self._write_temporary_value(dpk_values=dpk_values)
820
+
821
+ # Schedule master paramset polling for BidCos interfaces
822
+ if (
823
+ self.interface in (Interface.BIDCOS_RF, Interface.BIDCOS_WIRED)
824
+ and paramset_key_or_link_address == ParamsetKey.MASTER
825
+ and (channel := self._central.device_coordinator.get_channel(channel_address=channel_address))
826
+ is not None
827
+ ):
828
+ await self._poll_master_values(channel=channel, paramset_key=ParamsetKey(paramset_key_or_link_address))
829
+
830
+ if wait_for_callback is not None and (
831
+ device := self._central.device_coordinator.get_device(
832
+ address=get_device_address(address=channel_address)
833
+ )
834
+ ):
835
+ await self._wait_for_state_change(
836
+ device=device, dpk_values=dpk_values, wait_for_callback=wait_for_callback
837
+ )
838
+
839
+ except BaseHomematicException as bhexc:
840
+ raise ClientException(
841
+ i18n.tr(
842
+ key="exception.client.put_paramset.failed",
843
+ channel_address=channel_address,
844
+ paramset_key=paramset_key_or_link_address,
845
+ values=values,
846
+ reason=extract_exc_args(exc=bhexc),
847
+ )
848
+ ) from bhexc
849
+ else:
850
+ return dpk_values
851
+
852
+ async def reconnect(self) -> bool:
853
+ """Re-init all RPC clients with exponential backoff."""
854
+ if self._state_machine.can_reconnect:
855
+ self._state_machine.transition_to(target=ClientState.RECONNECTING)
856
+
857
+ timeout_cfg = self._central.config.timeout_config
858
+ delay = min(
859
+ timeout_cfg.reconnect_initial_delay * (timeout_cfg.reconnect_backoff_factor**self._reconnect_attempts),
860
+ timeout_cfg.reconnect_max_delay,
861
+ )
862
+ _LOGGER.debug(
863
+ "RECONNECT: waiting to re-connect client %s for %.1fs (attempt %d)",
864
+ self.interface_id,
865
+ delay,
866
+ self._reconnect_attempts + 1,
867
+ )
868
+ await asyncio.sleep(delay)
869
+
870
+ if await self.reinitialize_proxy() == ProxyInitState.INIT_SUCCESS:
871
+ self.reset_circuit_breakers()
872
+ self._reconnect_attempts = 0
873
+ self._connection_error_count = 0
874
+ _LOGGER.info(
875
+ i18n.tr(
876
+ key="log.client.reconnect.reconnected",
877
+ interface_id=self.interface_id,
878
+ )
879
+ )
880
+ return True
881
+ self._reconnect_attempts += 1
882
+ return False
883
+
884
+ async def reinitialize_proxy(self) -> ProxyInitState:
885
+ """Reinitialize proxy."""
886
+ await self.deinitialize_proxy()
887
+ return await self.initialize_proxy()
888
+
889
+ async def remove_link(self, *, sender_address: str, receiver_address: str) -> None:
890
+ """Remove a link between two devices."""
891
+ if not self._backend.capabilities.linking:
892
+ return
893
+ await self._backend.remove_link(sender_address=sender_address, receiver_address=receiver_address)
894
+
895
+ async def rename_channel(self, *, rega_id: int, new_name: str) -> bool:
896
+ """Rename a channel on the CCU."""
897
+ if not self._backend.capabilities.rename:
898
+ return False
899
+ return await self._backend.rename_channel(rega_id=rega_id, new_name=new_name)
900
+
901
+ async def rename_device(self, *, rega_id: int, new_name: str) -> bool:
902
+ """Rename a device on the CCU."""
903
+ if not self._backend.capabilities.rename:
904
+ return False
905
+ return await self._backend.rename_device(rega_id=rega_id, new_name=new_name)
906
+
907
+ async def report_value_usage(self, *, address: str, value_id: str, ref_counter: int) -> bool:
908
+ """Report value usage."""
909
+ if not self._backend.capabilities.value_usage_reporting:
910
+ return False
911
+ return await self._backend.report_value_usage(address=address, value_id=value_id, ref_counter=ref_counter)
912
+
913
+ def reset_circuit_breakers(self) -> None:
914
+ """Reset all circuit breakers to closed state."""
915
+ self._backend.reset_circuit_breakers()
916
+
917
+ async def set_install_mode(
918
+ self,
919
+ *,
920
+ on: bool = True,
921
+ time: int = 60,
922
+ mode: int = 1,
923
+ device_address: str | None = None,
924
+ ) -> bool:
925
+ """Set the install mode on the backend."""
926
+ if not self._backend.capabilities.install_mode:
927
+ return False
928
+ return await self._backend.set_install_mode(on=on, time=time, mode=mode, device_address=device_address)
929
+
930
+ async def set_metadata(self, *, address: str, data_id: str, value: dict[str, Any]) -> dict[str, Any]:
931
+ """Write the metadata for an object."""
932
+ if not self._backend.capabilities.metadata:
933
+ return {}
934
+ return await self._backend.set_metadata(address=address, data_id=data_id, value=value)
935
+
936
+ async def set_program_state(self, *, pid: str, state: bool) -> bool:
937
+ """Set the program state on the backend."""
938
+ if not self._backend.capabilities.programs:
939
+ return False
940
+ return await self._backend.set_program_state(pid=pid, state=state)
941
+
942
+ async def set_system_variable(self, *, legacy_name: str, value: Any) -> bool:
943
+ """Set a system variable on the backend."""
944
+ return await self._backend.set_system_variable(name=legacy_name, value=value)
945
+
946
+ @inspector(re_raise=False, no_raise_return=set())
947
+ async def set_value(
948
+ self,
949
+ *,
950
+ channel_address: str,
951
+ paramset_key: ParamsetKey,
952
+ parameter: str,
953
+ value: Any,
954
+ wait_for_callback: int | None = WAIT_FOR_CALLBACK,
955
+ rx_mode: CommandRxMode | None = None,
956
+ check_against_pd: bool = False,
957
+ ) -> set[DP_KEY_VALUE]:
958
+ """Set single value on paramset VALUES."""
959
+ if paramset_key != ParamsetKey.VALUES:
960
+ return await self.put_paramset(
961
+ channel_address=channel_address,
962
+ paramset_key_or_link_address=paramset_key,
963
+ values={parameter: value},
964
+ wait_for_callback=wait_for_callback,
965
+ rx_mode=rx_mode,
966
+ check_against_pd=check_against_pd,
967
+ )
968
+
969
+ dpk_values: set[DP_KEY_VALUE] = set()
970
+ try:
971
+ # Validate and convert value if requested
972
+ checked_value = (
973
+ self._check_set_value(
974
+ channel_address=channel_address,
975
+ paramset_key=ParamsetKey.VALUES,
976
+ parameter=parameter,
977
+ value=value,
978
+ )
979
+ if check_against_pd
980
+ else value
981
+ )
982
+
983
+ if rx_mode and (device := self._central.device_coordinator.get_device(address=channel_address)):
984
+ if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
985
+ await self._backend.set_value(
986
+ address=channel_address, parameter=parameter, value=checked_value, rx_mode=rx_mode
987
+ )
988
+ else:
989
+ raise ClientException(i18n.tr(key="exception.client.rx_mode.unsupported", rx_mode=rx_mode))
990
+ else:
991
+ await self._backend.set_value(address=channel_address, parameter=parameter, value=checked_value)
992
+
993
+ # Store the sent value and write temporary value for UI feedback
994
+ dpk_values = self._last_value_send_tracker.add_set_value(
995
+ channel_address=channel_address, parameter=parameter, value=checked_value
996
+ )
997
+ self._write_temporary_value(dpk_values=dpk_values)
998
+
999
+ if wait_for_callback is not None and (
1000
+ device := self._central.device_coordinator.get_device(
1001
+ address=get_device_address(address=channel_address)
1002
+ )
1003
+ ):
1004
+ await self._wait_for_state_change(
1005
+ device=device, dpk_values=dpk_values, wait_for_callback=wait_for_callback
1006
+ )
1007
+ except BaseHomematicException as bhexc:
1008
+ raise ClientException(
1009
+ i18n.tr(
1010
+ key="exception.client.set_value.failed",
1011
+ channel_address=channel_address,
1012
+ parameter=parameter,
1013
+ value=value,
1014
+ reason=extract_exc_args(exc=bhexc),
1015
+ )
1016
+ ) from bhexc
1017
+ return dpk_values
1018
+
1019
+ async def stop(self) -> None:
1020
+ """Stop depending services."""
1021
+ self._unsubscribe_state_change()
1022
+ self._unsubscribe_system_status()
1023
+ self._state_machine.transition_to(target=ClientState.STOPPING, reason="stop() called")
1024
+ await self._backend.stop()
1025
+ self._state_machine.transition_to(target=ClientState.STOPPED, reason="services stopped")
1026
+
1027
+ async def trigger_firmware_update(self) -> bool:
1028
+ """Trigger the CCU firmware update process."""
1029
+ if not self._backend.capabilities.firmware_update_trigger:
1030
+ return False
1031
+ return await self._backend.trigger_firmware_update()
1032
+
1033
+ async def update_device_firmware(self, *, device_address: str) -> bool:
1034
+ """Update the firmware of a Homematic device."""
1035
+ if not self._backend.capabilities.device_firmware_update:
1036
+ return False
1037
+ return await self._backend.update_device_firmware(device_address=device_address)
1038
+
1039
+ async def update_paramset_descriptions(self, *, device_address: str) -> None:
1040
+ """Update paramsets descriptions for provided device_address."""
1041
+ if device_description := self._central.cache_coordinator.device_descriptions.find_device_description(
1042
+ interface_id=self.interface_id, device_address=device_address
1043
+ ):
1044
+ await self.fetch_paramset_descriptions(device_description=device_description)
1045
+ await self._central.save_files(save_paramset_descriptions=True)
1046
+
1047
+ def _check_put_paramset(
1048
+ self, *, channel_address: str, paramset_key: ParamsetKey, values: dict[str, Any]
1049
+ ) -> dict[str, Any]:
1050
+ """
1051
+ Validate and convert all values in a paramset against their descriptions.
1052
+
1053
+ Iterates through each parameter in the values dict, converting types
1054
+ and validating against MIN/MAX constraints.
1055
+
1056
+ Returns:
1057
+ Dict with validated/converted values.
1058
+
1059
+ Raises:
1060
+ ClientException: If any parameter validation fails.
1061
+
1062
+ """
1063
+ checked_values: dict[str, Any] = {}
1064
+ for param, value in values.items():
1065
+ checked_values[param] = self._convert_value(
1066
+ channel_address=channel_address,
1067
+ paramset_key=paramset_key,
1068
+ parameter=param,
1069
+ value=value,
1070
+ operation=Operations.WRITE,
1071
+ )
1072
+ return checked_values
1073
+
1074
+ def _check_set_value(self, *, channel_address: str, paramset_key: ParamsetKey, parameter: str, value: Any) -> Any:
1075
+ """Validate and convert a single value against its parameter description."""
1076
+ return self._convert_value(
1077
+ channel_address=channel_address,
1078
+ paramset_key=paramset_key,
1079
+ parameter=parameter,
1080
+ value=value,
1081
+ operation=Operations.WRITE,
1082
+ )
1083
+
1084
+ def _convert_value(
1085
+ self,
1086
+ *,
1087
+ channel_address: str,
1088
+ paramset_key: ParamsetKey,
1089
+ parameter: str,
1090
+ value: Any,
1091
+ operation: Operations,
1092
+ ) -> Any:
1093
+ """
1094
+ Validate and convert a parameter value against its description.
1095
+
1096
+ Performs the following checks:
1097
+ 1. Parameter exists in paramset description
1098
+ 2. Requested operation (READ/WRITE/EVENT) is supported
1099
+ 3. Value is converted to the correct type (INTEGER, FLOAT, BOOL, ENUM, STRING)
1100
+ 4. For numeric types, value is within MIN/MAX bounds
1101
+
1102
+ Returns:
1103
+ Converted value matching the parameter's type definition.
1104
+
1105
+ Raises:
1106
+ ClientException: If parameter not found or operation not supported.
1107
+ ValidationException: If value is outside MIN/MAX bounds.
1108
+
1109
+ """
1110
+ if parameter_data := self._central.cache_coordinator.paramset_descriptions.get_parameter_data(
1111
+ interface_id=self.interface_id,
1112
+ channel_address=channel_address,
1113
+ paramset_key=paramset_key,
1114
+ parameter=parameter,
1115
+ ):
1116
+ pd_type = parameter_data["TYPE"]
1117
+ op_mask = int(operation)
1118
+ if (int(parameter_data["OPERATIONS"]) & op_mask) != op_mask:
1119
+ raise ClientException(
1120
+ i18n.tr(
1121
+ key="exception.client.parameter.operation_unsupported",
1122
+ parameter=parameter,
1123
+ operation=operation.value,
1124
+ )
1125
+ )
1126
+ # Only build a tuple if a value list exists
1127
+ pd_value_list = tuple(parameter_data["VALUE_LIST"]) if parameter_data.get("VALUE_LIST") else None
1128
+ converted_value = convert_value(value=value, target_type=pd_type, value_list=pd_value_list)
1129
+
1130
+ # Validate MIN/MAX constraints for numeric types
1131
+ if pd_type in (ParameterType.INTEGER, ParameterType.FLOAT) and converted_value is not None:
1132
+ pd_min = parameter_data.get("MIN")
1133
+ pd_max = parameter_data.get("MAX")
1134
+ if pd_min is not None and converted_value < pd_min:
1135
+ raise ValidationException(
1136
+ i18n.tr(
1137
+ key="exception.client.parameter.value_below_min",
1138
+ parameter=parameter,
1139
+ value=converted_value,
1140
+ min_value=pd_min,
1141
+ )
1142
+ )
1143
+ if pd_max is not None and converted_value > pd_max:
1144
+ raise ValidationException(
1145
+ i18n.tr(
1146
+ key="exception.client.parameter.value_above_max",
1147
+ parameter=parameter,
1148
+ value=converted_value,
1149
+ max_value=pd_max,
1150
+ )
1151
+ )
1152
+
1153
+ return converted_value
1154
+ raise ClientException(
1155
+ i18n.tr(
1156
+ key="exception.client.parameter.not_found",
1157
+ parameter=parameter,
1158
+ interface_id=self.interface_id,
1159
+ channel_address=channel_address,
1160
+ paramset_key=paramset_key,
1161
+ )
1162
+ )
1163
+
1164
+ def _get_init_url(self) -> str:
1165
+ """Return the init URL."""
1166
+ callback_host = (
1167
+ self._central.config.callback_host if self._central.config.callback_host else self._central.callback_ip_addr
1168
+ )
1169
+ callback_port = (
1170
+ self._central.config.callback_port_xml_rpc
1171
+ if self._central.config.callback_port_xml_rpc
1172
+ else self._central.listen_port_xml_rpc
1173
+ )
1174
+ return f"http://{callback_host}:{callback_port}"
1175
+
1176
+ async def _get_paramset_description(
1177
+ self, *, address: str, paramset_key: ParamsetKey
1178
+ ) -> dict[str, ParameterData] | None:
1179
+ """
1180
+ Fetch a paramset description via backend, with request coalescing.
1181
+
1182
+ Uses request coalescing to deduplicate concurrent requests for the same
1183
+ address and paramset_key combination. This is particularly beneficial
1184
+ during device discovery when multiple channels request the same descriptions.
1185
+ """
1186
+ key = make_coalesce_key(method="getParamsetDescription", args=(address, paramset_key))
1187
+
1188
+ async def _fetch() -> dict[str, ParameterData] | None:
1189
+ try:
1190
+ return await self._backend.get_paramset_description(address=address, paramset_key=paramset_key)
1191
+ except BaseHomematicException as bhexc:
1192
+ _LOGGER.debug(
1193
+ "GET_PARAMSET_DESCRIPTION failed with %s [%s] for %s address %s",
1194
+ bhexc.name,
1195
+ extract_exc_args(exc=bhexc),
1196
+ paramset_key,
1197
+ address,
1198
+ )
1199
+ return None
1200
+
1201
+ return await self._paramset_description_coalescer.execute(key=key, executor=_fetch)
1202
+
1203
+ def _mark_all_devices_forced_availability(self, *, forced_availability: ForcedDeviceAvailability) -> None:
1204
+ """Mark device's availability state for this interface."""
1205
+ available = forced_availability != ForcedDeviceAvailability.FORCE_FALSE
1206
+ if not available or self._state_machine.is_available != available:
1207
+ for device in self._central.device_registry.devices:
1208
+ if device.interface_id == self.interface_id:
1209
+ device.set_forced_availability(forced_availability=forced_availability)
1210
+ _LOGGER.debug(
1211
+ "MARK_ALL_DEVICES_FORCED_AVAILABILITY: marked all devices %s for %s",
1212
+ "available" if available else "unavailable",
1213
+ self.interface_id,
1214
+ )
1215
+
1216
+ def _on_client_state_changed_event(self, *, event: ClientStateChangedEvent) -> None:
1217
+ """Handle client state machine transitions."""
1218
+ self._central.event_bus.publish_sync(
1219
+ event=SystemStatusChangedEvent(
1220
+ timestamp=datetime.now(),
1221
+ client_state=(event.interface_id, ClientState(event.old_state), ClientState(event.new_state)),
1222
+ )
1223
+ )
1224
+
1225
+ def _on_system_status_event(self, *, event: SystemStatusChangedEvent) -> None:
1226
+ """Handle system status events."""
1227
+ if event.connection_state and event.connection_state[0] == self.interface_id and event.connection_state[1]:
1228
+ self._ping_pong_tracker.clear()
1229
+ _LOGGER.debug(
1230
+ "PING PONG CACHE: Cleared on connection restored: %s",
1231
+ self.interface_id,
1232
+ )
1233
+
1234
+ async def _poll_master_values(self, *, channel: ChannelProtocol, paramset_key: ParamsetKey) -> None:
1235
+ """Poll master paramset values after write for BidCos devices."""
1236
+
1237
+ async def poll_master_dp_values() -> None:
1238
+ """Load master paramset values with intervals."""
1239
+ for interval in self._central.config.schedule_timer_config.master_poll_after_send_intervals:
1240
+ await asyncio.sleep(interval)
1241
+ for dp in channel.get_readable_data_points(paramset_key=paramset_key):
1242
+ await dp.load_data_point_value(call_source=CallSource.MANUAL_OR_SCHEDULED, direct_call=True)
1243
+
1244
+ self._central.looper.create_task(target=poll_master_dp_values(), name="poll_master_dp_values")
1245
+
1246
+ def _record_callback_timeout_incident(
1247
+ self,
1248
+ *,
1249
+ seconds_since_last_event: float,
1250
+ callback_warn_interval: float,
1251
+ last_event_time: datetime,
1252
+ ) -> None:
1253
+ """Record a CALLBACK_TIMEOUT incident for diagnostics."""
1254
+ incident_recorder = self._central.cache_coordinator.incident_store
1255
+
1256
+ # Get circuit breaker state safely
1257
+ circuit_breaker_state: str | None = None
1258
+ if (cb := self._backend.circuit_breaker) is not None:
1259
+ circuit_breaker_state = cb.state.value
1260
+
1261
+ context = {
1262
+ "seconds_since_last_event": round(seconds_since_last_event, 2),
1263
+ "callback_warn_interval": callback_warn_interval,
1264
+ "last_event_time": last_event_time.strftime(DATETIME_FORMAT_MILLIS),
1265
+ "client_state": self._state_machine.state.value,
1266
+ "circuit_breaker_state": circuit_breaker_state,
1267
+ }
1268
+
1269
+ async def _record() -> None:
1270
+ try:
1271
+ await incident_recorder.record_incident(
1272
+ incident_type=IncidentType.CALLBACK_TIMEOUT,
1273
+ severity=IncidentSeverity.WARNING,
1274
+ message=f"No callback received for {self.interface_id} in {int(seconds_since_last_event)} seconds",
1275
+ interface_id=self.interface_id,
1276
+ context=context,
1277
+ )
1278
+ except Exception as err:
1279
+ _LOGGER.debug("Failed to record CALLBACK_TIMEOUT incident: %s", err)
1280
+
1281
+ self._central.looper.create_task(
1282
+ target=_record(),
1283
+ name=f"record_callback_timeout_incident_{self.interface_id}",
1284
+ )
1285
+
1286
+ async def _wait_for_state_change(
1287
+ self, *, device: DeviceProtocol, dpk_values: set[DP_KEY_VALUE], wait_for_callback: int
1288
+ ) -> None:
1289
+ """Wait for device state change or timeout."""
1290
+ await _wait_for_state_change_or_timeout(
1291
+ device=device, dpk_values=dpk_values, wait_for_callback=wait_for_callback
1292
+ )
1293
+
1294
+ def _write_temporary_value(self, *, dpk_values: set[DP_KEY_VALUE]) -> None:
1295
+ """Write temporary values to polling data points for immediate UI feedback."""
1296
+ for dpk, value in dpk_values:
1297
+ if (
1298
+ data_point := self._central.get_generic_data_point(
1299
+ channel_address=dpk.channel_address,
1300
+ parameter=dpk.parameter,
1301
+ paramset_key=dpk.paramset_key,
1302
+ )
1303
+ ) and data_point.requires_polling:
1304
+ data_point.write_temporary_value(value=value, write_at=datetime.now())