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,1085 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Device operations handler.
5
+
6
+ Handles value read/write, paramset operations, and device description fetching.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from datetime import datetime
13
+ import logging
14
+ from typing import TYPE_CHECKING, Any, Final, cast
15
+
16
+ from aiohomematic import i18n
17
+ from aiohomematic.central.events import IntegrationIssue, SystemStatusChangedEvent
18
+ from aiohomematic.client.handlers.base import BaseHandler
19
+ from aiohomematic.client.request_coalescer import RequestCoalescer, make_coalesce_key
20
+ from aiohomematic.const import (
21
+ DP_KEY_VALUE,
22
+ WAIT_FOR_CALLBACK,
23
+ CallSource,
24
+ CommandRxMode,
25
+ DeviceDescription,
26
+ IntegrationIssueSeverity,
27
+ IntegrationIssueType,
28
+ Interface,
29
+ InternalCustomID,
30
+ Operations,
31
+ ParameterData,
32
+ ParameterType,
33
+ ParamsetKey,
34
+ )
35
+ from aiohomematic.decorators import inspector, measure_execution_time
36
+ from aiohomematic.exceptions import BaseHomematicException, ClientException, ValidationException
37
+ from aiohomematic.interfaces import (
38
+ DeviceDiscoveryOperationsProtocol,
39
+ ParamsetOperationsProtocol,
40
+ ValueOperationsProtocol,
41
+ )
42
+ from aiohomematic.model.support import convert_value
43
+ from aiohomematic.schemas import normalize_device_description, normalize_paramset_description
44
+ from aiohomematic.support import (
45
+ extract_exc_args,
46
+ get_device_address,
47
+ is_channel_address,
48
+ is_paramset_key,
49
+ supports_rx_mode,
50
+ )
51
+
52
+ if TYPE_CHECKING:
53
+ from aiohomematic.client import AioJsonRpcAioHttpClient, BaseRpcProxy
54
+ from aiohomematic.interfaces import ClientDependenciesProtocol, DeviceProtocol
55
+ from aiohomematic.store.dynamic import CommandTracker
56
+
57
+ _LOGGER: Final = logging.getLogger(__name__)
58
+
59
+
60
+ class DeviceHandler(
61
+ BaseHandler,
62
+ DeviceDiscoveryOperationsProtocol,
63
+ ParamsetOperationsProtocol,
64
+ ValueOperationsProtocol,
65
+ ):
66
+ """
67
+ Handler for device value and paramset operations.
68
+
69
+ Implements DeviceDiscoveryOperationsProtocol, ParamsetOperationsProtocol, and ValueOperationsProtocol
70
+ protocols for ISP-compliant client operations.
71
+
72
+ Handles:
73
+ - Reading and writing data point values
74
+ - Reading and writing paramsets
75
+ - Fetching device and paramset descriptions
76
+ - Value conversion and validation
77
+ """
78
+
79
+ __slots__ = ("_device_description_coalescer", "_last_value_send_tracker", "_paramset_description_coalescer")
80
+
81
+ def __init__(
82
+ self,
83
+ *,
84
+ client_deps: ClientDependenciesProtocol,
85
+ interface: Interface,
86
+ interface_id: str,
87
+ json_rpc_client: AioJsonRpcAioHttpClient,
88
+ proxy: BaseRpcProxy,
89
+ proxy_read: BaseRpcProxy,
90
+ last_value_send_tracker: CommandTracker,
91
+ ) -> None:
92
+ """Initialize the device operations handler."""
93
+ super().__init__(
94
+ client_deps=client_deps,
95
+ interface=interface,
96
+ interface_id=interface_id,
97
+ json_rpc_client=json_rpc_client,
98
+ proxy=proxy,
99
+ proxy_read=proxy_read,
100
+ )
101
+ self._last_value_send_tracker: Final = last_value_send_tracker
102
+ self._device_description_coalescer: Final = RequestCoalescer(
103
+ name=f"device_desc:{interface_id}",
104
+ event_bus=client_deps.event_bus,
105
+ interface_id=interface_id,
106
+ )
107
+ self._paramset_description_coalescer: Final = RequestCoalescer(
108
+ name=f"paramset:{interface_id}",
109
+ event_bus=client_deps.event_bus,
110
+ interface_id=interface_id,
111
+ )
112
+
113
+ @property
114
+ def paramset_description_coalescer(self) -> RequestCoalescer:
115
+ """Return the paramset description coalescer for metrics access."""
116
+ return self._paramset_description_coalescer
117
+
118
+ @inspector(re_raise=False, measure_performance=True)
119
+ async def fetch_all_device_data(self) -> None:
120
+ """
121
+ Fetch all device data from the backend via JSON-RPC.
122
+
123
+ Retrieves current values for all data points on this interface in a single
124
+ bulk request. This is more efficient than fetching values individually.
125
+
126
+ The fetched data is stored in the central data cache for later use during
127
+ device initialization.
128
+
129
+ Raises
130
+ ------
131
+ ClientException: If the JSON-RPC call fails. Also publishes a
132
+ SystemStatusChangedEvent with an IntegrationIssue.
133
+
134
+ """
135
+ try:
136
+ if all_device_data := await self._json_rpc_client.get_all_device_data(interface=self._interface):
137
+ _LOGGER.debug(
138
+ "FETCH_ALL_DEVICE_DATA: Fetched all device data for interface %s",
139
+ self._interface,
140
+ )
141
+ self._client_deps.cache_coordinator.data_cache.add_data(
142
+ interface=self._interface, all_device_data=all_device_data
143
+ )
144
+ return
145
+ except ClientException:
146
+ issue = IntegrationIssue(
147
+ issue_type=IntegrationIssueType.FETCH_DATA_FAILED,
148
+ severity=IntegrationIssueSeverity.ERROR,
149
+ interface_id=self._interface_id,
150
+ )
151
+ await self._client_deps.event_bus.publish(
152
+ event=SystemStatusChangedEvent(
153
+ timestamp=datetime.now(),
154
+ issues=(issue,),
155
+ )
156
+ )
157
+ raise
158
+
159
+ _LOGGER.debug(
160
+ "FETCH_ALL_DEVICE_DATA: Unable to get all device data via JSON-RPC RegaScript for interface %s",
161
+ self._interface,
162
+ )
163
+
164
+ @inspector(re_raise=False, measure_performance=True)
165
+ async def fetch_device_details(self) -> None:
166
+ """
167
+ Fetch device details (names, interfaces, rega IDs) via JSON-RPC.
168
+
169
+ Retrieves metadata for all devices and channels from the CCU's ReGaHSS
170
+ scripting engine. The JSON response contains typed DeviceDetail objects
171
+ with address, name, id, interface, and nested channels.
172
+
173
+ Data is stored in the central's device_details cache for later use
174
+ during device/channel creation.
175
+ """
176
+ if json_result := await self._json_rpc_client.get_device_details():
177
+ for device in json_result:
178
+ # ignore unknown interfaces
179
+ if (interface := device["interface"]) and interface not in Interface:
180
+ continue
181
+
182
+ device_address = device["address"]
183
+ self._client_deps.cache_coordinator.device_details.add_interface(
184
+ address=device_address, interface=Interface(interface)
185
+ )
186
+ self._client_deps.cache_coordinator.device_details.add_name(address=device_address, name=device["name"])
187
+ self._client_deps.cache_coordinator.device_details.add_address_rega_id(
188
+ address=device_address, rega_id=device["id"]
189
+ )
190
+ for channel in device["channels"]:
191
+ channel_address = channel["address"]
192
+ self._client_deps.cache_coordinator.device_details.add_name(
193
+ address=channel_address, name=channel["name"]
194
+ )
195
+ self._client_deps.cache_coordinator.device_details.add_address_rega_id(
196
+ address=channel_address, rega_id=channel["id"]
197
+ )
198
+ else:
199
+ _LOGGER.debug("FETCH_DEVICE_DETAILS: Unable to fetch device details via JSON-RPC")
200
+
201
+ @inspector(re_raise=False)
202
+ async def fetch_paramset_description(self, *, channel_address: str, paramset_key: ParamsetKey) -> None:
203
+ """
204
+ Fetch a single paramset description and add it to the cache.
205
+
206
+ Args:
207
+ channel_address: Channel address (e.g., "VCU0000001:1").
208
+ paramset_key: Type of paramset (VALUES, MASTER, or LINK).
209
+
210
+ """
211
+ _LOGGER.debug("FETCH_PARAMSET_DESCRIPTION: %s for %s", paramset_key, channel_address)
212
+
213
+ if paramset_description := await self._get_paramset_description(
214
+ address=channel_address, paramset_key=paramset_key
215
+ ):
216
+ self._client_deps.cache_coordinator.paramset_descriptions.add(
217
+ interface_id=self._interface_id,
218
+ channel_address=channel_address,
219
+ paramset_key=paramset_key,
220
+ paramset_description=paramset_description,
221
+ )
222
+
223
+ @inspector(re_raise=False)
224
+ async def fetch_paramset_descriptions(self, *, device_description: DeviceDescription) -> None:
225
+ """
226
+ Fetch all paramset descriptions for a device and store in cache.
227
+
228
+ Iterates through all available paramsets (VALUES, MASTER, LINK) for the
229
+ device/channel specified in the device_description and adds each to the
230
+ central's paramset_descriptions cache.
231
+
232
+ Args:
233
+ device_description: Device description from listDevices() containing
234
+ ADDRESS and PARAMSETS fields.
235
+
236
+ """
237
+ data = await self.get_paramset_descriptions(device_description=device_description)
238
+ for address, paramsets in data.items():
239
+ _LOGGER.debug("FETCH_PARAMSET_DESCRIPTIONS for %s", address)
240
+ for paramset_key, paramset_description in paramsets.items():
241
+ self._client_deps.cache_coordinator.paramset_descriptions.add(
242
+ interface_id=self._interface_id,
243
+ channel_address=address,
244
+ paramset_key=paramset_key,
245
+ paramset_description=paramset_description,
246
+ )
247
+
248
+ @inspector(re_raise=False)
249
+ async def get_all_device_descriptions(self, *, device_address: str) -> tuple[DeviceDescription, ...]:
250
+ """
251
+ Return device description and all child channel descriptions.
252
+
253
+ Fetches the main device description, then iterates through its CHILDREN
254
+ field to fetch each channel's description. Logs warnings for any
255
+ missing descriptions but continues processing.
256
+
257
+ Args:
258
+ device_address: Device address without channel suffix (e.g., "VCU0000001").
259
+
260
+ Returns:
261
+ Tuple of DeviceDescription dicts, starting with the main device
262
+ followed by all its channels. Empty tuple if device not found.
263
+
264
+ """
265
+ all_device_description: list[DeviceDescription] = []
266
+ if main_dd := await self.get_device_description(address=device_address):
267
+ all_device_description.append(main_dd)
268
+ else:
269
+ _LOGGER.warning( # i18n-log: ignore
270
+ "GET_ALL_DEVICE_DESCRIPTIONS: No device description for %s",
271
+ device_address,
272
+ )
273
+
274
+ if main_dd:
275
+ for channel_address in main_dd.get("CHILDREN", []):
276
+ if channel_dd := await self.get_device_description(address=channel_address):
277
+ all_device_description.append(channel_dd)
278
+ else:
279
+ _LOGGER.warning( # i18n-log: ignore
280
+ "GET_ALL_DEVICE_DESCRIPTIONS: No channel description for %s",
281
+ channel_address,
282
+ )
283
+ return tuple(all_device_description)
284
+
285
+ @inspector
286
+ async def get_all_paramset_descriptions(
287
+ self, *, device_descriptions: tuple[DeviceDescription, ...]
288
+ ) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
289
+ """
290
+ Return aggregated paramset descriptions for multiple devices.
291
+
292
+ Iterates through each device description, fetching its paramset
293
+ descriptions and merging them into a single dictionary.
294
+
295
+ Args:
296
+ device_descriptions: Tuple of DeviceDescription dicts to process.
297
+
298
+ Returns:
299
+ Nested dict mapping: address -> paramset_key -> parameter -> ParameterData.
300
+
301
+ """
302
+ all_paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
303
+ for device_description in device_descriptions:
304
+ all_paramsets.update(await self.get_paramset_descriptions(device_description=device_description))
305
+ return all_paramsets
306
+
307
+ @inspector(re_raise=False)
308
+ async def get_device_description(self, *, address: str) -> DeviceDescription | None:
309
+ """
310
+ Return device description for a single address (normalized).
311
+
312
+ Uses request coalescing to deduplicate concurrent requests for the same
313
+ address. This is beneficial during device discovery when multiple callers
314
+ may request the same device description simultaneously.
315
+
316
+ Args:
317
+ address: Device or channel address (e.g., "VCU0000001" or "VCU0000001:1").
318
+
319
+ Returns:
320
+ Normalized DeviceDescription dict with TYPE, ADDRESS, CHILDREN, PARAMSETS, etc.
321
+ None if the address is not found or the RPC call fails.
322
+
323
+ """
324
+ key = make_coalesce_key(method="getDeviceDescription", args=(address,))
325
+
326
+ async def _fetch() -> DeviceDescription | None:
327
+ try:
328
+ if raw := await self._proxy_read.getDeviceDescription(address):
329
+ return normalize_device_description(device_description=raw)
330
+ except BaseHomematicException as bhexc:
331
+ _LOGGER.warning( # i18n-log: ignore
332
+ "GET_DEVICE_DESCRIPTION failed: %s [%s]", bhexc.name, extract_exc_args(exc=bhexc)
333
+ )
334
+ return None
335
+
336
+ return await self._device_description_coalescer.execute(key=key, executor=_fetch)
337
+
338
+ @inspector
339
+ async def get_paramset(
340
+ self,
341
+ *,
342
+ address: str,
343
+ paramset_key: ParamsetKey | str,
344
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
345
+ ) -> dict[str, Any]:
346
+ """
347
+ Return a paramset from the backend.
348
+
349
+ Address is usually the channel_address, but for bidcos devices
350
+ there is a master paramset at the device.
351
+ """
352
+ try:
353
+ _LOGGER.debug(
354
+ "GET_PARAMSET: address %s, paramset_key %s, source %s",
355
+ address,
356
+ paramset_key,
357
+ call_source,
358
+ )
359
+ return cast(dict[str, Any], await self._proxy_read.getParamset(address, paramset_key))
360
+ except BaseHomematicException as bhexc:
361
+ raise ClientException(
362
+ i18n.tr(
363
+ key="exception.client.get_paramset.failed",
364
+ address=address,
365
+ paramset_key=paramset_key,
366
+ reason=extract_exc_args(exc=bhexc),
367
+ )
368
+ ) from bhexc
369
+
370
+ @inspector(re_raise=False, no_raise_return={})
371
+ async def get_paramset_descriptions(
372
+ self, *, device_description: DeviceDescription
373
+ ) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
374
+ """
375
+ Return paramset descriptions for a single device/channel.
376
+
377
+ Iterates through the PARAMSETS field of the device_description to fetch
378
+ each available paramset (VALUES, MASTER, LINK) from the backend.
379
+
380
+ Args:
381
+ device_description: DeviceDescription dict containing ADDRESS and
382
+ PARAMSETS fields.
383
+
384
+ Returns:
385
+ Dict mapping address -> paramset_key -> parameter_name -> ParameterData.
386
+ Empty dict if all paramset fetches fail.
387
+
388
+ """
389
+ paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
390
+ address = device_description["ADDRESS"]
391
+ paramsets[address] = {}
392
+ _LOGGER.debug("GET_PARAMSET_DESCRIPTIONS for %s", address)
393
+ for p_key in device_description["PARAMSETS"]:
394
+ paramset_key = ParamsetKey(p_key)
395
+ if paramset_description := await self._get_paramset_description(address=address, paramset_key=paramset_key):
396
+ paramsets[address][paramset_key] = paramset_description
397
+ return paramsets
398
+
399
+ @inspector(log_level=logging.NOTSET)
400
+ async def get_value(
401
+ self,
402
+ *,
403
+ channel_address: str,
404
+ paramset_key: ParamsetKey,
405
+ parameter: str,
406
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
407
+ ) -> Any:
408
+ """
409
+ Return a single parameter value from the backend.
410
+
411
+ For VALUES paramset: Uses the optimized getValue() RPC call.
412
+ For MASTER paramset: Fetches entire paramset via getParamset() and
413
+ extracts the requested parameter, as there's no direct getValue for MASTER.
414
+
415
+ Args:
416
+ channel_address: Channel address (e.g., "VCU0000001:1").
417
+ paramset_key: VALUES or MASTER paramset key.
418
+ parameter: Parameter name (e.g., "STATE", "LEVEL").
419
+ call_source: Origin of the call for logging/metrics.
420
+
421
+ Returns:
422
+ Parameter value (type varies by parameter definition).
423
+
424
+ Raises:
425
+ ClientException: If the RPC call fails.
426
+
427
+ """
428
+ try:
429
+ _LOGGER.debug(
430
+ "GET_VALUE: channel_address %s, parameter %s, paramset_key, %s, source:%s",
431
+ channel_address,
432
+ parameter,
433
+ paramset_key,
434
+ call_source,
435
+ )
436
+ if paramset_key == ParamsetKey.VALUES:
437
+ return await self._proxy_read.getValue(channel_address, parameter)
438
+ paramset = await self._proxy_read.getParamset(channel_address, ParamsetKey.MASTER) or {}
439
+ return paramset.get(parameter)
440
+ except BaseHomematicException as bhexc:
441
+ raise ClientException(
442
+ i18n.tr(
443
+ key="exception.client.get_value.failed",
444
+ channel_address=channel_address,
445
+ parameter=parameter,
446
+ paramset_key=paramset_key,
447
+ reason=extract_exc_args(exc=bhexc),
448
+ )
449
+ ) from bhexc
450
+
451
+ @inspector(re_raise=False, measure_performance=True)
452
+ async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
453
+ """
454
+ Return all device descriptions from the backend (normalized).
455
+
456
+ Calls the XML-RPC listDevices() method to retrieve descriptions for all
457
+ devices and channels known to this interface.
458
+
459
+ Returns:
460
+ Tuple of normalized DeviceDescription dicts for all devices/channels.
461
+ None if the RPC call fails (e.g., connection error).
462
+
463
+ """
464
+ try:
465
+ raw_descriptions = await self._proxy_read.listDevices()
466
+ return tuple(normalize_device_description(device_description=desc) for desc in raw_descriptions)
467
+ except BaseHomematicException as bhexc:
468
+ _LOGGER.debug(
469
+ "LIST_DEVICES failed: %s [%s]",
470
+ bhexc.name,
471
+ extract_exc_args(exc=bhexc),
472
+ )
473
+ return None
474
+
475
+ @inspector(measure_performance=True)
476
+ async def put_paramset(
477
+ self,
478
+ *,
479
+ channel_address: str,
480
+ paramset_key_or_link_address: ParamsetKey | str,
481
+ values: dict[str, Any],
482
+ wait_for_callback: int | None = WAIT_FOR_CALLBACK,
483
+ rx_mode: CommandRxMode | None = None,
484
+ check_against_pd: bool = False,
485
+ ) -> set[DP_KEY_VALUE]:
486
+ """
487
+ Set paramsets manually.
488
+
489
+ Address is usually the channel_address, but for bidcos devices there is
490
+ a master paramset at the device. Paramset_key can be a str with a channel
491
+ address in case of manipulating a direct link.
492
+ """
493
+ is_link_call: bool = False
494
+ checked_values = values
495
+ try:
496
+ if check_against_pd:
497
+ check_paramset_key = (
498
+ ParamsetKey(paramset_key_or_link_address)
499
+ if is_paramset_key(paramset_key=paramset_key_or_link_address)
500
+ else ParamsetKey.LINK
501
+ if (is_link_call := is_channel_address(address=paramset_key_or_link_address))
502
+ else None
503
+ )
504
+ if check_paramset_key:
505
+ checked_values = self._check_put_paramset(
506
+ channel_address=channel_address,
507
+ paramset_key=check_paramset_key,
508
+ values=values,
509
+ )
510
+ else:
511
+ raise ClientException(i18n.tr(key="exception.client.paramset_key.invalid"))
512
+
513
+ _LOGGER.debug("PUT_PARAMSET: %s, %s, %s", channel_address, paramset_key_or_link_address, checked_values)
514
+ if rx_mode and (device := self._client_deps.device_coordinator.get_device(address=channel_address)):
515
+ if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
516
+ await self._exec_put_paramset(
517
+ channel_address=channel_address,
518
+ paramset_key=paramset_key_or_link_address,
519
+ values=checked_values,
520
+ rx_mode=rx_mode,
521
+ )
522
+ else:
523
+ raise ClientException(i18n.tr(key="exception.client.rx_mode.unsupported", rx_mode=rx_mode))
524
+ else:
525
+ await self._exec_put_paramset(
526
+ channel_address=channel_address,
527
+ paramset_key=paramset_key_or_link_address,
528
+ values=checked_values,
529
+ )
530
+
531
+ # if a call is related to a link then no further action is needed
532
+ if is_link_call:
533
+ return set()
534
+
535
+ # store the send value in the last_value_send_tracker
536
+ dpk_values = self._last_value_send_tracker.add_put_paramset(
537
+ channel_address=channel_address,
538
+ paramset_key=ParamsetKey(paramset_key_or_link_address),
539
+ values=checked_values,
540
+ )
541
+ self._write_temporary_value(dpk_values=dpk_values)
542
+
543
+ if (
544
+ self._interface in ("BidCos-RF", "BidCos-Wired")
545
+ and paramset_key_or_link_address == ParamsetKey.MASTER
546
+ and (channel := self._client_deps.device_coordinator.get_channel(channel_address=channel_address))
547
+ is not None
548
+ ):
549
+
550
+ async def poll_master_dp_values() -> None:
551
+ """Load master paramset values."""
552
+ if not channel:
553
+ return
554
+ for interval in self._client_deps.config.schedule_timer_config.master_poll_after_send_intervals:
555
+ await asyncio.sleep(interval)
556
+ for dp in channel.get_readable_data_points(
557
+ paramset_key=ParamsetKey(paramset_key_or_link_address)
558
+ ):
559
+ await dp.load_data_point_value(call_source=CallSource.MANUAL_OR_SCHEDULED, direct_call=True)
560
+
561
+ self._client_deps.looper.create_task(target=poll_master_dp_values(), name="poll_master_dp_values")
562
+
563
+ if wait_for_callback is not None and (
564
+ device := self._client_deps.device_coordinator.get_device(
565
+ address=get_device_address(address=channel_address)
566
+ )
567
+ ):
568
+ await _wait_for_state_change_or_timeout(
569
+ device=device,
570
+ dpk_values=dpk_values,
571
+ wait_for_callback=wait_for_callback,
572
+ )
573
+ except BaseHomematicException as bhexc:
574
+ raise ClientException(
575
+ i18n.tr(
576
+ key="exception.client.put_paramset.failed",
577
+ channel_address=channel_address,
578
+ paramset_key=paramset_key_or_link_address,
579
+ values=values,
580
+ reason=extract_exc_args(exc=bhexc),
581
+ )
582
+ ) from bhexc
583
+ else:
584
+ return dpk_values
585
+
586
+ @inspector
587
+ async def report_value_usage(
588
+ self,
589
+ *,
590
+ address: str,
591
+ value_id: str,
592
+ ref_counter: int,
593
+ supports: bool = True,
594
+ ) -> bool:
595
+ """
596
+ Report value usage to the backend for subscription management.
597
+
598
+ Used by the Homematic backend to track which parameters are actively
599
+ being used. This helps optimize event delivery by only sending events
600
+ for subscribed parameters.
601
+
602
+ Args:
603
+ address: Channel address (e.g., "VCU0000001:1").
604
+ value_id: Parameter identifier.
605
+ ref_counter: Reference count (positive = subscribe, 0 = unsubscribe).
606
+ supports: Whether this client type supports value usage reporting.
607
+ Defaults to True; ClientCCU passes actual capability.
608
+
609
+ Returns:
610
+ True if the report was successful, False if not supported or failed.
611
+
612
+ Raises:
613
+ ClientException: If the RPC call fails (when supports=True).
614
+
615
+ """
616
+ if not supports:
617
+ _LOGGER.debug("REPORT_VALUE_USAGE: Not supported by client for %s", self._interface_id)
618
+ return False
619
+
620
+ try:
621
+ return bool(await self._proxy.reportValueUsage(address, value_id, ref_counter))
622
+ except BaseHomematicException as bhexc:
623
+ raise ClientException(
624
+ i18n.tr(
625
+ key="exception.client.report_value_usage.failed",
626
+ address=address,
627
+ value_id=value_id,
628
+ ref_counter=ref_counter,
629
+ reason=extract_exc_args(exc=bhexc),
630
+ )
631
+ ) from bhexc
632
+
633
+ @inspector(re_raise=False, no_raise_return=set())
634
+ async def set_value(
635
+ self,
636
+ *,
637
+ channel_address: str,
638
+ paramset_key: ParamsetKey,
639
+ parameter: str,
640
+ value: Any,
641
+ wait_for_callback: int | None = WAIT_FOR_CALLBACK,
642
+ rx_mode: CommandRxMode | None = None,
643
+ check_against_pd: bool = False,
644
+ ) -> set[DP_KEY_VALUE]:
645
+ """
646
+ Set a single parameter value.
647
+
648
+ Routes to set_value_internal() for VALUES paramset or put_paramset()
649
+ for MASTER paramset.
650
+
651
+ Args:
652
+ channel_address: Channel address (e.g., "VCU0000001:1").
653
+ paramset_key: VALUES or MASTER paramset key.
654
+ parameter: Parameter name (e.g., "STATE", "LEVEL").
655
+ value: New value to set.
656
+ wait_for_callback: Seconds to wait for confirmation event (None = don't wait).
657
+ rx_mode: Optional transmission mode (BURST, WAKEUP, etc.).
658
+ check_against_pd: Validate value against paramset description.
659
+
660
+ Returns:
661
+ Set of (DataPointKey, value) tuples for the affected data points.
662
+
663
+ """
664
+ if paramset_key == ParamsetKey.VALUES:
665
+ return await self.set_value_internal(
666
+ channel_address=channel_address,
667
+ parameter=parameter,
668
+ value=value,
669
+ wait_for_callback=wait_for_callback,
670
+ rx_mode=rx_mode,
671
+ check_against_pd=check_against_pd,
672
+ )
673
+ return await self.put_paramset(
674
+ channel_address=channel_address,
675
+ paramset_key_or_link_address=paramset_key,
676
+ values={parameter: value},
677
+ wait_for_callback=wait_for_callback,
678
+ rx_mode=rx_mode,
679
+ check_against_pd=check_against_pd,
680
+ )
681
+
682
+ @inspector(measure_performance=True)
683
+ async def set_value_internal(
684
+ self,
685
+ *,
686
+ channel_address: str,
687
+ parameter: str,
688
+ value: Any,
689
+ wait_for_callback: int | None,
690
+ rx_mode: CommandRxMode | None = None,
691
+ check_against_pd: bool = False,
692
+ ) -> set[DP_KEY_VALUE]:
693
+ """
694
+ Set a single value on the VALUES paramset via setValue() RPC.
695
+
696
+ This is the core implementation for sending values to devices. It:
697
+ 1. Optionally validates the value against paramset description
698
+ 2. Sends the value via XML-RPC setValue()
699
+ 3. Caches the sent value for comparison with callback events
700
+ 4. Writes a temporary value to the data point for immediate UI feedback
701
+ 5. Optionally waits for the backend callback confirming the change
702
+
703
+ Args:
704
+ channel_address: Channel address (e.g., "VCU0000001:1").
705
+ parameter: Parameter name (e.g., "STATE", "LEVEL").
706
+ value: New value to set.
707
+ wait_for_callback: Seconds to wait for confirmation event (None = don't wait).
708
+ rx_mode: Optional transmission mode (BURST, WAKEUP, etc.).
709
+ check_against_pd: Validate value against paramset description.
710
+
711
+ Returns:
712
+ Set of (DataPointKey, value) tuples for the affected data points.
713
+
714
+ Raises:
715
+ ClientException: If the RPC call fails or rx_mode is unsupported.
716
+
717
+ """
718
+ try:
719
+ checked_value = (
720
+ self._check_set_value(
721
+ channel_address=channel_address,
722
+ paramset_key=ParamsetKey.VALUES,
723
+ parameter=parameter,
724
+ value=value,
725
+ )
726
+ if check_against_pd
727
+ else value
728
+ )
729
+ _LOGGER.debug("SET_VALUE: %s, %s, %s", channel_address, parameter, checked_value)
730
+ if rx_mode and (device := self._client_deps.device_coordinator.get_device(address=channel_address)):
731
+ if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
732
+ await self._exec_set_value(
733
+ channel_address=channel_address,
734
+ parameter=parameter,
735
+ value=value,
736
+ rx_mode=rx_mode,
737
+ )
738
+ else:
739
+ raise ClientException(i18n.tr(key="exception.client.rx_mode.unsupported", rx_mode=rx_mode))
740
+ else:
741
+ await self._exec_set_value(channel_address=channel_address, parameter=parameter, value=value)
742
+ # store the send value in the last_value_send_tracker
743
+ dpk_values = self._last_value_send_tracker.add_set_value(
744
+ channel_address=channel_address, parameter=parameter, value=checked_value
745
+ )
746
+ self._write_temporary_value(dpk_values=dpk_values)
747
+
748
+ if wait_for_callback is not None and (
749
+ device := self._client_deps.device_coordinator.get_device(
750
+ address=get_device_address(address=channel_address)
751
+ )
752
+ ):
753
+ await _wait_for_state_change_or_timeout(
754
+ device=device,
755
+ dpk_values=dpk_values,
756
+ wait_for_callback=wait_for_callback,
757
+ )
758
+ except BaseHomematicException as bhexc:
759
+ raise ClientException(
760
+ i18n.tr(
761
+ key="exception.client.set_value.failed",
762
+ channel_address=channel_address,
763
+ parameter=parameter,
764
+ value=value,
765
+ reason=extract_exc_args(exc=bhexc),
766
+ )
767
+ ) from bhexc
768
+ else:
769
+ return dpk_values
770
+
771
+ @inspector(re_raise=False)
772
+ async def update_paramset_descriptions(self, *, device_address: str) -> None:
773
+ """
774
+ Re-fetch and update paramset descriptions for a device.
775
+
776
+ Used when a device's firmware is updated or its configuration changes.
777
+ Fetches fresh paramset descriptions from the backend and saves them
778
+ to the persistent cache.
779
+
780
+ Args:
781
+ device_address: Device address without channel suffix (e.g., "VCU0000001").
782
+
783
+ """
784
+ if not self._client_deps.cache_coordinator.device_descriptions.get_device_descriptions(
785
+ interface_id=self._interface_id
786
+ ):
787
+ _LOGGER.warning( # i18n-log: ignore
788
+ "UPDATE_PARAMSET_DESCRIPTIONS failed: Interface missing in central cache. Not updating paramsets for %s",
789
+ device_address,
790
+ )
791
+ return
792
+
793
+ if device_description := self._client_deps.cache_coordinator.device_descriptions.find_device_description(
794
+ interface_id=self._interface_id, device_address=device_address
795
+ ):
796
+ await self.fetch_paramset_descriptions(device_description=device_description)
797
+ else:
798
+ _LOGGER.warning( # i18n-log: ignore
799
+ "UPDATE_PARAMSET_DESCRIPTIONS failed: Channel missing in central.cache. Not updating paramsets for %s",
800
+ device_address,
801
+ )
802
+ return
803
+ await self._client_deps.save_files(save_paramset_descriptions=True)
804
+
805
+ def _check_put_paramset(
806
+ self, *, channel_address: str, paramset_key: ParamsetKey, values: dict[str, Any]
807
+ ) -> dict[str, Any]:
808
+ """
809
+ Validate and convert all values in a paramset against their descriptions.
810
+
811
+ Iterates through each parameter in the values dict, converting types
812
+ and validating against MIN/MAX constraints.
813
+
814
+ Returns:
815
+ Dict with validated/converted values.
816
+
817
+ Raises:
818
+ ClientException: If any parameter validation fails.
819
+
820
+ """
821
+ checked_values: dict[str, Any] = {}
822
+ for param, value in values.items():
823
+ checked_values[param] = self._convert_value(
824
+ channel_address=channel_address,
825
+ paramset_key=paramset_key,
826
+ parameter=param,
827
+ value=value,
828
+ operation=Operations.WRITE,
829
+ )
830
+ return checked_values
831
+
832
+ def _check_set_value(self, *, channel_address: str, paramset_key: ParamsetKey, parameter: str, value: Any) -> Any:
833
+ """Validate and convert a single value against its parameter description."""
834
+ return self._convert_value(
835
+ channel_address=channel_address,
836
+ paramset_key=paramset_key,
837
+ parameter=parameter,
838
+ value=value,
839
+ operation=Operations.WRITE,
840
+ )
841
+
842
+ def _convert_value(
843
+ self,
844
+ *,
845
+ channel_address: str,
846
+ paramset_key: ParamsetKey,
847
+ parameter: str,
848
+ value: Any,
849
+ operation: Operations,
850
+ ) -> Any:
851
+ """
852
+ Validate and convert a parameter value against its description.
853
+
854
+ Performs the following checks:
855
+ 1. Parameter exists in paramset description
856
+ 2. Requested operation (READ/WRITE/EVENT) is supported
857
+ 3. Value is converted to the correct type (INTEGER, FLOAT, BOOL, ENUM, STRING)
858
+ 4. For numeric types, value is within MIN/MAX bounds
859
+
860
+ Returns:
861
+ Converted value matching the parameter's type definition.
862
+
863
+ Raises:
864
+ ClientException: If parameter not found or operation not supported.
865
+ ValidationException: If value is outside MIN/MAX bounds.
866
+
867
+ """
868
+ if parameter_data := self._client_deps.cache_coordinator.paramset_descriptions.get_parameter_data(
869
+ interface_id=self._interface_id,
870
+ channel_address=channel_address,
871
+ paramset_key=paramset_key,
872
+ parameter=parameter,
873
+ ):
874
+ pd_type = parameter_data["TYPE"]
875
+ op_mask = int(operation)
876
+ if (int(parameter_data["OPERATIONS"]) & op_mask) != op_mask:
877
+ raise ClientException(
878
+ i18n.tr(
879
+ key="exception.client.parameter.operation_unsupported",
880
+ parameter=parameter,
881
+ operation=operation.value,
882
+ )
883
+ )
884
+ # Only build a tuple if a value list exists
885
+ pd_value_list = tuple(parameter_data["VALUE_LIST"]) if parameter_data.get("VALUE_LIST") else None
886
+ converted_value = convert_value(value=value, target_type=pd_type, value_list=pd_value_list)
887
+
888
+ # Validate MIN/MAX constraints for numeric types
889
+ if pd_type in (ParameterType.INTEGER, ParameterType.FLOAT) and converted_value is not None:
890
+ pd_min = parameter_data.get("MIN")
891
+ pd_max = parameter_data.get("MAX")
892
+ if pd_min is not None and converted_value < pd_min:
893
+ raise ValidationException(
894
+ i18n.tr(
895
+ key="exception.client.parameter.value_below_min",
896
+ parameter=parameter,
897
+ value=converted_value,
898
+ min_value=pd_min,
899
+ )
900
+ )
901
+ if pd_max is not None and converted_value > pd_max:
902
+ raise ValidationException(
903
+ i18n.tr(
904
+ key="exception.client.parameter.value_above_max",
905
+ parameter=parameter,
906
+ value=converted_value,
907
+ max_value=pd_max,
908
+ )
909
+ )
910
+
911
+ return converted_value
912
+ raise ClientException(
913
+ i18n.tr(
914
+ key="exception.client.parameter.not_found",
915
+ parameter=parameter,
916
+ interface_id=self._interface_id,
917
+ channel_address=channel_address,
918
+ paramset_key=paramset_key,
919
+ )
920
+ )
921
+
922
+ async def _exec_put_paramset(
923
+ self,
924
+ *,
925
+ channel_address: str,
926
+ paramset_key: ParamsetKey | str,
927
+ values: dict[str, Any],
928
+ rx_mode: CommandRxMode | None = None,
929
+ ) -> None:
930
+ """Execute the XML-RPC putParamset call with optional rx_mode."""
931
+ if rx_mode:
932
+ await self._proxy.putParamset(channel_address, paramset_key, values, rx_mode)
933
+ else:
934
+ await self._proxy.putParamset(channel_address, paramset_key, values)
935
+
936
+ async def _exec_set_value(
937
+ self,
938
+ *,
939
+ channel_address: str,
940
+ parameter: str,
941
+ value: Any,
942
+ rx_mode: CommandRxMode | None = None,
943
+ ) -> None:
944
+ """Execute the XML-RPC setValue call with optional rx_mode."""
945
+ if rx_mode:
946
+ await self._proxy.setValue(channel_address, parameter, value, rx_mode)
947
+ else:
948
+ await self._proxy.setValue(channel_address, parameter, value)
949
+
950
+ def _get_parameter_type(
951
+ self,
952
+ *,
953
+ channel_address: str,
954
+ paramset_key: ParamsetKey,
955
+ parameter: str,
956
+ ) -> ParameterType | None:
957
+ """Return the parameter's TYPE field from its description, or None if not found."""
958
+ if parameter_data := self._client_deps.cache_coordinator.paramset_descriptions.get_parameter_data(
959
+ interface_id=self._interface_id,
960
+ channel_address=channel_address,
961
+ paramset_key=paramset_key,
962
+ parameter=parameter,
963
+ ):
964
+ return parameter_data["TYPE"]
965
+ return None
966
+
967
+ async def _get_paramset_description(
968
+ self, *, address: str, paramset_key: ParamsetKey
969
+ ) -> dict[str, ParameterData] | None:
970
+ """
971
+ Fetch and normalize paramset description via XML-RPC.
972
+
973
+ Uses request coalescing to deduplicate concurrent requests for the same
974
+ address and paramset_key combination. This is particularly beneficial
975
+ during device discovery when multiple channels request the same descriptions.
976
+ """
977
+ key = make_coalesce_key(method="getParamsetDescription", args=(address, paramset_key))
978
+
979
+ async def _fetch() -> dict[str, ParameterData] | None:
980
+ try:
981
+ raw = await self._proxy_read.getParamsetDescription(address, paramset_key)
982
+ return normalize_paramset_description(paramset=raw)
983
+ except BaseHomematicException as bhexc:
984
+ _LOGGER.debug(
985
+ "GET_PARAMSET_DESCRIPTIONS failed with %s [%s] for %s address %s",
986
+ bhexc.name,
987
+ extract_exc_args(exc=bhexc),
988
+ paramset_key,
989
+ address,
990
+ )
991
+ return None
992
+
993
+ return await self._paramset_description_coalescer.execute(key=key, executor=_fetch)
994
+
995
+ def _write_temporary_value(self, *, dpk_values: set[DP_KEY_VALUE]) -> None:
996
+ """Write temporary values to polling data points for immediate UI feedback."""
997
+ for dpk, value in dpk_values:
998
+ if (
999
+ data_point := self._client_deps.get_generic_data_point(
1000
+ channel_address=dpk.channel_address,
1001
+ parameter=dpk.parameter,
1002
+ paramset_key=dpk.paramset_key,
1003
+ )
1004
+ ) and data_point.requires_polling:
1005
+ data_point.write_temporary_value(value=value, write_at=datetime.now())
1006
+
1007
+
1008
+ @measure_execution_time
1009
+ async def _wait_for_state_change_or_timeout(
1010
+ *,
1011
+ device: DeviceProtocol,
1012
+ dpk_values: set[DP_KEY_VALUE],
1013
+ wait_for_callback: int,
1014
+ ) -> None:
1015
+ """Wait for all affected data points to receive confirmation callbacks in parallel."""
1016
+ waits = [
1017
+ _track_single_data_point_state_change_or_timeout(
1018
+ device=device,
1019
+ dpk_value=dpk_value,
1020
+ wait_for_callback=wait_for_callback,
1021
+ )
1022
+ for dpk_value in dpk_values
1023
+ ]
1024
+ await asyncio.gather(*waits)
1025
+
1026
+
1027
+ @measure_execution_time
1028
+ async def _track_single_data_point_state_change_or_timeout(
1029
+ *, device: DeviceProtocol, dpk_value: DP_KEY_VALUE, wait_for_callback: int
1030
+ ) -> None:
1031
+ """
1032
+ Wait for a single data point to receive its confirmation callback.
1033
+
1034
+ Subscribes to the data point's update events and waits until the received
1035
+ value matches the sent value (using fuzzy float comparison) or times out.
1036
+ """
1037
+ ev = asyncio.Event()
1038
+ dpk, value = dpk_value
1039
+
1040
+ def _async_event_changed(*args: Any, **kwargs: Any) -> None:
1041
+ if dp:
1042
+ _LOGGER.debug(
1043
+ "TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Received event %s with value %s",
1044
+ dpk,
1045
+ dp.value,
1046
+ )
1047
+ if _isclose(value1=value, value2=dp.value):
1048
+ _LOGGER.debug(
1049
+ "TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Finished event %s with value %s",
1050
+ dpk,
1051
+ dp.value,
1052
+ )
1053
+ ev.set()
1054
+
1055
+ if dp := device.get_generic_data_point(
1056
+ channel_address=dpk.channel_address,
1057
+ parameter=dpk.parameter,
1058
+ paramset_key=ParamsetKey(dpk.paramset_key),
1059
+ ):
1060
+ if not dp.has_events:
1061
+ _LOGGER.debug(
1062
+ "TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: DataPoint supports no events %s",
1063
+ dpk,
1064
+ )
1065
+ return
1066
+ unreg = dp.subscribe_to_data_point_updated(handler=_async_event_changed, custom_id=InternalCustomID.DEFAULT)
1067
+
1068
+ try:
1069
+ async with asyncio.timeout(wait_for_callback):
1070
+ await ev.wait()
1071
+ except TimeoutError:
1072
+ _LOGGER.debug(
1073
+ "TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Timeout waiting for event %s with value %s",
1074
+ dpk,
1075
+ dp.value,
1076
+ )
1077
+ finally:
1078
+ unreg()
1079
+
1080
+
1081
+ def _isclose(*, value1: Any, value2: Any) -> bool:
1082
+ """Compare values with fuzzy float matching (2 decimal places) for confirmation."""
1083
+ if isinstance(value1, float):
1084
+ return bool(round(value1, 2) == round(value2, 2))
1085
+ return bool(value1 == value2)