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