aiohomematic 2025.8.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (77) hide show
  1. aiohomematic/__init__.py +47 -0
  2. aiohomematic/async_support.py +146 -0
  3. aiohomematic/caches/__init__.py +10 -0
  4. aiohomematic/caches/dynamic.py +554 -0
  5. aiohomematic/caches/persistent.py +459 -0
  6. aiohomematic/caches/visibility.py +774 -0
  7. aiohomematic/central/__init__.py +2034 -0
  8. aiohomematic/central/decorators.py +110 -0
  9. aiohomematic/central/xml_rpc_server.py +267 -0
  10. aiohomematic/client/__init__.py +1746 -0
  11. aiohomematic/client/json_rpc.py +1193 -0
  12. aiohomematic/client/xml_rpc.py +222 -0
  13. aiohomematic/const.py +795 -0
  14. aiohomematic/context.py +8 -0
  15. aiohomematic/converter.py +82 -0
  16. aiohomematic/decorators.py +188 -0
  17. aiohomematic/exceptions.py +145 -0
  18. aiohomematic/hmcli.py +159 -0
  19. aiohomematic/model/__init__.py +137 -0
  20. aiohomematic/model/calculated/__init__.py +65 -0
  21. aiohomematic/model/calculated/climate.py +230 -0
  22. aiohomematic/model/calculated/data_point.py +319 -0
  23. aiohomematic/model/calculated/operating_voltage_level.py +311 -0
  24. aiohomematic/model/calculated/support.py +174 -0
  25. aiohomematic/model/custom/__init__.py +175 -0
  26. aiohomematic/model/custom/climate.py +1334 -0
  27. aiohomematic/model/custom/const.py +146 -0
  28. aiohomematic/model/custom/cover.py +741 -0
  29. aiohomematic/model/custom/data_point.py +318 -0
  30. aiohomematic/model/custom/definition.py +861 -0
  31. aiohomematic/model/custom/light.py +1092 -0
  32. aiohomematic/model/custom/lock.py +389 -0
  33. aiohomematic/model/custom/siren.py +268 -0
  34. aiohomematic/model/custom/support.py +40 -0
  35. aiohomematic/model/custom/switch.py +172 -0
  36. aiohomematic/model/custom/valve.py +112 -0
  37. aiohomematic/model/data_point.py +1109 -0
  38. aiohomematic/model/decorators.py +173 -0
  39. aiohomematic/model/device.py +1347 -0
  40. aiohomematic/model/event.py +210 -0
  41. aiohomematic/model/generic/__init__.py +211 -0
  42. aiohomematic/model/generic/action.py +32 -0
  43. aiohomematic/model/generic/binary_sensor.py +28 -0
  44. aiohomematic/model/generic/button.py +25 -0
  45. aiohomematic/model/generic/data_point.py +162 -0
  46. aiohomematic/model/generic/number.py +73 -0
  47. aiohomematic/model/generic/select.py +36 -0
  48. aiohomematic/model/generic/sensor.py +72 -0
  49. aiohomematic/model/generic/switch.py +52 -0
  50. aiohomematic/model/generic/text.py +27 -0
  51. aiohomematic/model/hub/__init__.py +334 -0
  52. aiohomematic/model/hub/binary_sensor.py +22 -0
  53. aiohomematic/model/hub/button.py +26 -0
  54. aiohomematic/model/hub/data_point.py +332 -0
  55. aiohomematic/model/hub/number.py +37 -0
  56. aiohomematic/model/hub/select.py +47 -0
  57. aiohomematic/model/hub/sensor.py +35 -0
  58. aiohomematic/model/hub/switch.py +42 -0
  59. aiohomematic/model/hub/text.py +28 -0
  60. aiohomematic/model/support.py +599 -0
  61. aiohomematic/model/update.py +136 -0
  62. aiohomematic/py.typed +0 -0
  63. aiohomematic/rega_scripts/fetch_all_device_data.fn +75 -0
  64. aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
  65. aiohomematic/rega_scripts/get_serial.fn +44 -0
  66. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
  67. aiohomematic/rega_scripts/set_program_state.fn +12 -0
  68. aiohomematic/rega_scripts/set_system_variable.fn +15 -0
  69. aiohomematic/support.py +482 -0
  70. aiohomematic/validator.py +65 -0
  71. aiohomematic-2025.8.6.dist-info/METADATA +69 -0
  72. aiohomematic-2025.8.6.dist-info/RECORD +77 -0
  73. aiohomematic-2025.8.6.dist-info/WHEEL +5 -0
  74. aiohomematic-2025.8.6.dist-info/licenses/LICENSE +21 -0
  75. aiohomematic-2025.8.6.dist-info/top_level.txt +2 -0
  76. aiohomematic_support/__init__.py +1 -0
  77. aiohomematic_support/client_local.py +349 -0
@@ -0,0 +1,1746 @@
1
+ """
2
+ Client adapters for communicating with HomeMatic CCU and compatible backends.
3
+
4
+ Overview
5
+ --------
6
+ This package provides client implementations that abstract the transport details of
7
+ HomeMatic backends (e.g., CCU via JSON-RPC/XML-RPC or Homegear) and expose a
8
+ consistent API used by the central module.
9
+
10
+ Provided clients
11
+ ----------------
12
+ - Client: Abstract base with common logic for parameter access, metadata retrieval,
13
+ connection checks, and firmware support detection.
14
+ - ClientCCU: Concrete client for CCU-compatible backends using XML-RPC for write/reads
15
+ and optional JSON-RPC for rich metadata and sysvar/program access.
16
+ - ClientJsonCCU: Specialization of ClientCCU that prefers JSON-RPC endpoints for
17
+ reads/writes and metadata.
18
+ - ClientHomegear: Client for Homegear using XML-RPC.
19
+
20
+ Key responsibilities
21
+ --------------------
22
+ - Initialize and manage transport proxies (XmlRpcProxy, JsonRpcAioHttpClient)
23
+ - Read/write data point values and paramsets
24
+ - Fetch device, channel, and parameter descriptions
25
+ - Track connection health and implement ping/pong where supported
26
+ - Provide program and system variable access (where supported)
27
+
28
+ Quick start
29
+ -----------
30
+ Create a client via create_client using an InterfaceConfig and a CentralUnit:
31
+
32
+ from aiohomematic import client as hmcl
33
+
34
+ iface_cfg = hmcl.InterfaceConfig(central_name="ccu-main", interface=hmcl.Interface.HMIP, port=2010)
35
+ client = hmcl.create_client(central, iface_cfg)
36
+ await client.init_client()
37
+ # ... use client.get_value(...), client.set_value(...), etc.
38
+
39
+ Notes
40
+ -----
41
+ - Most users interact with clients via the CentralUnit. Direct usage is possible for
42
+ advanced scenarios.
43
+ - XML-RPC support depends on the interface; JSON-RPC is only available on CCU backends.
44
+
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from abc import ABC, abstractmethod
50
+ import asyncio
51
+ from datetime import datetime
52
+ import logging
53
+ from typing import Any, Final, cast
54
+
55
+ from aiohomematic import central as hmcu
56
+ from aiohomematic.caches.dynamic import CommandCache, PingPongCache
57
+ from aiohomematic.client.xml_rpc import XmlRpcProxy
58
+ from aiohomematic.const import (
59
+ CALLBACK_WARN_INTERVAL,
60
+ DATETIME_FORMAT_MILLIS,
61
+ DEFAULT_CUSTOM_ID,
62
+ DEFAULT_MAX_WORKERS,
63
+ DP_KEY_VALUE,
64
+ DUMMY_SERIAL,
65
+ INIT_DATETIME,
66
+ INTERFACES_SUPPORTING_FIRMWARE_UPDATES,
67
+ INTERFACES_SUPPORTING_XML_RPC,
68
+ RECONNECT_WAIT,
69
+ VIRTUAL_REMOTE_MODELS,
70
+ WAIT_FOR_CALLBACK,
71
+ Backend,
72
+ CallSource,
73
+ CommandRxMode,
74
+ DescriptionMarker,
75
+ DeviceDescription,
76
+ EventKey,
77
+ ForcedDeviceAvailability,
78
+ Interface,
79
+ InterfaceEventType,
80
+ Operations,
81
+ ParameterData,
82
+ ParameterType,
83
+ ParamsetKey,
84
+ ProductGroup,
85
+ ProgramData,
86
+ ProxyInitState,
87
+ SystemInformation,
88
+ SystemVariableData,
89
+ )
90
+ from aiohomematic.decorators import inspector, measure_execution_time
91
+ from aiohomematic.exceptions import BaseHomematicException, ClientException, NoConnectionException
92
+ from aiohomematic.model.device import Device
93
+ from aiohomematic.model.support import convert_value
94
+ from aiohomematic.support import (
95
+ build_xml_rpc_headers,
96
+ build_xml_rpc_uri,
97
+ extract_exc_args,
98
+ get_device_address,
99
+ is_channel_address,
100
+ is_paramset_key,
101
+ supports_rx_mode,
102
+ )
103
+
104
+ __all__ = ["Client", "InterfaceConfig", "create_client", "get_client"]
105
+
106
+ _LOGGER: Final = logging.getLogger(__name__)
107
+
108
+ _JSON_ADDRESS: Final = "address"
109
+ _JSON_CHANNELS: Final = "channels"
110
+ _JSON_ID: Final = "id"
111
+ _JSON_INTERFACE: Final = "interface"
112
+ _JSON_NAME: Final = "name"
113
+ _NAME: Final = "NAME"
114
+
115
+ _CCU_JSON_VALUE_TYPE: Final = {
116
+ "ACTION": "bool",
117
+ "BOOL": "bool",
118
+ "ENUM": "list",
119
+ "FLOAT": "double",
120
+ "INTEGER": "int",
121
+ "STRING": "string",
122
+ }
123
+
124
+
125
+ class Client(ABC):
126
+ """Client object to access the backends via XML-RPC or JSON-RPC."""
127
+
128
+ def __init__(self, client_config: _ClientConfig) -> None:
129
+ """Initialize the Client."""
130
+ self._config: Final = client_config
131
+ self._supports_xml_rpc = self.interface in INTERFACES_SUPPORTING_XML_RPC
132
+ self._last_value_send_cache = CommandCache(interface_id=client_config.interface_id)
133
+ self._available: bool = True
134
+ self._connection_error_count: int = 0
135
+ self._is_callback_alive: bool = True
136
+ self._is_initialized: bool = False
137
+ self._ping_pong_cache: Final = PingPongCache(
138
+ central=client_config.central, interface_id=client_config.interface_id
139
+ )
140
+ self._proxy: XmlRpcProxy
141
+ self._proxy_read: XmlRpcProxy
142
+ self._system_information: SystemInformation
143
+ self.modified_at: datetime = INIT_DATETIME
144
+
145
+ async def init_client(self) -> None:
146
+ """Init the client."""
147
+ self._system_information = await self._get_system_information()
148
+ self._proxy = await self._config.create_xml_rpc_proxy(auth_enabled=self.system_information.auth_enabled)
149
+ self._proxy_read = await self._config.create_xml_rpc_proxy(
150
+ auth_enabled=self.system_information.auth_enabled,
151
+ max_workers=self._config.max_read_workers,
152
+ )
153
+
154
+ @property
155
+ def available(self) -> bool:
156
+ """Return the availability of the client."""
157
+ return self._available
158
+
159
+ @property
160
+ def central(self) -> hmcu.CentralUnit:
161
+ """Return the central of the client."""
162
+ return self._config.central
163
+
164
+ @property
165
+ def interface(self) -> Interface:
166
+ """Return the interface of the client."""
167
+ return self._config.interface
168
+
169
+ @property
170
+ def interface_id(self) -> str:
171
+ """Return the interface id of the client."""
172
+ return self._config.interface_id
173
+
174
+ @property
175
+ def is_initialized(self) -> bool:
176
+ """Return if interface is initialized."""
177
+ return self._is_initialized
178
+
179
+ @property
180
+ def last_value_send_cache(self) -> CommandCache:
181
+ """Return the last value send cache."""
182
+ return self._last_value_send_cache
183
+
184
+ @property
185
+ @abstractmethod
186
+ def model(self) -> str:
187
+ """Return the model of the backend."""
188
+
189
+ @property
190
+ def ping_pong_cache(self) -> PingPongCache:
191
+ """Return the ping pong cache."""
192
+ return self._ping_pong_cache
193
+
194
+ @property
195
+ def supports_xml_rpc(self) -> bool:
196
+ """Return if interface support xml rpc."""
197
+ return self._supports_xml_rpc
198
+
199
+ @property
200
+ def system_information(self) -> SystemInformation:
201
+ """Return the system_information of the client."""
202
+ return self._system_information
203
+
204
+ @property
205
+ def version(self) -> str:
206
+ """Return the version id of the client."""
207
+ return self._config.version
208
+
209
+ def get_product_group(self, model: str) -> ProductGroup:
210
+ """Return the product group."""
211
+ l_model = model.lower()
212
+ if l_model.startswith("hmipw-"):
213
+ return ProductGroup.HMIPW
214
+ if l_model.startswith("hmip-"):
215
+ return ProductGroup.HMIP
216
+ if l_model.startswith("hmw-"):
217
+ return ProductGroup.HMW
218
+ if l_model.startswith("hm-"):
219
+ return ProductGroup.HM
220
+ if self.interface == Interface.HMIP_RF:
221
+ return ProductGroup.HMIP
222
+ if self.interface == Interface.BIDCOS_WIRED:
223
+ return ProductGroup.HMW
224
+ if self.interface == Interface.BIDCOS_RF:
225
+ return ProductGroup.HM
226
+ if self.interface == Interface.VIRTUAL_DEVICES:
227
+ return ProductGroup.VIRTUAL
228
+ return ProductGroup.UNKNOWN
229
+
230
+ @property
231
+ @abstractmethod
232
+ def supports_ping_pong(self) -> bool:
233
+ """Return the supports_ping_pong info of the backend."""
234
+
235
+ @property
236
+ def supports_push_updates(self) -> bool:
237
+ """Return the client supports push update."""
238
+ return self.interface not in self.central.config.interfaces_requiring_periodic_refresh
239
+
240
+ @property
241
+ def supports_firmware_updates(self) -> bool:
242
+ """Return the supports_ping_pong info of the backend."""
243
+ return self.interface in INTERFACES_SUPPORTING_FIRMWARE_UPDATES
244
+
245
+ async def initialize_proxy(self) -> ProxyInitState:
246
+ """Init the proxy has to tell the CCU / Homegear where to send the events."""
247
+
248
+ if not self.supports_xml_rpc:
249
+ if device_descriptions := await self.list_devices():
250
+ await self.central.add_new_devices(
251
+ interface_id=self.interface_id, device_descriptions=device_descriptions
252
+ )
253
+ return ProxyInitState.INIT_SUCCESS
254
+ return ProxyInitState.INIT_FAILED
255
+ try:
256
+ _LOGGER.debug("PROXY_INIT: init('%s', '%s')", self._config.init_url, self.interface_id)
257
+ self._ping_pong_cache.clear()
258
+ await self._proxy.init(self._config.init_url, self.interface_id)
259
+ self._is_initialized = True
260
+ self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.NOT_SET)
261
+ _LOGGER.debug("PROXY_INIT: Proxy for %s initialized", self.interface_id)
262
+ except BaseHomematicException as bhexc:
263
+ _LOGGER.warning(
264
+ "PROXY_INIT failed: %s [%s] Unable to initialize proxy for %s",
265
+ bhexc.name,
266
+ extract_exc_args(exc=bhexc),
267
+ self.interface_id,
268
+ )
269
+ self.modified_at = INIT_DATETIME
270
+ return ProxyInitState.INIT_FAILED
271
+ self.modified_at = datetime.now()
272
+ return ProxyInitState.INIT_SUCCESS
273
+
274
+ async def deinitialize_proxy(self) -> ProxyInitState:
275
+ """De-init to stop CCU from sending events for this remote."""
276
+ if not self.supports_xml_rpc:
277
+ return ProxyInitState.DE_INIT_SUCCESS
278
+
279
+ if self.modified_at == INIT_DATETIME:
280
+ _LOGGER.debug(
281
+ "PROXY_DE_INIT: Skipping de-init for %s (not initialized)",
282
+ self.interface_id,
283
+ )
284
+ return ProxyInitState.DE_INIT_SKIPPED
285
+ try:
286
+ _LOGGER.debug("PROXY_DE_INIT: init('%s')", self._config.init_url)
287
+ await self._proxy.init(self._config.init_url)
288
+ self._is_initialized = False
289
+ except BaseHomematicException as bhexc:
290
+ _LOGGER.warning(
291
+ "PROXY_DE_INIT failed: %s [%s] Unable to de-initialize proxy for %s",
292
+ bhexc.name,
293
+ extract_exc_args(exc=bhexc),
294
+ self.interface_id,
295
+ )
296
+ return ProxyInitState.DE_INIT_FAILED
297
+
298
+ self.modified_at = INIT_DATETIME
299
+ return ProxyInitState.DE_INIT_SUCCESS
300
+
301
+ async def reinitialize_proxy(self) -> ProxyInitState:
302
+ """Reinit Proxy."""
303
+ if await self.deinitialize_proxy() != ProxyInitState.DE_INIT_FAILED:
304
+ return await self.initialize_proxy()
305
+ return ProxyInitState.DE_INIT_FAILED
306
+
307
+ def _mark_all_devices_forced_availability(self, forced_availability: ForcedDeviceAvailability) -> None:
308
+ """Mark device's availability state for this interface."""
309
+ available = forced_availability != ForcedDeviceAvailability.FORCE_FALSE
310
+ if self._available != available:
311
+ for device in self.central.devices:
312
+ if device.interface_id == self.interface_id:
313
+ device.set_forced_availability(forced_availability=forced_availability)
314
+ self._available = available
315
+ _LOGGER.debug(
316
+ "MARK_ALL_DEVICES_FORCED_AVAILABILITY: marked all devices %s for %s",
317
+ "available" if available else "unavailable",
318
+ self.interface_id,
319
+ )
320
+ self.central.fire_interface_event(
321
+ interface_id=self.interface_id,
322
+ interface_event_type=InterfaceEventType.PROXY,
323
+ data={EventKey.AVAILABLE: available},
324
+ )
325
+
326
+ async def reconnect(self) -> bool:
327
+ """re-init all RPC clients."""
328
+ if await self.is_connected():
329
+ _LOGGER.debug(
330
+ "RECONNECT: waiting to re-connect client %s for %is",
331
+ self.interface_id,
332
+ int(RECONNECT_WAIT),
333
+ )
334
+ await asyncio.sleep(RECONNECT_WAIT)
335
+
336
+ await self.reinitialize_proxy()
337
+ _LOGGER.info(
338
+ "RECONNECT: re-connected client %s",
339
+ self.interface_id,
340
+ )
341
+ return True
342
+ return False
343
+
344
+ async def stop(self) -> None:
345
+ """Stop depending services."""
346
+ if not self.supports_xml_rpc:
347
+ return
348
+ await self._proxy.stop()
349
+ await self._proxy_read.stop()
350
+
351
+ @abstractmethod
352
+ @inspector(re_raise=False, measure_performance=True)
353
+ async def fetch_all_device_data(self) -> None:
354
+ """Fetch all device data from CCU."""
355
+
356
+ @abstractmethod
357
+ @inspector(re_raise=False, measure_performance=True)
358
+ async def fetch_device_details(self) -> None:
359
+ """Fetch names from backend."""
360
+
361
+ @inspector(re_raise=False, no_raise_return=False)
362
+ async def is_connected(self) -> bool:
363
+ """
364
+ Perform actions required for connectivity check.
365
+
366
+ Connection is not connected, if three consecutive checks fail.
367
+ Return connectivity state.
368
+ """
369
+ if await self.check_connection_availability(handle_ping_pong=True) is True:
370
+ self._connection_error_count = 0
371
+ else:
372
+ self._connection_error_count += 1
373
+
374
+ if self._connection_error_count > 3:
375
+ self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
376
+ return False
377
+ if not self.supports_push_updates:
378
+ return True
379
+ return (datetime.now() - self.modified_at).total_seconds() < CALLBACK_WARN_INTERVAL
380
+
381
+ def is_callback_alive(self) -> bool:
382
+ """Return if XmlRPC-Server is alive based on received events for this client."""
383
+ if not self.supports_ping_pong:
384
+ return True
385
+ if (last_events_dt := self.central.get_last_event_dt(interface_id=self.interface_id)) is not None:
386
+ if (seconds_since_last_event := (datetime.now() - last_events_dt).total_seconds()) > CALLBACK_WARN_INTERVAL:
387
+ if self._is_callback_alive:
388
+ self.central.fire_interface_event(
389
+ interface_id=self.interface_id,
390
+ interface_event_type=InterfaceEventType.CALLBACK,
391
+ data={
392
+ EventKey.AVAILABLE: False,
393
+ EventKey.SECONDS_SINCE_LAST_EVENT: int(seconds_since_last_event),
394
+ },
395
+ )
396
+ self._is_callback_alive = False
397
+ _LOGGER.warning(
398
+ "IS_CALLBACK_ALIVE: Callback for %s has not received events for %is",
399
+ self.interface_id,
400
+ seconds_since_last_event,
401
+ )
402
+ return False
403
+
404
+ if not self._is_callback_alive:
405
+ self.central.fire_interface_event(
406
+ interface_id=self.interface_id,
407
+ interface_event_type=InterfaceEventType.CALLBACK,
408
+ data={EventKey.AVAILABLE: True},
409
+ )
410
+ self._is_callback_alive = True
411
+ return True
412
+
413
+ @abstractmethod
414
+ @inspector(re_raise=False, no_raise_return=False)
415
+ async def check_connection_availability(self, handle_ping_pong: bool) -> bool:
416
+ """Send ping to CCU to generate PONG event."""
417
+
418
+ @abstractmethod
419
+ @inspector()
420
+ async def execute_program(self, pid: str) -> bool:
421
+ """Execute a program on CCU / Homegear."""
422
+
423
+ @abstractmethod
424
+ @inspector()
425
+ async def set_program_state(self, pid: str, state: bool) -> bool:
426
+ """Set the program state on CCU / Homegear."""
427
+
428
+ @abstractmethod
429
+ @inspector(measure_performance=True)
430
+ async def set_system_variable(self, legacy_name: str, value: Any) -> bool:
431
+ """Set a system variable on CCU / Homegear."""
432
+
433
+ @abstractmethod
434
+ @inspector()
435
+ async def delete_system_variable(self, name: str) -> bool:
436
+ """Delete a system variable from CCU / Homegear."""
437
+
438
+ @abstractmethod
439
+ @inspector()
440
+ async def get_system_variable(self, name: str) -> Any:
441
+ """Get single system variable from CCU / Homegear."""
442
+
443
+ @abstractmethod
444
+ @inspector(re_raise=False)
445
+ async def get_all_system_variables(
446
+ self, markers: tuple[DescriptionMarker | str, ...]
447
+ ) -> tuple[SystemVariableData, ...] | None:
448
+ """Get all system variables from CCU / Homegear."""
449
+
450
+ @abstractmethod
451
+ @inspector(re_raise=False)
452
+ async def get_all_programs(self, markers: tuple[DescriptionMarker | str, ...]) -> tuple[ProgramData, ...] | None:
453
+ """Get all programs, if available."""
454
+
455
+ @abstractmethod
456
+ @inspector(re_raise=False, no_raise_return={})
457
+ async def get_all_rooms(self) -> dict[str, set[str]]:
458
+ """Get all rooms, if available."""
459
+
460
+ @abstractmethod
461
+ @inspector(re_raise=False, no_raise_return={})
462
+ async def get_all_functions(self) -> dict[str, set[str]]:
463
+ """Get all functions, if available."""
464
+
465
+ @abstractmethod
466
+ async def _get_system_information(self) -> SystemInformation:
467
+ """Get system information of the backend."""
468
+
469
+ def get_virtual_remote(self) -> Device | None:
470
+ """Get the virtual remote for the Client."""
471
+ for model in VIRTUAL_REMOTE_MODELS:
472
+ for device in self.central.devices:
473
+ if device.interface_id == self.interface_id and device.model == model:
474
+ return device
475
+ return None
476
+
477
+ @inspector(re_raise=False)
478
+ async def get_device_description(self, device_address: str) -> DeviceDescription | None:
479
+ """Get device descriptions from CCU / Homegear."""
480
+ try:
481
+ if device_description := cast(
482
+ DeviceDescription | None,
483
+ await self._proxy_read.getDeviceDescription(device_address),
484
+ ):
485
+ return device_description
486
+ except BaseHomematicException as bhexc:
487
+ _LOGGER.warning("GET_DEVICE_DESCRIPTIONS failed: %s [%s]", bhexc.name, extract_exc_args(exc=bhexc))
488
+ return None
489
+
490
+ @inspector()
491
+ async def add_link(self, sender_address: str, receiver_address: str, name: str, description: str) -> None:
492
+ """Return a list of links."""
493
+ try:
494
+ await self._proxy.addLink(sender_address, receiver_address, name, description)
495
+ except BaseHomematicException as bhexc:
496
+ raise ClientException(
497
+ f"ADD_LINK failed with for: {sender_address}/{receiver_address}/{name}/{description}: {extract_exc_args(exc=bhexc)}"
498
+ ) from bhexc
499
+
500
+ @inspector()
501
+ async def remove_link(self, sender_address: str, receiver_address: str) -> None:
502
+ """Return a list of links."""
503
+ try:
504
+ await self._proxy.removeLink(sender_address, receiver_address)
505
+ except BaseHomematicException as bhexc:
506
+ raise ClientException(
507
+ f"REMOVE_LINK failed with for: {sender_address}/{receiver_address}: {extract_exc_args(exc=bhexc)}"
508
+ ) from bhexc
509
+
510
+ @inspector()
511
+ async def get_link_peers(self, address: str) -> tuple[str, ...] | None:
512
+ """Return a list of link pers."""
513
+ try:
514
+ return tuple(await self._proxy.getLinkPeers(address))
515
+ except BaseHomematicException as bhexc:
516
+ raise ClientException(
517
+ f"GET_LINK_PEERS failed with for: {address}: {extract_exc_args(exc=bhexc)}"
518
+ ) from bhexc
519
+
520
+ @inspector()
521
+ async def get_links(self, address: str, flags: int) -> dict[str, Any]:
522
+ """Return a list of links."""
523
+ try:
524
+ return cast(dict[str, Any], await self._proxy.getLinks(address, flags))
525
+ except BaseHomematicException as bhexc:
526
+ raise ClientException(f"GET_LINKS failed with for: {address}: {extract_exc_args(exc=bhexc)}") from bhexc
527
+
528
+ @inspector()
529
+ async def get_metadata(self, address: str, data_id: str) -> dict[str, Any]:
530
+ """Return the metadata for an object."""
531
+ try:
532
+ return cast(dict[str, Any], await self._proxy.getMetadata(address, data_id))
533
+ except BaseHomematicException as bhexc:
534
+ raise ClientException(
535
+ f"GET_METADATA failed with for: {address}/{data_id}: {extract_exc_args(exc=bhexc)}"
536
+ ) from bhexc
537
+
538
+ @inspector()
539
+ async def set_metadata(self, address: str, data_id: str, value: dict[str, Any]) -> dict[str, Any]:
540
+ """Write the metadata for an object."""
541
+ try:
542
+ return cast(dict[str, Any], await self._proxy.setMetadata(address, data_id, value))
543
+ except BaseHomematicException as bhexc:
544
+ raise ClientException(
545
+ f"SET_METADATA failed with for: {address}/{data_id}/{value}: {extract_exc_args(exc=bhexc)}"
546
+ ) from bhexc
547
+
548
+ @inspector(log_level=logging.NOTSET)
549
+ async def get_value(
550
+ self,
551
+ channel_address: str,
552
+ paramset_key: ParamsetKey,
553
+ parameter: str,
554
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
555
+ ) -> Any:
556
+ """Return a value from CCU."""
557
+ try:
558
+ _LOGGER.debug(
559
+ "GET_VALUE: channel_address %s, parameter %s, paramset_key, %s, source:%s",
560
+ channel_address,
561
+ parameter,
562
+ paramset_key,
563
+ call_source,
564
+ )
565
+ if paramset_key == ParamsetKey.VALUES:
566
+ return await self._proxy_read.getValue(channel_address, parameter)
567
+ paramset = await self._proxy_read.getParamset(channel_address, ParamsetKey.MASTER) or {}
568
+ return paramset.get(parameter)
569
+ except BaseHomematicException as bhexc:
570
+ raise ClientException(
571
+ f"GET_VALUE failed with for: {channel_address}/{parameter}/{paramset_key}: {extract_exc_args(exc=bhexc)}"
572
+ ) from bhexc
573
+
574
+ @inspector(measure_performance=True)
575
+ async def _set_value(
576
+ self,
577
+ channel_address: str,
578
+ parameter: str,
579
+ value: Any,
580
+ wait_for_callback: int | None,
581
+ rx_mode: CommandRxMode | None = None,
582
+ check_against_pd: bool = False,
583
+ ) -> set[DP_KEY_VALUE]:
584
+ """Set single value on paramset VALUES."""
585
+ try:
586
+ checked_value = (
587
+ self._check_set_value(
588
+ channel_address=channel_address,
589
+ paramset_key=ParamsetKey.VALUES,
590
+ parameter=parameter,
591
+ value=value,
592
+ )
593
+ if check_against_pd
594
+ else value
595
+ )
596
+ _LOGGER.debug("SET_VALUE: %s, %s, %s", channel_address, parameter, checked_value)
597
+ if rx_mode and (device := self.central.get_device(address=channel_address)):
598
+ if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
599
+ await self._exec_set_value(
600
+ channel_address=channel_address,
601
+ parameter=parameter,
602
+ value=value,
603
+ rx_mode=rx_mode,
604
+ )
605
+ else:
606
+ raise ClientException(f"Unsupported rx_mode: {rx_mode}")
607
+ else:
608
+ await self._exec_set_value(channel_address=channel_address, parameter=parameter, value=value)
609
+ # store the send value in the last_value_send_cache
610
+ dpk_values = self._last_value_send_cache.add_set_value(
611
+ channel_address=channel_address, parameter=parameter, value=checked_value
612
+ )
613
+ self._write_temporary_value(dpk_values=dpk_values)
614
+
615
+ if wait_for_callback is not None and (
616
+ device := self.central.get_device(address=get_device_address(address=channel_address))
617
+ ):
618
+ await _wait_for_state_change_or_timeout(
619
+ device=device,
620
+ dpk_values=dpk_values,
621
+ wait_for_callback=wait_for_callback,
622
+ )
623
+ except BaseHomematicException as bhexc:
624
+ raise ClientException(
625
+ f"SET_VALUE failed for {channel_address}/{parameter}/{value}: {extract_exc_args(exc=bhexc)}"
626
+ ) from bhexc
627
+ else:
628
+ return dpk_values
629
+
630
+ async def _exec_set_value(
631
+ self,
632
+ channel_address: str,
633
+ parameter: str,
634
+ value: Any,
635
+ rx_mode: CommandRxMode | None = None,
636
+ ) -> None:
637
+ """Set single value on paramset VALUES."""
638
+ if rx_mode:
639
+ await self._proxy.setValue(channel_address, parameter, value, rx_mode)
640
+ else:
641
+ await self._proxy.setValue(channel_address, parameter, value)
642
+
643
+ def _check_set_value(self, channel_address: str, paramset_key: ParamsetKey, parameter: str, value: Any) -> Any:
644
+ """Check set_value."""
645
+ return self._convert_value(
646
+ channel_address=channel_address,
647
+ paramset_key=paramset_key,
648
+ parameter=parameter,
649
+ value=value,
650
+ operation=Operations.WRITE,
651
+ )
652
+
653
+ def _write_temporary_value(self, dpk_values: set[DP_KEY_VALUE]) -> None:
654
+ """Write data point temp value."""
655
+ for dpk, value in dpk_values:
656
+ if (
657
+ data_point := self.central.get_generic_data_point(
658
+ channel_address=dpk.channel_address,
659
+ parameter=dpk.parameter,
660
+ paramset_key=dpk.paramset_key,
661
+ )
662
+ ) and data_point.requires_polling:
663
+ data_point.write_temporary_value(value=value, write_at=datetime.now())
664
+
665
+ @inspector(re_raise=False, no_raise_return=set())
666
+ async def set_value(
667
+ self,
668
+ channel_address: str,
669
+ paramset_key: ParamsetKey,
670
+ parameter: str,
671
+ value: Any,
672
+ wait_for_callback: int | None = WAIT_FOR_CALLBACK,
673
+ rx_mode: CommandRxMode | None = None,
674
+ check_against_pd: bool = False,
675
+ ) -> set[DP_KEY_VALUE]:
676
+ """Set single value on paramset VALUES."""
677
+ if paramset_key == ParamsetKey.VALUES:
678
+ return await self._set_value(
679
+ channel_address=channel_address,
680
+ parameter=parameter,
681
+ value=value,
682
+ wait_for_callback=wait_for_callback,
683
+ rx_mode=rx_mode,
684
+ check_against_pd=check_against_pd,
685
+ )
686
+ return await self.put_paramset(
687
+ channel_address=channel_address,
688
+ paramset_key_or_link_address=paramset_key,
689
+ values={parameter: value},
690
+ wait_for_callback=wait_for_callback,
691
+ rx_mode=rx_mode,
692
+ check_against_pd=check_against_pd,
693
+ )
694
+
695
+ @inspector()
696
+ async def get_paramset(
697
+ self,
698
+ address: str,
699
+ paramset_key: ParamsetKey | str,
700
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
701
+ ) -> dict[str, Any]:
702
+ """
703
+ Return a paramset from CCU.
704
+
705
+ Address is usually the channel_address,
706
+ but for bidcos devices there is a master paramset at the device.
707
+ """
708
+ try:
709
+ _LOGGER.debug(
710
+ "GET_PARAMSET: address %s, paramset_key %s, source %s",
711
+ address,
712
+ paramset_key,
713
+ call_source,
714
+ )
715
+ return cast(dict[str, Any], await self._proxy_read.getParamset(address, paramset_key))
716
+ except BaseHomematicException as bhexc:
717
+ raise ClientException(
718
+ f"GET_PARAMSET failed with for {address}/{paramset_key}: {extract_exc_args(exc=bhexc)}"
719
+ ) from bhexc
720
+
721
+ @inspector(measure_performance=True)
722
+ async def put_paramset(
723
+ self,
724
+ channel_address: str,
725
+ paramset_key_or_link_address: ParamsetKey | str,
726
+ values: dict[str, Any],
727
+ wait_for_callback: int | None = WAIT_FOR_CALLBACK,
728
+ rx_mode: CommandRxMode | None = None,
729
+ check_against_pd: bool = False,
730
+ ) -> set[DP_KEY_VALUE]:
731
+ """
732
+ Set paramsets manually.
733
+
734
+ Address is usually the channel_address, but for bidcos devices there is a master paramset at the device.
735
+ Paramset_key can be a str with a channel address in case of manipulating a direct link.
736
+ If paramset_key is string and contains a channel address, then the LINK paramset must be used for a check.
737
+ """
738
+ is_link_call: bool = False
739
+ checked_values = values
740
+ try:
741
+ if check_against_pd:
742
+ check_paramset_key = (
743
+ ParamsetKey(paramset_key_or_link_address)
744
+ if is_paramset_key(paramset_key=paramset_key_or_link_address)
745
+ else ParamsetKey.LINK
746
+ if (is_link_call := is_channel_address(address=paramset_key_or_link_address))
747
+ else None
748
+ )
749
+ if check_paramset_key:
750
+ checked_values = self._check_put_paramset(
751
+ channel_address=channel_address,
752
+ paramset_key=check_paramset_key,
753
+ values=values,
754
+ )
755
+ else:
756
+ raise ClientException(
757
+ "Parameter paramset_key is neither a valid ParamsetKey nor a channel address."
758
+ )
759
+
760
+ _LOGGER.debug("PUT_PARAMSET: %s, %s, %s", channel_address, paramset_key_or_link_address, checked_values)
761
+ if rx_mode and (device := self.central.get_device(address=channel_address)):
762
+ if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
763
+ await self._exec_put_paramset(
764
+ channel_address=channel_address,
765
+ paramset_key=paramset_key_or_link_address,
766
+ values=checked_values,
767
+ rx_mode=rx_mode,
768
+ )
769
+ else:
770
+ raise ClientException(f"Unsupported rx_mode: {rx_mode}")
771
+ else:
772
+ await self._exec_put_paramset(
773
+ channel_address=channel_address,
774
+ paramset_key=paramset_key_or_link_address,
775
+ values=checked_values,
776
+ )
777
+
778
+ # if a call is related to a link then no further action is needed
779
+ if is_link_call:
780
+ return set()
781
+
782
+ # store the send value in the last_value_send_cache
783
+ dpk_values = self._last_value_send_cache.add_put_paramset(
784
+ channel_address=channel_address,
785
+ paramset_key=ParamsetKey(paramset_key_or_link_address),
786
+ values=checked_values,
787
+ )
788
+ self._write_temporary_value(dpk_values=dpk_values)
789
+
790
+ if (
791
+ self.interface in (Interface.BIDCOS_RF, Interface.BIDCOS_WIRED)
792
+ and paramset_key_or_link_address == ParamsetKey.MASTER
793
+ and (channel := self.central.get_channel(channel_address=channel_address)) is not None
794
+ ):
795
+
796
+ async def poll_master_dp_values() -> None:
797
+ """Load master paramset values."""
798
+ if not channel:
799
+ return
800
+ for interval in self.central.config.hm_master_poll_after_send_intervals:
801
+ await asyncio.sleep(interval)
802
+ for dp in channel.get_readable_data_points(
803
+ paramset_key=ParamsetKey(paramset_key_or_link_address)
804
+ ):
805
+ await dp.load_data_point_value(call_source=CallSource.MANUAL_OR_SCHEDULED, direct_call=True)
806
+
807
+ self.central.looper.create_task(target=poll_master_dp_values(), name="poll_master_dp_values")
808
+
809
+ if wait_for_callback is not None and (
810
+ device := self.central.get_device(address=get_device_address(address=channel_address))
811
+ ):
812
+ await _wait_for_state_change_or_timeout(
813
+ device=device,
814
+ dpk_values=dpk_values,
815
+ wait_for_callback=wait_for_callback,
816
+ )
817
+ except BaseHomematicException as bhexc:
818
+ raise ClientException(
819
+ f"PUT_PARAMSET failed for {channel_address}/{paramset_key_or_link_address}/{values}: {extract_exc_args(exc=bhexc)}"
820
+ ) from bhexc
821
+ else:
822
+ return dpk_values
823
+
824
+ async def _exec_put_paramset(
825
+ self,
826
+ channel_address: str,
827
+ paramset_key: ParamsetKey | str,
828
+ values: dict[str, Any],
829
+ rx_mode: CommandRxMode | None = None,
830
+ ) -> None:
831
+ """Put paramset into CCU."""
832
+ if rx_mode:
833
+ await self._proxy.putParamset(channel_address, paramset_key, values, rx_mode)
834
+ else:
835
+ await self._proxy.putParamset(channel_address, paramset_key, values)
836
+
837
+ def _check_put_paramset(
838
+ self, channel_address: str, paramset_key: ParamsetKey, values: dict[str, Any]
839
+ ) -> dict[str, Any]:
840
+ """Check put_paramset."""
841
+ checked_values: dict[str, Any] = {}
842
+ for param, value in values.items():
843
+ checked_values[param] = self._convert_value(
844
+ channel_address=channel_address,
845
+ paramset_key=paramset_key,
846
+ parameter=param,
847
+ value=value,
848
+ operation=Operations.WRITE,
849
+ )
850
+ return checked_values
851
+
852
+ def _convert_value(
853
+ self,
854
+ channel_address: str,
855
+ paramset_key: ParamsetKey,
856
+ parameter: str,
857
+ value: Any,
858
+ operation: Operations,
859
+ ) -> Any:
860
+ """Check a single parameter against paramset descriptions and convert the value."""
861
+ if parameter_data := self.central.paramset_descriptions.get_parameter_data(
862
+ interface_id=self.interface_id,
863
+ channel_address=channel_address,
864
+ paramset_key=paramset_key,
865
+ parameter=parameter,
866
+ ):
867
+ pd_type = parameter_data["TYPE"]
868
+ op_mask = int(operation)
869
+ if (int(parameter_data["OPERATIONS"]) & op_mask) != op_mask:
870
+ raise ClientException(
871
+ f"Parameter {parameter} does not support the requested operation {operation.value}"
872
+ )
873
+ # Only build a tuple if a value list exists
874
+ pd_value_list = tuple(parameter_data["VALUE_LIST"]) if parameter_data.get("VALUE_LIST") else None
875
+ return convert_value(value=value, target_type=pd_type, value_list=pd_value_list)
876
+ raise ClientException(
877
+ f"Parameter {parameter} could not be found: {self.interface_id}/{channel_address}/{paramset_key}"
878
+ )
879
+
880
+ def _get_parameter_type(
881
+ self,
882
+ channel_address: str,
883
+ paramset_key: ParamsetKey,
884
+ parameter: str,
885
+ ) -> ParameterType | None:
886
+ if parameter_data := self.central.paramset_descriptions.get_parameter_data(
887
+ interface_id=self.interface_id,
888
+ channel_address=channel_address,
889
+ paramset_key=paramset_key,
890
+ parameter=parameter,
891
+ ):
892
+ return parameter_data["TYPE"]
893
+ return None
894
+
895
+ @inspector(re_raise=False)
896
+ async def fetch_paramset_description(self, channel_address: str, paramset_key: ParamsetKey) -> None:
897
+ """Fetch a specific paramset and add it to the known ones."""
898
+ _LOGGER.debug("FETCH_PARAMSET_DESCRIPTION: %s for %s", paramset_key, channel_address)
899
+
900
+ if paramset_description := await self._get_paramset_description(
901
+ address=channel_address, paramset_key=paramset_key
902
+ ):
903
+ self.central.paramset_descriptions.add(
904
+ interface_id=self.interface_id,
905
+ channel_address=channel_address,
906
+ paramset_key=paramset_key,
907
+ paramset_description=paramset_description,
908
+ )
909
+
910
+ @inspector(re_raise=False)
911
+ async def fetch_paramset_descriptions(self, device_description: DeviceDescription) -> None:
912
+ """Fetch paramsets for provided device description."""
913
+ data = await self.get_paramset_descriptions(device_description=device_description)
914
+ for address, paramsets in data.items():
915
+ _LOGGER.debug("FETCH_PARAMSET_DESCRIPTIONS for %s", address)
916
+ for paramset_key, paramset_description in paramsets.items():
917
+ self.central.paramset_descriptions.add(
918
+ interface_id=self.interface_id,
919
+ channel_address=address,
920
+ paramset_key=paramset_key,
921
+ paramset_description=paramset_description,
922
+ )
923
+
924
+ @inspector(re_raise=False, no_raise_return={})
925
+ async def get_paramset_descriptions(
926
+ self, device_description: DeviceDescription
927
+ ) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
928
+ """Get paramsets for provided device description."""
929
+ paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
930
+ address = device_description["ADDRESS"]
931
+ paramsets[address] = {}
932
+ _LOGGER.debug("GET_PARAMSET_DESCRIPTIONS for %s", address)
933
+ for p_key in device_description["PARAMSETS"]:
934
+ paramset_key = ParamsetKey(p_key)
935
+ if paramset_description := await self._get_paramset_description(address=address, paramset_key=paramset_key):
936
+ paramsets[address][paramset_key] = paramset_description
937
+ return paramsets
938
+
939
+ async def _get_paramset_description(
940
+ self, address: str, paramset_key: ParamsetKey
941
+ ) -> dict[str, ParameterData] | None:
942
+ """Get paramset description from CCU."""
943
+ try:
944
+ return cast(
945
+ dict[str, ParameterData],
946
+ await self._proxy_read.getParamsetDescription(address, paramset_key),
947
+ )
948
+ except BaseHomematicException as bhexc:
949
+ _LOGGER.debug(
950
+ "GET_PARAMSET_DESCRIPTIONS failed with %s [%s] for %s address %s",
951
+ bhexc.name,
952
+ extract_exc_args(exc=bhexc),
953
+ paramset_key,
954
+ address,
955
+ )
956
+ return None
957
+
958
+ @inspector()
959
+ async def get_all_paramset_descriptions(
960
+ self, device_descriptions: tuple[DeviceDescription, ...]
961
+ ) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
962
+ """Get all paramset descriptions for provided device descriptions."""
963
+ all_paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
964
+ for device_description in device_descriptions:
965
+ all_paramsets.update(await self.get_paramset_descriptions(device_description=device_description))
966
+ return all_paramsets
967
+
968
+ @inspector()
969
+ async def has_program_ids(self, channel_hmid: str) -> bool:
970
+ """Return if a channel has program ids."""
971
+ return False
972
+
973
+ @inspector(re_raise=False, measure_performance=True)
974
+ async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
975
+ """List devices of homematic backend."""
976
+ try:
977
+ return tuple(await self._proxy_read.listDevices())
978
+ except BaseHomematicException as bhexc:
979
+ _LOGGER.debug(
980
+ "LIST_DEVICES failed: %s [%s]",
981
+ bhexc.name,
982
+ extract_exc_args(exc=bhexc),
983
+ )
984
+ return None
985
+
986
+ @inspector()
987
+ async def report_value_usage(self, address: str, value_id: str, ref_counter: int) -> bool:
988
+ """Report value usage."""
989
+ return False
990
+
991
+ @inspector()
992
+ async def update_device_firmware(self, device_address: str) -> bool:
993
+ """Update the firmware of a homematic device."""
994
+ if device := self.central.get_device(address=device_address):
995
+ _LOGGER.info(
996
+ "UPDATE_DEVICE_FIRMWARE: Trying firmware update for %s",
997
+ device_address,
998
+ )
999
+ try:
1000
+ update_result = (
1001
+ await self._proxy.installFirmware(device_address)
1002
+ if device.product_group in (ProductGroup.HMIPW, ProductGroup.HMIP)
1003
+ else await self._proxy.updateFirmware(device_address)
1004
+ )
1005
+ result = bool(update_result) if isinstance(update_result, bool) else bool(update_result[0])
1006
+ _LOGGER.info(
1007
+ "UPDATE_DEVICE_FIRMWARE: Executed firmware update for %s with result '%s'",
1008
+ device_address,
1009
+ "success" if result else "failed",
1010
+ )
1011
+ except BaseHomematicException as bhexc:
1012
+ raise ClientException(f"UPDATE_DEVICE_FIRMWARE failed]: {extract_exc_args(exc=bhexc)}") from bhexc
1013
+ return result
1014
+ return False
1015
+
1016
+ @inspector(re_raise=False)
1017
+ async def update_paramset_descriptions(self, device_address: str) -> None:
1018
+ """Update paramsets descriptions for provided device_address."""
1019
+ if not self.central.device_descriptions.get_device_descriptions(interface_id=self.interface_id):
1020
+ _LOGGER.warning(
1021
+ "UPDATE_PARAMSET_DESCRIPTIONS failed: "
1022
+ "Interface missing in central cache. "
1023
+ "Not updating paramsets for %s",
1024
+ device_address,
1025
+ )
1026
+ return
1027
+
1028
+ if device_description := self.central.device_descriptions.find_device_description(
1029
+ interface_id=self.interface_id, device_address=device_address
1030
+ ):
1031
+ await self.fetch_paramset_descriptions(device_description=device_description)
1032
+ else:
1033
+ _LOGGER.warning(
1034
+ "UPDATE_PARAMSET_DESCRIPTIONS failed: Channel missing in central.cache. Not updating paramsets for %s",
1035
+ device_address,
1036
+ )
1037
+ return
1038
+ await self.central.save_caches(save_paramset_descriptions=True)
1039
+
1040
+ def __str__(self) -> str:
1041
+ """Provide some useful information."""
1042
+ return f"interface_id: {self.interface_id}"
1043
+
1044
+
1045
+ class ClientCCU(Client):
1046
+ """Client implementation for CCU backend."""
1047
+
1048
+ def __init__(self, client_config: _ClientConfig) -> None:
1049
+ """Initialize the Client."""
1050
+ self._json_rpc_client: Final = client_config.central.json_rpc_client
1051
+ super().__init__(client_config=client_config)
1052
+
1053
+ @property
1054
+ def model(self) -> str:
1055
+ """Return the model of the backend."""
1056
+ return Backend.CCU
1057
+
1058
+ @property
1059
+ def supports_ping_pong(self) -> bool:
1060
+ """Return the supports_ping_pong info of the backend."""
1061
+ return True
1062
+
1063
+ @inspector(re_raise=False, measure_performance=True)
1064
+ async def fetch_device_details(self) -> None:
1065
+ """Get all names via JSON-RPS and store in data.NAMES."""
1066
+ if json_result := await self._json_rpc_client.get_device_details():
1067
+ for device in json_result:
1068
+ # ignore unknown interfaces
1069
+ if (interface := device[_JSON_INTERFACE]) and interface not in Interface:
1070
+ continue
1071
+
1072
+ device_address = device[_JSON_ADDRESS]
1073
+ self.central.device_details.add_interface(address=device_address, interface=Interface(interface))
1074
+ self.central.device_details.add_name(address=device_address, name=device[_JSON_NAME])
1075
+ self.central.device_details.add_address_id(address=device_address, hmid=device[_JSON_ID])
1076
+ for channel in device.get(_JSON_CHANNELS, []):
1077
+ channel_address = channel[_JSON_ADDRESS]
1078
+ self.central.device_details.add_name(address=channel_address, name=channel[_JSON_NAME])
1079
+ self.central.device_details.add_address_id(address=channel_address, hmid=channel[_JSON_ID])
1080
+ else:
1081
+ _LOGGER.debug("FETCH_DEVICE_DETAILS: Unable to fetch device details via JSON-RPC")
1082
+
1083
+ @inspector(re_raise=False, measure_performance=True)
1084
+ async def fetch_all_device_data(self) -> None:
1085
+ """Fetch all device data from CCU."""
1086
+ try:
1087
+ if all_device_data := await self._json_rpc_client.get_all_device_data(interface=self.interface):
1088
+ _LOGGER.debug(
1089
+ "FETCH_ALL_DEVICE_DATA: Fetched all device data for interface %s",
1090
+ self.interface,
1091
+ )
1092
+ self.central.data_cache.add_data(interface=self.interface, all_device_data=all_device_data)
1093
+ return
1094
+ except ClientException:
1095
+ self.central.fire_interface_event(
1096
+ interface_id=self.interface_id,
1097
+ interface_event_type=InterfaceEventType.FETCH_DATA,
1098
+ data={EventKey.AVAILABLE: False},
1099
+ )
1100
+ raise
1101
+
1102
+ _LOGGER.debug(
1103
+ "FETCH_ALL_DEVICE_DATA: Unable to get all device data via JSON-RPC RegaScript for interface %s",
1104
+ self.interface,
1105
+ )
1106
+
1107
+ @inspector(re_raise=False, no_raise_return=False)
1108
+ async def check_connection_availability(self, handle_ping_pong: bool) -> bool:
1109
+ """Check if _proxy is still initialized."""
1110
+ try:
1111
+ dt_now = datetime.now()
1112
+ if handle_ping_pong and self.supports_ping_pong and self._is_initialized:
1113
+ callerId = (
1114
+ f"{self.interface_id}#{dt_now.strftime(format=DATETIME_FORMAT_MILLIS)}"
1115
+ if handle_ping_pong
1116
+ else self.interface_id
1117
+ )
1118
+ self._ping_pong_cache.handle_send_ping(ping_ts=dt_now)
1119
+ await self._proxy.ping(callerId)
1120
+ elif not self._is_initialized:
1121
+ await self._proxy.ping(self.interface_id)
1122
+ self.modified_at = dt_now
1123
+ except BaseHomematicException as bhexc:
1124
+ _LOGGER.debug(
1125
+ "CHECK_CONNECTION_AVAILABILITY failed: %s [%s]",
1126
+ bhexc.name,
1127
+ extract_exc_args(exc=bhexc),
1128
+ )
1129
+ else:
1130
+ return True
1131
+ self.modified_at = INIT_DATETIME
1132
+ return False
1133
+
1134
+ @inspector()
1135
+ async def execute_program(self, pid: str) -> bool:
1136
+ """Execute a program on CCU."""
1137
+ return await self._json_rpc_client.execute_program(pid=pid)
1138
+
1139
+ @inspector()
1140
+ async def set_program_state(self, pid: str, state: bool) -> bool:
1141
+ """Set the program state on CCU."""
1142
+ return await self._json_rpc_client.set_program_state(pid=pid, state=state)
1143
+
1144
+ @inspector()
1145
+ async def has_program_ids(self, channel_hmid: str) -> bool:
1146
+ """Return if a channel has program ids."""
1147
+ return await self._json_rpc_client.has_program_ids(channel_hmid=channel_hmid)
1148
+
1149
+ @inspector()
1150
+ async def report_value_usage(self, address: str, value_id: str, ref_counter: int) -> bool:
1151
+ """Report value usage."""
1152
+ try:
1153
+ return bool(await self._proxy.reportValueUsage(address, value_id, ref_counter))
1154
+ except BaseHomematicException as bhexc:
1155
+ raise ClientException(
1156
+ f"REPORT_VALUE_USAGE failed with: {address}/{value_id}/{ref_counter}: {extract_exc_args(exc=bhexc)}"
1157
+ ) from bhexc
1158
+
1159
+ @inspector(measure_performance=True)
1160
+ async def set_system_variable(self, legacy_name: str, value: Any) -> bool:
1161
+ """Set a system variable on CCU / Homegear."""
1162
+ return await self._json_rpc_client.set_system_variable(legacy_name=legacy_name, value=value)
1163
+
1164
+ @inspector()
1165
+ async def delete_system_variable(self, name: str) -> bool:
1166
+ """Delete a system variable from CCU / Homegear."""
1167
+ return await self._json_rpc_client.delete_system_variable(name=name)
1168
+
1169
+ @inspector()
1170
+ async def get_system_variable(self, name: str) -> Any:
1171
+ """Get single system variable from CCU / Homegear."""
1172
+ return await self._json_rpc_client.get_system_variable(name=name)
1173
+
1174
+ @inspector(re_raise=False)
1175
+ async def get_all_system_variables(
1176
+ self, markers: tuple[DescriptionMarker | str, ...]
1177
+ ) -> tuple[SystemVariableData, ...] | None:
1178
+ """Get all system variables from CCU."""
1179
+ return await self._json_rpc_client.get_all_system_variables(markers=markers)
1180
+
1181
+ @inspector(re_raise=False)
1182
+ async def get_all_programs(self, markers: tuple[DescriptionMarker | str, ...]) -> tuple[ProgramData, ...]:
1183
+ """Get all programs, if available."""
1184
+ return await self._json_rpc_client.get_all_programs(markers=markers)
1185
+
1186
+ @inspector(re_raise=False, no_raise_return={})
1187
+ async def get_all_rooms(self) -> dict[str, set[str]]:
1188
+ """Get all rooms from CCU."""
1189
+ rooms: dict[str, set[str]] = {}
1190
+ channel_ids_room = await self._json_rpc_client.get_all_channel_ids_room()
1191
+ for address, channel_id in self.central.device_details.device_channel_ids.items():
1192
+ if names := channel_ids_room.get(channel_id):
1193
+ if address not in rooms:
1194
+ rooms[address] = set()
1195
+ rooms[address].update(names)
1196
+ return rooms
1197
+
1198
+ @inspector(re_raise=False, no_raise_return={})
1199
+ async def get_all_functions(self) -> dict[str, set[str]]:
1200
+ """Get all functions from CCU."""
1201
+ functions: dict[str, set[str]] = {}
1202
+ channel_ids_function = await self._json_rpc_client.get_all_channel_ids_function()
1203
+ for address, channel_id in self.central.device_details.device_channel_ids.items():
1204
+ if sections := channel_ids_function.get(channel_id):
1205
+ if address not in functions:
1206
+ functions[address] = set()
1207
+ functions[address].update(sections)
1208
+ return functions
1209
+
1210
+ async def _get_system_information(self) -> SystemInformation:
1211
+ """Get system information of the backend."""
1212
+ return await self._json_rpc_client.get_system_information()
1213
+
1214
+
1215
+ class ClientJsonCCU(ClientCCU):
1216
+ """Client implementation for CCU backend."""
1217
+
1218
+ async def init_client(self) -> None:
1219
+ """Init the client."""
1220
+ self._system_information = await self._get_system_information()
1221
+
1222
+ @inspector(re_raise=False, no_raise_return=False)
1223
+ async def check_connection_availability(self, handle_ping_pong: bool) -> bool:
1224
+ """Check if proxy is still initialized."""
1225
+ return await self._json_rpc_client.is_present(interface=self.interface)
1226
+
1227
+ @property
1228
+ def supports_ping_pong(self) -> bool:
1229
+ """Return the supports_ping_pong info of the backend."""
1230
+ return False
1231
+
1232
+ @inspector(re_raise=False)
1233
+ async def get_device_description(self, device_address: str) -> DeviceDescription | None:
1234
+ """Get device descriptions from CCU / Homegear."""
1235
+ try:
1236
+ if device_description := await self._json_rpc_client.get_device_description(
1237
+ interface=self.interface, address=device_address
1238
+ ):
1239
+ return device_description
1240
+ except BaseHomematicException as bhexc:
1241
+ _LOGGER.warning("GET_DEVICE_DESCRIPTIONS failed: %s [%s]", bhexc.name, extract_exc_args(exc=bhexc))
1242
+ return None
1243
+
1244
+ @inspector()
1245
+ async def get_paramset(
1246
+ self,
1247
+ address: str,
1248
+ paramset_key: ParamsetKey | str,
1249
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
1250
+ ) -> dict[str, Any]:
1251
+ """
1252
+ Return a paramset from CCU.
1253
+
1254
+ Address is usually the channel_address,
1255
+ but for bidcos devices there is a master paramset at the device.
1256
+ """
1257
+ try:
1258
+ _LOGGER.debug(
1259
+ "GET_PARAMSET: address %s, paramset_key %s, source %s",
1260
+ address,
1261
+ paramset_key,
1262
+ call_source,
1263
+ )
1264
+ return (
1265
+ await self._json_rpc_client.get_paramset(
1266
+ interface=self.interface, address=address, paramset_key=paramset_key
1267
+ )
1268
+ or {}
1269
+ )
1270
+ except BaseHomematicException as bhexc:
1271
+ raise ClientException(
1272
+ f"GET_PARAMSET failed with for {address}/{paramset_key}: {extract_exc_args(exc=bhexc)}"
1273
+ ) from bhexc
1274
+
1275
+ @inspector(log_level=logging.NOTSET)
1276
+ async def get_value(
1277
+ self,
1278
+ channel_address: str,
1279
+ paramset_key: ParamsetKey,
1280
+ parameter: str,
1281
+ call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
1282
+ ) -> Any:
1283
+ """Return a value from CCU."""
1284
+ try:
1285
+ _LOGGER.debug(
1286
+ "GET_VALUE: channel_address %s, parameter %s, paramset_key, %s, source:%s",
1287
+ channel_address,
1288
+ parameter,
1289
+ paramset_key,
1290
+ call_source,
1291
+ )
1292
+ if paramset_key == ParamsetKey.VALUES:
1293
+ return await self._json_rpc_client.get_value(
1294
+ interface=self.interface,
1295
+ address=channel_address,
1296
+ paramset_key=paramset_key,
1297
+ parameter=parameter,
1298
+ )
1299
+ paramset = (
1300
+ await self._json_rpc_client.get_paramset(
1301
+ interface=self.interface,
1302
+ address=channel_address,
1303
+ paramset_key=ParamsetKey.MASTER,
1304
+ )
1305
+ or {}
1306
+ )
1307
+ return paramset.get(parameter)
1308
+ except BaseHomematicException as bhexc:
1309
+ raise ClientException(
1310
+ f"GET_VALUE failed with for: {channel_address}/{parameter}/{paramset_key}: {extract_exc_args(exc=bhexc)}"
1311
+ ) from bhexc
1312
+
1313
+ @inspector(re_raise=False, measure_performance=True)
1314
+ async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
1315
+ """List devices of homematic backend."""
1316
+ try:
1317
+ return await self._json_rpc_client.list_devices(interface=self.interface)
1318
+ except BaseHomematicException as bhexc:
1319
+ _LOGGER.debug(
1320
+ "LIST_DEVICES failed with %s [%s]",
1321
+ bhexc.name,
1322
+ extract_exc_args(exc=bhexc),
1323
+ )
1324
+ return None
1325
+
1326
+ async def _get_paramset_description(
1327
+ self, address: str, paramset_key: ParamsetKey
1328
+ ) -> dict[str, ParameterData] | None:
1329
+ """Get paramset description from CCU."""
1330
+ try:
1331
+ return cast(
1332
+ dict[str, ParameterData],
1333
+ await self._json_rpc_client.get_paramset_description(
1334
+ interface=self.interface, address=address, paramset_key=paramset_key
1335
+ ),
1336
+ )
1337
+ except BaseHomematicException as bhexc:
1338
+ _LOGGER.debug(
1339
+ "GET_PARAMSET_DESCRIPTIONS failed with %s [%s] for %s address %s",
1340
+ bhexc.name,
1341
+ extract_exc_args(exc=bhexc),
1342
+ paramset_key,
1343
+ address,
1344
+ )
1345
+ return None
1346
+
1347
+ async def _exec_put_paramset(
1348
+ self,
1349
+ channel_address: str,
1350
+ paramset_key: ParamsetKey | str,
1351
+ values: dict[str, Any],
1352
+ rx_mode: CommandRxMode | None = None,
1353
+ ) -> None:
1354
+ """Put paramset into CCU."""
1355
+ # _values: list[dict[str, Any]] = []
1356
+ for parameter, value in values.items():
1357
+ await self._exec_set_value(
1358
+ channel_address=channel_address, parameter=parameter, value=value, rx_mode=rx_mode
1359
+ )
1360
+
1361
+ # Funktioniert nicht
1362
+ # if (
1363
+ # value_type := self._get_parameter_type(
1364
+ # channel_address=channel_address,
1365
+ # paramset_key=ParamsetKey.VALUES,
1366
+ # parameter=parameter,
1367
+ # )
1368
+ # ) is None:
1369
+ # raise ClientException(
1370
+ # f"PUT_PARAMSET failed: Unable to identify parameter type {channel_address}/{paramset_key}/{parameter}"
1371
+ # )
1372
+ # _type = _CCU_JSON_VALUE_TYPE.get(value_type, "string")
1373
+ #
1374
+ # _values.append({"name": parameter, "type":_type, "value": str(value)})
1375
+ #
1376
+ # await self._json_rpc_client.put_paramset(
1377
+ # interface=self.interface,
1378
+ # address=channel_address,
1379
+ # paramset_key=paramset_key,
1380
+ # values=_values,
1381
+ # )
1382
+
1383
+ async def _exec_set_value(
1384
+ self,
1385
+ channel_address: str,
1386
+ parameter: str,
1387
+ value: Any,
1388
+ rx_mode: CommandRxMode | None = None,
1389
+ ) -> None:
1390
+ """Set single value on paramset VALUES."""
1391
+ if (
1392
+ value_type := self._get_parameter_type(
1393
+ channel_address=channel_address,
1394
+ paramset_key=ParamsetKey.VALUES,
1395
+ parameter=parameter,
1396
+ )
1397
+ ) is None:
1398
+ raise ClientException(
1399
+ f"SET_VALUE failed: Unable to identify parameter type {channel_address}/{ParamsetKey.VALUES}/{parameter}"
1400
+ )
1401
+
1402
+ _type = _CCU_JSON_VALUE_TYPE.get(value_type, "string")
1403
+ await self._json_rpc_client.set_value(
1404
+ interface=self.interface,
1405
+ address=channel_address,
1406
+ parameter=parameter,
1407
+ value_type=_type,
1408
+ value=value,
1409
+ )
1410
+
1411
+ async def _get_system_information(self) -> SystemInformation:
1412
+ """Get system information of the backend."""
1413
+ return SystemInformation(
1414
+ available_interfaces=(self.interface,),
1415
+ serial=f"{self.interface}_{DUMMY_SERIAL}",
1416
+ )
1417
+
1418
+
1419
+ class ClientHomegear(Client):
1420
+ """Client implementation for Homegear backend."""
1421
+
1422
+ @property
1423
+ def model(self) -> str:
1424
+ """Return the model of the backend."""
1425
+ if self._config.version:
1426
+ return Backend.PYDEVCCU if Backend.PYDEVCCU.lower() in self._config.version else Backend.HOMEGEAR
1427
+ return Backend.CCU
1428
+
1429
+ @property
1430
+ def supports_ping_pong(self) -> bool:
1431
+ """Return the supports_ping_pong info of the backend."""
1432
+ return False
1433
+
1434
+ @inspector(re_raise=False)
1435
+ async def fetch_all_device_data(self) -> None:
1436
+ """Fetch all device data from CCU."""
1437
+ return
1438
+
1439
+ @inspector(re_raise=False, measure_performance=True)
1440
+ async def fetch_device_details(self) -> None:
1441
+ """Get all names from metadata (Homegear)."""
1442
+ _LOGGER.debug("FETCH_DEVICE_DETAILS: Fetching names via Metadata")
1443
+ for address in self.central.device_descriptions.get_device_descriptions(interface_id=self.interface_id):
1444
+ try:
1445
+ self.central.device_details.add_name(
1446
+ address=address,
1447
+ name=await self._proxy_read.getMetadata(address, _NAME),
1448
+ )
1449
+ except BaseHomematicException as bhexc:
1450
+ _LOGGER.warning(
1451
+ "%s [%s] Failed to fetch name for device %s",
1452
+ bhexc.name,
1453
+ extract_exc_args(exc=bhexc),
1454
+ address,
1455
+ )
1456
+
1457
+ @inspector(re_raise=False, no_raise_return=False)
1458
+ async def check_connection_availability(self, handle_ping_pong: bool) -> bool:
1459
+ """Check if proxy is still initialized."""
1460
+ try:
1461
+ await self._proxy.clientServerInitialized(self.interface_id)
1462
+ self.modified_at = datetime.now()
1463
+ except BaseHomematicException as bhexc:
1464
+ _LOGGER.debug(
1465
+ "CHECK_CONNECTION_AVAILABILITY failed: %s [%s]",
1466
+ bhexc.name,
1467
+ extract_exc_args(exc=bhexc),
1468
+ )
1469
+ else:
1470
+ return True
1471
+ self.modified_at = INIT_DATETIME
1472
+ return False
1473
+
1474
+ @inspector()
1475
+ async def execute_program(self, pid: str) -> bool:
1476
+ """Execute a program on Homegear."""
1477
+ return True
1478
+
1479
+ @inspector()
1480
+ async def set_program_state(self, pid: str, state: bool) -> bool:
1481
+ """Set the program state on Homegear."""
1482
+ return True
1483
+
1484
+ @inspector(measure_performance=True)
1485
+ async def set_system_variable(self, legacy_name: str, value: Any) -> bool:
1486
+ """Set a system variable on CCU / Homegear."""
1487
+ await self._proxy.setSystemVariable(legacy_name, value)
1488
+ return True
1489
+
1490
+ @inspector()
1491
+ async def delete_system_variable(self, name: str) -> bool:
1492
+ """Delete a system variable from CCU / Homegear."""
1493
+ await self._proxy.deleteSystemVariable(name)
1494
+ return True
1495
+
1496
+ @inspector()
1497
+ async def get_system_variable(self, name: str) -> Any:
1498
+ """Get single system variable from CCU / Homegear."""
1499
+ return await self._proxy.getSystemVariable(name)
1500
+
1501
+ @inspector(re_raise=False)
1502
+ async def get_all_system_variables(
1503
+ self, markers: tuple[DescriptionMarker | str, ...]
1504
+ ) -> tuple[SystemVariableData, ...] | None:
1505
+ """Get all system variables from Homegear."""
1506
+ variables: list[SystemVariableData] = []
1507
+ if hg_variables := await self._proxy.getAllSystemVariables():
1508
+ for name, value in hg_variables.items():
1509
+ variables.append(SystemVariableData(vid=name, legacy_name=name, value=value))
1510
+ return tuple(variables)
1511
+
1512
+ @inspector(re_raise=False)
1513
+ async def get_all_programs(self, markers: tuple[DescriptionMarker | str, ...]) -> tuple[ProgramData, ...] | None:
1514
+ """Get all programs, if available."""
1515
+ return ()
1516
+
1517
+ @inspector(re_raise=False, no_raise_return={})
1518
+ async def get_all_rooms(self) -> dict[str, set[str]]:
1519
+ """Get all rooms from Homegear."""
1520
+ return {}
1521
+
1522
+ @inspector(re_raise=False, no_raise_return={})
1523
+ async def get_all_functions(self) -> dict[str, set[str]]:
1524
+ """Get all functions from Homegear."""
1525
+ return {}
1526
+
1527
+ async def _get_system_information(self) -> SystemInformation:
1528
+ """Get system information of the backend."""
1529
+ return SystemInformation(available_interfaces=(Interface.BIDCOS_RF,), serial=f"{self.interface}_{DUMMY_SERIAL}")
1530
+
1531
+
1532
+ class _ClientConfig:
1533
+ """Config for a Client."""
1534
+
1535
+ def __init__(
1536
+ self,
1537
+ central: hmcu.CentralUnit,
1538
+ interface_config: InterfaceConfig,
1539
+ ) -> None:
1540
+ self.central: Final = central
1541
+ self.version: str = "0"
1542
+ self.system_information = SystemInformation()
1543
+ self.interface_config: Final = interface_config
1544
+ self.interface: Final = interface_config.interface
1545
+ self.interface_id: Final = interface_config.interface_id
1546
+ self.max_read_workers: Final[int] = central.config.max_read_workers
1547
+ self.has_credentials: Final[bool] = central.config.username is not None and central.config.password is not None
1548
+ self.init_url: Final[str] = f"http://{
1549
+ central.config.callback_host if central.config.callback_host else central.callback_ip_addr
1550
+ }:{central.config.callback_port if central.config.callback_port else central.listen_port}"
1551
+ self.xml_rpc_uri: Final = build_xml_rpc_uri(
1552
+ host=central.config.host,
1553
+ port=interface_config.port,
1554
+ path=interface_config.remote_path,
1555
+ tls=central.config.tls,
1556
+ )
1557
+
1558
+ async def create_client(self) -> Client:
1559
+ """Identify the used client."""
1560
+ try:
1561
+ self.version = await self._get_version()
1562
+ client: Client | None
1563
+ if self.interface == Interface.BIDCOS_RF and ("Homegear" in self.version or "pydevccu" in self.version):
1564
+ client = ClientHomegear(client_config=self)
1565
+ elif self.interface in (Interface.CCU_JACK, Interface.CUXD):
1566
+ client = ClientJsonCCU(client_config=self)
1567
+ else:
1568
+ client = ClientCCU(client_config=self)
1569
+
1570
+ if client:
1571
+ await client.init_client()
1572
+ if await client.check_connection_availability(handle_ping_pong=False):
1573
+ return client
1574
+ raise NoConnectionException(f"No connection to {self.interface_id}")
1575
+ except BaseHomematicException:
1576
+ raise
1577
+ except Exception as exc:
1578
+ raise NoConnectionException(f"Unable to connect {extract_exc_args(exc=exc)}.") from exc
1579
+
1580
+ async def _get_version(self) -> str:
1581
+ """Return the version of the backend."""
1582
+ if self.interface in (Interface.CCU_JACK, Interface.CUXD):
1583
+ return "0"
1584
+ check_proxy = await self._create_simple_xml_rpc_proxy()
1585
+ try:
1586
+ if (methods := check_proxy.supported_methods) and "getVersion" in methods:
1587
+ # BidCos-Wired does not support getVersion()
1588
+ return cast(str, await check_proxy.getVersion())
1589
+ except Exception as exc:
1590
+ raise NoConnectionException(f"Unable to connect {extract_exc_args(exc=exc)}.") from exc
1591
+ return "0"
1592
+
1593
+ async def create_xml_rpc_proxy(
1594
+ self, auth_enabled: bool | None = None, max_workers: int = DEFAULT_MAX_WORKERS
1595
+ ) -> XmlRpcProxy:
1596
+ """Return a XmlRPC proxy for backend communication."""
1597
+ config = self.central.config
1598
+ xml_rpc_headers = (
1599
+ build_xml_rpc_headers(
1600
+ username=config.username,
1601
+ password=config.password,
1602
+ )
1603
+ if auth_enabled
1604
+ else []
1605
+ )
1606
+ xml_proxy = XmlRpcProxy(
1607
+ max_workers=max_workers,
1608
+ interface_id=self.interface_id,
1609
+ connection_state=self.central.connection_state,
1610
+ uri=self.xml_rpc_uri,
1611
+ headers=xml_rpc_headers,
1612
+ tls=config.tls,
1613
+ verify_tls=config.verify_tls,
1614
+ )
1615
+ await xml_proxy.do_init()
1616
+ return xml_proxy
1617
+
1618
+ async def _create_simple_xml_rpc_proxy(self) -> XmlRpcProxy:
1619
+ """Return a XmlRPC proxy for backend communication."""
1620
+ return await self.create_xml_rpc_proxy(auth_enabled=True, max_workers=0)
1621
+
1622
+
1623
+ class InterfaceConfig:
1624
+ """interface config for a Client."""
1625
+
1626
+ def __init__(
1627
+ self,
1628
+ central_name: str,
1629
+ interface: Interface,
1630
+ port: int | None = None,
1631
+ remote_path: str | None = None,
1632
+ ) -> None:
1633
+ """Init the interface config."""
1634
+ self.interface: Final[Interface] = interface
1635
+ self.interface_id: Final[str] = f"{central_name}-{self.interface}"
1636
+ self.port: Final = port
1637
+ self.remote_path: Final = remote_path
1638
+ self._init_validate()
1639
+ self._enabled: bool = True
1640
+
1641
+ def _init_validate(self) -> None:
1642
+ """Validate the client_config."""
1643
+ if not self.port and self.interface in INTERFACES_SUPPORTING_XML_RPC:
1644
+ raise ClientException(f"VALIDATE interface config failed: Port must defined for interface{self.interface}")
1645
+
1646
+ @property
1647
+ def enabled(self) -> bool:
1648
+ """Return if the interface config is enabled."""
1649
+ return self._enabled
1650
+
1651
+ def disable(self) -> None:
1652
+ """Disable the interface config."""
1653
+ self._enabled = False
1654
+
1655
+
1656
+ async def create_client(
1657
+ central: hmcu.CentralUnit,
1658
+ interface_config: InterfaceConfig,
1659
+ ) -> Client:
1660
+ """Return a new client for with a given interface_config."""
1661
+ return await _ClientConfig(central=central, interface_config=interface_config).create_client()
1662
+
1663
+
1664
+ def get_client(interface_id: str) -> Client | None:
1665
+ """Return client by interface_id."""
1666
+ for central in hmcu.CENTRAL_INSTANCES.values():
1667
+ if central.has_client(interface_id=interface_id):
1668
+ return central.get_client(interface_id=interface_id)
1669
+ return None
1670
+
1671
+
1672
+ @measure_execution_time
1673
+ async def _wait_for_state_change_or_timeout(
1674
+ device: Device,
1675
+ dpk_values: set[DP_KEY_VALUE],
1676
+ wait_for_callback: int,
1677
+ ) -> None:
1678
+ """Wait for a data_point to change state."""
1679
+ waits = [
1680
+ _track_single_data_point_state_change_or_timeout(
1681
+ device=device,
1682
+ dpk_value=dpk_value,
1683
+ wait_for_callback=wait_for_callback,
1684
+ )
1685
+ for dpk_value in dpk_values
1686
+ ]
1687
+ await asyncio.gather(*waits)
1688
+
1689
+
1690
+ @measure_execution_time
1691
+ async def _track_single_data_point_state_change_or_timeout(
1692
+ device: Device, dpk_value: DP_KEY_VALUE, wait_for_callback: int
1693
+ ) -> None:
1694
+ """Wait for a data_point to change state."""
1695
+ ev = asyncio.Event()
1696
+ dpk, value = dpk_value
1697
+
1698
+ def _async_event_changed(*args: Any, **kwargs: Any) -> None:
1699
+ if dp:
1700
+ _LOGGER.debug(
1701
+ "TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Received event %s with value %s",
1702
+ dpk,
1703
+ dp.value,
1704
+ )
1705
+ if _isclose(value, dp.value):
1706
+ _LOGGER.debug(
1707
+ "TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Finished event %s with value %s",
1708
+ dpk,
1709
+ dp.value,
1710
+ )
1711
+ ev.set()
1712
+
1713
+ if dp := device.get_generic_data_point(
1714
+ channel_address=dpk.channel_address,
1715
+ parameter=dpk.parameter,
1716
+ paramset_key=ParamsetKey(dpk.paramset_key),
1717
+ ):
1718
+ if not dp.supports_events:
1719
+ _LOGGER.debug(
1720
+ "TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: DataPoint supports no events %s",
1721
+ dpk,
1722
+ )
1723
+ return
1724
+ if (
1725
+ unsub := dp.register_data_point_updated_callback(cb=_async_event_changed, custom_id=DEFAULT_CUSTOM_ID)
1726
+ ) is None:
1727
+ return
1728
+
1729
+ try:
1730
+ async with asyncio.timeout(wait_for_callback):
1731
+ await ev.wait()
1732
+ except TimeoutError:
1733
+ _LOGGER.debug(
1734
+ "TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Timeout waiting for event %s with value %s",
1735
+ dpk,
1736
+ dp.value,
1737
+ )
1738
+ finally:
1739
+ unsub()
1740
+
1741
+
1742
+ def _isclose(value1: Any, value2: Any) -> bool:
1743
+ """Check if the both values are close to each other."""
1744
+ if isinstance(value1, float):
1745
+ return bool(round(value1, 2) == round(value2, 2))
1746
+ return bool(value1 == value2)