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,1445 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025
3
+ """
4
+ Device and channel model for AioHomematic.
5
+
6
+ This module implements the runtime representation of a Homematic device and its
7
+ channels, including creation and lookup of data points/events, firmware and
8
+ availability handling, link management, value caching, and exporting of device
9
+ definitions for diagnostics.
10
+
11
+ Key classes:
12
+ - Device: Encapsulates metadata, channels, and operations for a single device.
13
+ - Channel: Represents a functional channel with its data points and events.
14
+
15
+ Other components:
16
+ - _ValueCache: Lazy loading and caching of parameter values to minimize RPCs.
17
+ - _DefinitionExporter: Utility to export device and paramset descriptions.
18
+
19
+ The Device/Channel classes are the anchor used by generic, custom, calculated,
20
+ and hub model code to attach data points and events.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ from collections.abc import Callable, Mapping
27
+ from datetime import datetime
28
+ from functools import partial
29
+ import logging
30
+ import os
31
+ import random
32
+ from typing import Any, Final
33
+
34
+ import orjson
35
+
36
+ from aiohomematic import central as hmcu, client as hmcl
37
+ from aiohomematic.async_support import loop_check
38
+ from aiohomematic.const import (
39
+ ADDRESS_SEPARATOR,
40
+ CALLBACK_TYPE,
41
+ CLICK_EVENTS,
42
+ DEVICE_DESCRIPTIONS_DIR,
43
+ IDENTIFIER_SEPARATOR,
44
+ INIT_DATETIME,
45
+ NO_CACHE_ENTRY,
46
+ PARAMSET_DESCRIPTIONS_DIR,
47
+ RELEVANT_INIT_PARAMETERS,
48
+ REPORT_VALUE_USAGE_DATA,
49
+ REPORT_VALUE_USAGE_VALUE_ID,
50
+ VIRTUAL_REMOTE_MODELS,
51
+ CallSource,
52
+ DataOperationResult,
53
+ DataPointCategory,
54
+ DataPointKey,
55
+ DataPointUsage,
56
+ DeviceDescription,
57
+ DeviceFirmwareState,
58
+ EventType,
59
+ ForcedDeviceAvailability,
60
+ Interface,
61
+ Manufacturer,
62
+ Parameter,
63
+ ParameterData,
64
+ ParamsetKey,
65
+ ProductGroup,
66
+ RxMode,
67
+ check_ignore_model_on_initial_load,
68
+ get_link_source_categories,
69
+ get_link_target_categories,
70
+ )
71
+ from aiohomematic.decorators import inspector
72
+ from aiohomematic.exceptions import AioHomematicException, BaseHomematicException
73
+ from aiohomematic.model.calculated import CalculatedDataPoint
74
+ from aiohomematic.model.custom import data_point as hmce, definition as hmed
75
+ from aiohomematic.model.data_point import BaseParameterDataPoint, CallbackDataPoint
76
+ from aiohomematic.model.event import GenericEvent
77
+ from aiohomematic.model.generic import GenericDataPoint
78
+ from aiohomematic.model.support import (
79
+ ChannelNameData,
80
+ generate_channel_unique_id,
81
+ get_channel_name_data,
82
+ get_device_name,
83
+ )
84
+ from aiohomematic.model.update import DpUpdate
85
+ from aiohomematic.property_decorators import hm_property, info_property, state_property
86
+ from aiohomematic.support import (
87
+ CacheEntry,
88
+ LogContextMixin,
89
+ PayloadMixin,
90
+ check_or_create_directory,
91
+ extract_exc_args,
92
+ get_channel_address,
93
+ get_channel_no,
94
+ get_rx_modes,
95
+ )
96
+
97
+ __all__ = ["Channel", "Device"]
98
+
99
+ _LOGGER: Final = logging.getLogger(__name__)
100
+
101
+
102
+ class Device(LogContextMixin, PayloadMixin):
103
+ """Object to hold information about a device and associated data points."""
104
+
105
+ __slots__ = (
106
+ "_address",
107
+ "_cached_relevant_for_central_link_management",
108
+ "_central",
109
+ "_channel_group",
110
+ "_channels",
111
+ "_client",
112
+ "_description",
113
+ "_device_updated_callbacks",
114
+ "_firmware_update_callbacks",
115
+ "_forced_availability",
116
+ "_group_channels",
117
+ "_has_custom_data_point_definition",
118
+ "_id",
119
+ "_ignore_for_custom_data_point",
120
+ "_ignore_on_initial_load",
121
+ "_interface",
122
+ "_interface_id",
123
+ "_is_updatable",
124
+ "_manufacturer",
125
+ "_model",
126
+ "_modified_at",
127
+ "_name",
128
+ "_product_group",
129
+ "_rooms",
130
+ "_rx_modes",
131
+ "_sub_model",
132
+ "_update_data_point",
133
+ "_value_cache",
134
+ )
135
+
136
+ def __init__(self, *, central: hmcu.CentralUnit, interface_id: str, device_address: str) -> None:
137
+ """Initialize the device object."""
138
+ PayloadMixin.__init__(self)
139
+ self._central: Final = central
140
+ self._interface_id: Final = interface_id
141
+ self._address: Final = device_address
142
+ self._channel_group: Final[dict[int | None, int]] = {}
143
+ self._group_channels: Final[dict[int, set[int | None]]] = {}
144
+ self._id: Final = self._central.device_details.get_address_id(address=device_address)
145
+ self._interface: Final = central.device_details.get_interface(address=device_address)
146
+ self._client: Final = central.get_client(interface_id=interface_id)
147
+ self._description = self._central.device_descriptions.get_device_description(
148
+ interface_id=interface_id, address=device_address
149
+ )
150
+ _LOGGER.debug(
151
+ "__INIT__: Initializing device: %s, %s",
152
+ interface_id,
153
+ device_address,
154
+ )
155
+
156
+ self._modified_at: datetime = INIT_DATETIME
157
+ self._forced_availability: ForcedDeviceAvailability = ForcedDeviceAvailability.NOT_SET
158
+ self._device_updated_callbacks: Final[list[Callable]] = []
159
+ self._firmware_update_callbacks: Final[list[Callable]] = []
160
+ self._model: Final[str] = self._description["TYPE"]
161
+ self._ignore_on_initial_load: Final[bool] = check_ignore_model_on_initial_load(model=self._model)
162
+ self._is_updatable: Final = self._description.get("UPDATABLE") or False
163
+ self._rx_modes: Final = get_rx_modes(mode=self._description.get("RX_MODE", 0))
164
+ self._sub_model: Final[str | None] = self._description.get("SUBTYPE")
165
+ self._ignore_for_custom_data_point: Final[bool] = central.parameter_visibility.model_is_ignored(
166
+ model=self._model
167
+ )
168
+ self._manufacturer = self._identify_manufacturer()
169
+ self._product_group: Final = self._client.get_product_group(model=self._model)
170
+ # marker if device will be created as custom data_point
171
+ self._has_custom_data_point_definition: Final = (
172
+ hmed.data_point_definition_exists(model=self._model) and not self._ignore_for_custom_data_point
173
+ )
174
+ self._name: Final = get_device_name(
175
+ central=central,
176
+ device_address=device_address,
177
+ model=self._model,
178
+ )
179
+ channel_addresses = tuple(
180
+ [device_address] + [address for address in self._description["CHILDREN"] if address != ""]
181
+ )
182
+ self._channels: Final[dict[str, Channel]] = {
183
+ address: Channel(device=self, channel_address=address) for address in channel_addresses
184
+ }
185
+ self._value_cache: Final[_ValueCache] = _ValueCache(device=self)
186
+ self._rooms: Final = central.device_details.get_device_rooms(device_address=device_address)
187
+ self._update_data_point: Final = DpUpdate(device=self) if self.is_updatable else None
188
+ _LOGGER.debug(
189
+ "__INIT__: Initialized device: %s, %s, %s, %s",
190
+ self._interface_id,
191
+ self._address,
192
+ self._model,
193
+ self._name,
194
+ )
195
+
196
+ def _identify_manufacturer(self) -> Manufacturer:
197
+ """Identify the manufacturer of a device."""
198
+ if self._model.lower().startswith("hb"):
199
+ return Manufacturer.HB
200
+ if self._model.lower().startswith("alpha"):
201
+ return Manufacturer.MOEHLENHOFF
202
+ return Manufacturer.EQ3
203
+
204
+ @info_property(log_context=True)
205
+ def address(self) -> str:
206
+ """Return the address of the device."""
207
+ return self._address
208
+
209
+ @property
210
+ def allow_undefined_generic_data_points(self) -> bool:
211
+ """Return if undefined generic data points of this device are allowed."""
212
+ return bool(
213
+ all(
214
+ channel.custom_data_point.allow_undefined_generic_data_points
215
+ for channel in self._channels.values()
216
+ if channel.custom_data_point is not None
217
+ )
218
+ )
219
+
220
+ @state_property
221
+ def available(self) -> bool:
222
+ """Return the availability of the device."""
223
+ if self._forced_availability != ForcedDeviceAvailability.NOT_SET:
224
+ return self._forced_availability == ForcedDeviceAvailability.FORCE_TRUE
225
+ if (un_reach := self._dp_un_reach) is None:
226
+ un_reach = self._dp_sticky_un_reach
227
+ if un_reach is not None and un_reach.value is not None:
228
+ return not un_reach.value
229
+ return True
230
+
231
+ @property
232
+ def available_firmware(self) -> str | None:
233
+ """Return the available firmware of the device."""
234
+ return str(self._description.get("AVAILABLE_FIRMWARE", ""))
235
+
236
+ @property
237
+ def calculated_data_points(self) -> tuple[CalculatedDataPoint, ...]:
238
+ """Return the generic data points."""
239
+ data_points: list[CalculatedDataPoint] = []
240
+ for channel in self._channels.values():
241
+ data_points.extend(channel.calculated_data_points)
242
+ return tuple(data_points)
243
+
244
+ @property
245
+ def central(self) -> hmcu.CentralUnit:
246
+ """Return the central of the device."""
247
+ return self._central
248
+
249
+ @property
250
+ def channels(self) -> Mapping[str, Channel]:
251
+ """Return the channels."""
252
+ return self._channels
253
+
254
+ @property
255
+ def client(self) -> hmcl.Client:
256
+ """Return the client of the device."""
257
+ return self._client
258
+
259
+ @property
260
+ def config_pending(self) -> bool:
261
+ """Return if a config change of the device is pending."""
262
+ if self._dp_config_pending is not None and self._dp_config_pending.value is not None:
263
+ return self._dp_config_pending.value is True
264
+ return False
265
+
266
+ @property
267
+ def custom_data_points(self) -> tuple[hmce.CustomDataPoint, ...]:
268
+ """Return the custom data points."""
269
+ return tuple(
270
+ channel.custom_data_point for channel in self._channels.values() if channel.custom_data_point is not None
271
+ )
272
+
273
+ @info_property
274
+ def firmware(self) -> str:
275
+ """Return the firmware of the device."""
276
+ return self._description.get("FIRMWARE") or "0.0"
277
+
278
+ @property
279
+ def firmware_updatable(self) -> bool:
280
+ """Return the firmware update state of the device."""
281
+ return self._description.get("FIRMWARE_UPDATABLE") or False
282
+
283
+ @property
284
+ def firmware_update_state(self) -> DeviceFirmwareState:
285
+ """Return the firmware update state of the device."""
286
+ return DeviceFirmwareState(self._description.get("FIRMWARE_UPDATE_STATE") or DeviceFirmwareState.UNKNOWN)
287
+
288
+ @property
289
+ def generic_events(self) -> tuple[GenericEvent, ...]:
290
+ """Return the generic events."""
291
+ events: list[GenericEvent] = []
292
+ for channel in self._channels.values():
293
+ events.extend(channel.generic_events)
294
+ return tuple(events)
295
+
296
+ @property
297
+ def generic_data_points(self) -> tuple[GenericDataPoint, ...]:
298
+ """Return the generic data points."""
299
+ data_points: list[GenericDataPoint] = []
300
+ for channel in self._channels.values():
301
+ data_points.extend(channel.generic_data_points)
302
+ return tuple(data_points)
303
+
304
+ @property
305
+ def has_custom_data_point_definition(self) -> bool:
306
+ """Return if custom_data_point definition is available for the device."""
307
+ return self._has_custom_data_point_definition
308
+
309
+ @property
310
+ def has_sub_devices(self) -> bool:
311
+ """Return if device has multiple sub device channels."""
312
+ # If there is only one channel group, no sub devices are needed
313
+ if len(self._group_channels) <= 1:
314
+ return False
315
+ count = 0
316
+ # If there are multiple channel groups with more than one channel, there are sub devices
317
+ for gcs in self._group_channels.values():
318
+ if len(gcs) > 1:
319
+ count += 1
320
+ if count > 1:
321
+ return True
322
+
323
+ return False
324
+
325
+ @property
326
+ def id(self) -> str:
327
+ """Return the id of the device."""
328
+ return self._id
329
+
330
+ @info_property
331
+ def identifier(self) -> str:
332
+ """Return the identifier of the device."""
333
+ return f"{self._address}{IDENTIFIER_SEPARATOR}{self._interface_id}"
334
+
335
+ @property
336
+ def ignore_on_initial_load(self) -> bool:
337
+ """Return if model should be ignored on initial load."""
338
+ return self._ignore_on_initial_load
339
+
340
+ @property
341
+ def interface(self) -> Interface:
342
+ """Return the interface of the device."""
343
+ return self._interface
344
+
345
+ @hm_property(log_context=True)
346
+ def interface_id(self) -> str:
347
+ """Return the interface_id of the device."""
348
+ return self._interface_id
349
+
350
+ @property
351
+ def ignore_for_custom_data_point(self) -> bool:
352
+ """Return if device should be ignored for custom data_point."""
353
+ return self._ignore_for_custom_data_point
354
+
355
+ @property
356
+ def info(self) -> Mapping[str, Any]:
357
+ """Return the device info."""
358
+ device_info = dict(self.info_payload)
359
+ device_info["central"] = self._central.info_payload
360
+ return device_info
361
+
362
+ @property
363
+ def is_updatable(self) -> bool:
364
+ """Return if the device is updatable."""
365
+ return self._is_updatable
366
+
367
+ @property
368
+ def link_peer_channels(self) -> Mapping[Channel, tuple[Channel, ...]]:
369
+ """Return the link peer channels."""
370
+ return {
371
+ channel: channel.link_peer_channels for channel in self._channels.values() if channel.link_peer_channels
372
+ }
373
+
374
+ @info_property
375
+ def manufacturer(self) -> str:
376
+ """Return the manufacturer of the device."""
377
+ return self._manufacturer
378
+
379
+ @info_property(log_context=True)
380
+ def model(self) -> str:
381
+ """Return the model of the device."""
382
+ return self._model
383
+
384
+ @info_property
385
+ def name(self) -> str:
386
+ """Return the name of the device."""
387
+ return self._name
388
+
389
+ @property
390
+ def product_group(self) -> ProductGroup:
391
+ """Return the product group of the device."""
392
+ return self._product_group
393
+
394
+ @info_property
395
+ def room(self) -> str | None:
396
+ """Return the room of the device, if only one assigned in the backend."""
397
+ if self._rooms and len(self._rooms) == 1:
398
+ return list(self._rooms)[0]
399
+ if (maintenance_channel := self.get_channel(channel_address=f"{self._address}:0")) is not None:
400
+ return maintenance_channel.room
401
+ return None
402
+
403
+ @property
404
+ def rooms(self) -> set[str]:
405
+ """Return all rooms of the device."""
406
+ return self._rooms
407
+
408
+ @property
409
+ def rx_modes(self) -> tuple[RxMode, ...]:
410
+ """Return the rx mode."""
411
+ return self._rx_modes
412
+
413
+ @property
414
+ def sub_model(self) -> str | None:
415
+ """Return the sub model of the device."""
416
+ return self._sub_model
417
+
418
+ @property
419
+ def update_data_point(self) -> DpUpdate | None:
420
+ """Return the device firmware update data_point of the device."""
421
+ return self._update_data_point
422
+
423
+ @property
424
+ def value_cache(self) -> _ValueCache:
425
+ """Return the value_cache of the device."""
426
+ return self._value_cache
427
+
428
+ @property
429
+ def _dp_un_reach(self) -> GenericDataPoint | None:
430
+ """Return th UN REACH data_point."""
431
+ return self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.UN_REACH)
432
+
433
+ @property
434
+ def _dp_sticky_un_reach(self) -> GenericDataPoint | None:
435
+ """Return th STICKY_UN_REACH data_point."""
436
+ return self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.STICKY_UN_REACH)
437
+
438
+ @property
439
+ def _dp_config_pending(self) -> GenericDataPoint | None:
440
+ """Return th CONFIG_PENDING data_point."""
441
+ return self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.CONFIG_PENDING)
442
+
443
+ def add_channel_to_group(self, *, group_no: int, channel_no: int | None) -> None:
444
+ """Add channel to group."""
445
+ if group_no not in self._group_channels:
446
+ self._group_channels[group_no] = set()
447
+ self._group_channels[group_no].add(channel_no)
448
+
449
+ if group_no not in self._channel_group:
450
+ self._channel_group[group_no] = group_no
451
+ if channel_no not in self._channel_group:
452
+ self._channel_group[channel_no] = group_no
453
+
454
+ @inspector
455
+ async def create_central_links(self) -> None:
456
+ """Create a central links to support press events on all channels with click events."""
457
+ if self.relevant_for_central_link_management: # pylint: disable=using-constant-test
458
+ for channel in self._channels.values():
459
+ await channel.create_central_link()
460
+
461
+ @inspector
462
+ async def remove_central_links(self) -> None:
463
+ """Remove central links."""
464
+ if self.relevant_for_central_link_management: # pylint: disable=using-constant-test
465
+ for channel in self._channels.values():
466
+ await channel.remove_central_link()
467
+
468
+ @hm_property(cached=True)
469
+ def relevant_for_central_link_management(self) -> bool:
470
+ """Return if channel is relevant for central link management."""
471
+ return (
472
+ self._interface in (Interface.BIDCOS_RF, Interface.BIDCOS_WIRED, Interface.HMIP_RF)
473
+ and self._model not in VIRTUAL_REMOTE_MODELS
474
+ )
475
+
476
+ def get_channel_group_no(self, *, channel_no: int | None) -> int | None:
477
+ """Return the group no of the channel."""
478
+ return self._channel_group.get(channel_no)
479
+
480
+ def is_in_multi_channel_group(self, *, channel_no: int | None) -> bool:
481
+ """Return if multiple channels are in the group."""
482
+ if channel_no is None:
483
+ return False
484
+
485
+ return len([s for s, m in self._channel_group.items() if m == self._channel_group.get(channel_no)]) > 1
486
+
487
+ def get_channel(self, *, channel_address: str) -> Channel | None:
488
+ """Get channel of device."""
489
+ return self._channels.get(channel_address)
490
+
491
+ async def re_init_link_peers(self) -> None:
492
+ """Initiate link peers."""
493
+ for channel in self._channels.values():
494
+ await channel.init_link_peer()
495
+
496
+ def identify_channel(self, *, text: str) -> Channel | None:
497
+ """Identify channel within a text."""
498
+ for channel_address, channel in self._channels.items():
499
+ if text.endswith(channel_address):
500
+ return channel
501
+ if channel.id in text:
502
+ return channel
503
+ if channel.device.id in text:
504
+ return channel
505
+
506
+ return None
507
+
508
+ def remove(self) -> None:
509
+ """Remove data points from collections and central."""
510
+ for channel in self._channels.values():
511
+ channel.remove()
512
+
513
+ def register_device_updated_callback(self, *, cb: Callable) -> CALLBACK_TYPE:
514
+ """Register update callback."""
515
+ if callable(cb) and cb not in self._device_updated_callbacks:
516
+ self._device_updated_callbacks.append(cb)
517
+ return partial(self.unregister_device_updated_callback, cb=cb)
518
+ return None
519
+
520
+ def unregister_device_updated_callback(self, *, cb: Callable) -> None:
521
+ """Remove update callback."""
522
+ if cb in self._device_updated_callbacks:
523
+ self._device_updated_callbacks.remove(cb)
524
+
525
+ def register_firmware_update_callback(self, *, cb: Callable) -> CALLBACK_TYPE:
526
+ """Register firmware update callback."""
527
+ if callable(cb) and cb not in self._firmware_update_callbacks:
528
+ self._firmware_update_callbacks.append(cb)
529
+ return partial(self.unregister_firmware_update_callback, cb=cb)
530
+ return None
531
+
532
+ def unregister_firmware_update_callback(self, *, cb: Callable) -> None:
533
+ """Remove firmware update callback."""
534
+ if cb in self._firmware_update_callbacks:
535
+ self._firmware_update_callbacks.remove(cb)
536
+
537
+ def _set_modified_at(self) -> None:
538
+ self._modified_at = datetime.now()
539
+
540
+ def get_data_points(
541
+ self,
542
+ *,
543
+ category: DataPointCategory | None = None,
544
+ exclude_no_create: bool = True,
545
+ registered: bool | None = None,
546
+ ) -> tuple[CallbackDataPoint, ...]:
547
+ """Get all data points of the device."""
548
+ all_data_points: list[CallbackDataPoint] = []
549
+ if (
550
+ self._update_data_point
551
+ and (category is None or self._update_data_point.category == category)
552
+ and (
553
+ (exclude_no_create and self._update_data_point.usage != DataPointUsage.NO_CREATE)
554
+ or exclude_no_create is False
555
+ )
556
+ and (registered is None or self._update_data_point.is_registered == registered)
557
+ ):
558
+ all_data_points.append(self._update_data_point)
559
+ for channel in self._channels.values():
560
+ all_data_points.extend(
561
+ channel.get_data_points(category=category, exclude_no_create=exclude_no_create, registered=registered)
562
+ )
563
+ return tuple(all_data_points)
564
+
565
+ def get_events(
566
+ self, *, event_type: EventType, registered: bool | None = None
567
+ ) -> Mapping[int | None, tuple[GenericEvent, ...]]:
568
+ """Return a list of specific events of a channel."""
569
+ events: dict[int | None, tuple[GenericEvent, ...]] = {}
570
+ for channel in self._channels.values():
571
+ if (values := channel.get_events(event_type=event_type, registered=registered)) and len(values) > 0:
572
+ events[channel.no] = values
573
+ return events
574
+
575
+ def get_calculated_data_point(self, *, channel_address: str, parameter: str) -> CalculatedDataPoint | None:
576
+ """Return a calculated data_point from device."""
577
+ if channel := self.get_channel(channel_address=channel_address):
578
+ return channel.get_calculated_data_point(parameter=parameter)
579
+ return None
580
+
581
+ def get_custom_data_point(self, *, channel_no: int) -> hmce.CustomDataPoint | None:
582
+ """Return a custom data_point from device."""
583
+ if channel := self.get_channel(
584
+ channel_address=get_channel_address(device_address=self._address, channel_no=channel_no)
585
+ ):
586
+ return channel.custom_data_point
587
+ return None
588
+
589
+ def get_generic_data_point(
590
+ self, *, channel_address: str, parameter: str, paramset_key: ParamsetKey | None = None
591
+ ) -> GenericDataPoint | None:
592
+ """Return a generic data_point from device."""
593
+ if channel := self.get_channel(channel_address=channel_address):
594
+ return channel.get_generic_data_point(parameter=parameter, paramset_key=paramset_key)
595
+ return None
596
+
597
+ def get_generic_event(self, *, channel_address: str, parameter: str) -> GenericEvent | None:
598
+ """Return a generic event from device."""
599
+ if channel := self.get_channel(channel_address=channel_address):
600
+ return channel.get_generic_event(parameter=parameter)
601
+ return None
602
+
603
+ def get_readable_data_points(self, *, paramset_key: ParamsetKey) -> tuple[GenericDataPoint, ...]:
604
+ """Return the list of readable master data points."""
605
+ data_points: list[GenericDataPoint] = []
606
+ for channel in self._channels.values():
607
+ data_points.extend(channel.get_readable_data_points(paramset_key=paramset_key))
608
+ return tuple(data_points)
609
+
610
+ def set_forced_availability(self, *, forced_availability: ForcedDeviceAvailability) -> None:
611
+ """Set the availability of the device."""
612
+ if self._forced_availability != forced_availability:
613
+ self._forced_availability = forced_availability
614
+ for dp in self.generic_data_points:
615
+ dp.emit_data_point_updated_event()
616
+
617
+ @inspector
618
+ async def export_device_definition(self) -> None:
619
+ """Export the device definition for current device."""
620
+ try:
621
+ device_exporter = _DefinitionExporter(device=self)
622
+ await device_exporter.export_data()
623
+ except Exception as exc:
624
+ raise AioHomematicException(f"EXPORT_DEVICE_DEFINITION failed: {extract_exc_args(exc=exc)}") from exc
625
+
626
+ def refresh_firmware_data(self) -> None:
627
+ """Refresh firmware data of the device."""
628
+ old_available_firmware = self.available_firmware
629
+ old_firmware = self.firmware
630
+ old_firmware_update_state = self.firmware_update_state
631
+ old_firmware_updatable = self.firmware_updatable
632
+
633
+ self._description = self._central.device_descriptions.get_device_description(
634
+ interface_id=self._interface_id, address=self._address
635
+ )
636
+
637
+ if (
638
+ old_available_firmware != self.available_firmware
639
+ or old_firmware != self.firmware
640
+ or old_firmware_update_state != self.firmware_update_state
641
+ or old_firmware_updatable != self.firmware_updatable
642
+ ):
643
+ for callback_handler in self._firmware_update_callbacks:
644
+ callback_handler()
645
+
646
+ @inspector
647
+ async def update_firmware(self, *, refresh_after_update_intervals: tuple[int, ...]) -> bool:
648
+ """Update the firmware of the Homematic device."""
649
+ update_result = await self._client.update_device_firmware(device_address=self._address)
650
+
651
+ async def refresh_data() -> None:
652
+ for refresh_interval in refresh_after_update_intervals:
653
+ await asyncio.sleep(refresh_interval)
654
+ await self._central.refresh_firmware_data(device_address=self._address)
655
+
656
+ if refresh_after_update_intervals:
657
+ self._central.looper.create_task(target=refresh_data, name="refresh_firmware_data")
658
+
659
+ return update_result
660
+
661
+ @inspector
662
+ async def load_value_cache(self) -> None:
663
+ """Init the parameter cache."""
664
+ if len(self.generic_data_points) > 0:
665
+ await self._value_cache.init_base_data_points()
666
+ if len(self.generic_events) > 0:
667
+ await self._value_cache.init_readable_events()
668
+ _LOGGER.debug(
669
+ "INIT_DATA: Skipping load_data, missing data points for %s",
670
+ self._address,
671
+ )
672
+
673
+ @inspector
674
+ async def reload_paramset_descriptions(self) -> None:
675
+ """Reload paramset for device."""
676
+ for (
677
+ paramset_key,
678
+ channel_addresses,
679
+ ) in self._central.paramset_descriptions.get_channel_addresses_by_paramset_key(
680
+ interface_id=self._interface_id,
681
+ device_address=self._address,
682
+ ).items():
683
+ for channel_address in channel_addresses:
684
+ await self._client.fetch_paramset_description(
685
+ channel_address=channel_address,
686
+ paramset_key=paramset_key,
687
+ )
688
+ await self._central.save_files(save_paramset_descriptions=True)
689
+ for dp in self.generic_data_points:
690
+ dp.update_parameter_data()
691
+ self.emit_device_updated_callback()
692
+
693
+ @loop_check
694
+ def emit_device_updated_callback(self) -> None:
695
+ """Do what is needed when the state of the device has been updated."""
696
+ self._set_modified_at()
697
+ for callback_handler in self._device_updated_callbacks:
698
+ try:
699
+ callback_handler()
700
+ except Exception as exc:
701
+ _LOGGER.warning("EMIT_DEVICE_UPDATED failed: %s", extract_exc_args(exc=exc))
702
+
703
+ def __str__(self) -> str:
704
+ """Provide some useful information."""
705
+ return (
706
+ f"address: {self._address}, "
707
+ f"model: {self._model}, "
708
+ f"name: {self._name}, "
709
+ f"generic dps: {len(self.generic_data_points)}, "
710
+ f"calculated dps: {len(self.calculated_data_points)}, "
711
+ f"custom dps: {len(self.custom_data_points)}, "
712
+ f"events: {len(self.generic_events)}"
713
+ )
714
+
715
+
716
+ class Channel(LogContextMixin, PayloadMixin):
717
+ """Object to hold information about a channel and associated data points."""
718
+
719
+ __slots__ = (
720
+ "_address",
721
+ "_calculated_data_points",
722
+ "_central",
723
+ "_custom_data_point",
724
+ "_description",
725
+ "_device",
726
+ "_function",
727
+ "_generic_data_points",
728
+ "_generic_events",
729
+ "_group_master",
730
+ "_group_no",
731
+ "_id",
732
+ "_is_in_multi_group",
733
+ "_link_peer_addresses",
734
+ "_link_peer_changed_callbacks",
735
+ "_link_source_categories",
736
+ "_link_source_roles",
737
+ "_link_target_categories",
738
+ "_link_target_roles",
739
+ "_modified_at",
740
+ "_name_data",
741
+ "_no",
742
+ "_paramset_keys",
743
+ "_rooms",
744
+ "_type_name",
745
+ "_unique_id",
746
+ )
747
+
748
+ def __init__(self, *, device: Device, channel_address: str) -> None:
749
+ """Initialize the channel object."""
750
+ PayloadMixin.__init__(self)
751
+
752
+ self._device: Final = device
753
+ self._central: Final = device.central
754
+ self._address: Final = channel_address
755
+ self._id: Final = self._central.device_details.get_address_id(address=channel_address)
756
+ self._no: Final[int | None] = get_channel_no(address=channel_address)
757
+ self._name_data: Final = get_channel_name_data(channel=self)
758
+ self._description: DeviceDescription = self._central.device_descriptions.get_device_description(
759
+ interface_id=self._device.interface_id, address=channel_address
760
+ )
761
+ self._type_name: Final[str] = self._description["TYPE"]
762
+ self._paramset_keys: Final = tuple(ParamsetKey(paramset_key) for paramset_key in self._description["PARAMSETS"])
763
+
764
+ self._unique_id: Final = generate_channel_unique_id(central=self._central, address=channel_address)
765
+ self._group_no: int | None = None
766
+ self._group_master: Channel | None = None
767
+ self._is_in_multi_group: bool | None = None
768
+ self._calculated_data_points: Final[dict[DataPointKey, CalculatedDataPoint]] = {}
769
+ self._custom_data_point: hmce.CustomDataPoint | None = None
770
+ self._generic_data_points: Final[dict[DataPointKey, GenericDataPoint]] = {}
771
+ self._generic_events: Final[dict[DataPointKey, GenericEvent]] = {}
772
+ self._link_peer_addresses: tuple[str, ...] = ()
773
+ self._link_peer_changed_callbacks: list[Callable] = []
774
+ self._link_source_roles: tuple[str, ...] = (
775
+ tuple(source_roles.split(" ")) if (source_roles := self._description.get("LINK_SOURCE_ROLES")) else ()
776
+ )
777
+ self._link_source_categories: Final = get_link_source_categories(
778
+ source_roles=self._link_source_roles, channel_type_name=self._type_name
779
+ )
780
+ self._link_target_roles: tuple[str, ...] = (
781
+ tuple(target_roles.split(" ")) if (target_roles := self._description.get("LINK_TARGET_ROLES")) else ()
782
+ )
783
+ self._link_target_categories: Final = get_link_target_categories(
784
+ target_roles=self._link_target_roles, channel_type_name=self._type_name
785
+ )
786
+ self._modified_at: datetime = INIT_DATETIME
787
+ self._rooms: Final = self._central.device_details.get_channel_rooms(channel_address=channel_address)
788
+ self._function: Final = self._central.device_details.get_function_text(address=self._address)
789
+ self.init_channel()
790
+
791
+ def init_channel(self) -> None:
792
+ """Init the channel."""
793
+ self._central.looper.create_task(target=self.init_link_peer(), name=f"init_channel_{self._address}")
794
+
795
+ async def init_link_peer(self) -> None:
796
+ """Init the link partners."""
797
+ if self._link_source_categories and self._device.model not in VIRTUAL_REMOTE_MODELS:
798
+ link_peer_addresses = await self._device.client.get_link_peers(address=self._address)
799
+ if self._link_peer_addresses != link_peer_addresses:
800
+ self._link_peer_addresses = link_peer_addresses
801
+ self.emit_link_peer_changed_event()
802
+
803
+ @info_property
804
+ def address(self) -> str:
805
+ """Return the address of the channel."""
806
+ return self._address
807
+
808
+ @property
809
+ def calculated_data_points(self) -> tuple[CalculatedDataPoint, ...]:
810
+ """Return the generic data points."""
811
+ return tuple(self._calculated_data_points.values())
812
+
813
+ @property
814
+ def central(self) -> hmcu.CentralUnit:
815
+ """Return the central."""
816
+ return self._central
817
+
818
+ @property
819
+ def custom_data_point(self) -> hmce.CustomDataPoint | None:
820
+ """Return the custom data point."""
821
+ return self._custom_data_point
822
+
823
+ @property
824
+ def description(self) -> DeviceDescription:
825
+ """Return the device description for the channel."""
826
+ return self._description
827
+
828
+ @hm_property(log_context=True)
829
+ def device(self) -> Device:
830
+ """Return the device of the channel."""
831
+ return self._device
832
+
833
+ @property
834
+ def function(self) -> str | None:
835
+ """Return the function of the channel."""
836
+ return self._function
837
+
838
+ @property
839
+ def full_name(self) -> str:
840
+ """Return the full name of the channel."""
841
+ return self._name_data.full_name
842
+
843
+ @property
844
+ def generic_data_points(self) -> tuple[GenericDataPoint, ...]:
845
+ """Return the generic data points."""
846
+ return tuple(self._generic_data_points.values())
847
+
848
+ @property
849
+ def generic_events(self) -> tuple[GenericEvent, ...]:
850
+ """Return the generic events."""
851
+ return tuple(self._generic_events.values())
852
+
853
+ @property
854
+ def group_master(self) -> Channel | None:
855
+ """Return the master channel of the group."""
856
+ if self.group_no is None:
857
+ return None
858
+ if self._group_master is None:
859
+ self._group_master = (
860
+ self
861
+ if self.is_group_master
862
+ else self._device.get_channel(channel_address=f"{self._device.address}:{self.group_no}")
863
+ )
864
+ return self._group_master
865
+
866
+ @property
867
+ def group_no(self) -> int | None:
868
+ """Return the no of the channel group."""
869
+ if self._group_no is None:
870
+ self._group_no = self._device.get_channel_group_no(channel_no=self._no)
871
+ return self._group_no
872
+
873
+ @property
874
+ def id(self) -> str:
875
+ """Return the id of the channel."""
876
+ return self._id
877
+
878
+ @property
879
+ def is_in_multi_group(self) -> bool:
880
+ """Return if multiple channels are in the group."""
881
+ if self._is_in_multi_group is None:
882
+ self._is_in_multi_group = self._device.is_in_multi_channel_group(channel_no=self._no)
883
+ return self._is_in_multi_group
884
+
885
+ @property
886
+ def is_group_master(self) -> bool:
887
+ """Return if group master of channel."""
888
+ return self.group_no == self._no
889
+
890
+ @property
891
+ def link_peer_channels(self) -> tuple[Channel, ...]:
892
+ """Return the link peer channel."""
893
+ return tuple(
894
+ channel
895
+ for address in self._link_peer_addresses
896
+ if self._link_peer_addresses and (channel := self._central.get_channel(channel_address=address)) is not None
897
+ )
898
+
899
+ @property
900
+ def link_peer_addresses(self) -> tuple[str, ...]:
901
+ """Return the link peer addresses."""
902
+ return self._link_peer_addresses
903
+
904
+ @property
905
+ def link_peer_source_categories(self) -> tuple[str, ...]:
906
+ """Return the link peer source categories."""
907
+ return self._link_source_categories
908
+
909
+ @property
910
+ def link_peer_target_categories(self) -> tuple[str, ...]:
911
+ """Return the link peer target categories."""
912
+ return self._link_target_categories
913
+
914
+ @property
915
+ def name(self) -> str:
916
+ """Return the name of the channel."""
917
+ return self._name_data.channel_name
918
+
919
+ @property
920
+ def name_data(self) -> ChannelNameData:
921
+ """Return the name data of the channel."""
922
+ return self._name_data
923
+
924
+ @hm_property(log_context=True)
925
+ def no(self) -> int | None:
926
+ """Return the channel_no of the channel."""
927
+ return self._no
928
+
929
+ @property
930
+ def operation_mode(self) -> str | None:
931
+ """Return the channel operation mode if available."""
932
+ if (
933
+ cop := self.get_generic_data_point(parameter=Parameter.CHANNEL_OPERATION_MODE)
934
+ ) is not None and cop.value is not None:
935
+ return str(cop.value)
936
+ return None
937
+
938
+ @property
939
+ def paramset_keys(self) -> tuple[ParamsetKey, ...]:
940
+ """Return the paramset_keys of the channel."""
941
+ return self._paramset_keys
942
+
943
+ @property
944
+ def paramset_descriptions(self) -> Mapping[ParamsetKey, Mapping[str, ParameterData]]:
945
+ """Return the paramset descriptions of the channel."""
946
+ return self._central.paramset_descriptions.get_channel_paramset_descriptions(
947
+ interface_id=self._device.interface_id, channel_address=self._address
948
+ )
949
+
950
+ @info_property
951
+ def room(self) -> str | None:
952
+ """Return the room of the device, if only one assigned in the backend."""
953
+ if self._rooms and len(self._rooms) == 1:
954
+ return list(self._rooms)[0]
955
+ if self.is_group_master:
956
+ return None
957
+ if (master_channel := self.group_master) is not None:
958
+ return master_channel.room
959
+ return None
960
+
961
+ @property
962
+ def rooms(self) -> set[str]:
963
+ """Return all rooms of the channel."""
964
+ return self._rooms
965
+
966
+ @property
967
+ def type_name(self) -> str:
968
+ """Return the type name of the channel."""
969
+ return self._type_name
970
+
971
+ @property
972
+ def unique_id(self) -> str:
973
+ """Return the unique_id of the channel."""
974
+ return self._unique_id
975
+
976
+ @inspector
977
+ async def create_central_link(self) -> None:
978
+ """Create a central link to support press events."""
979
+ if self._has_key_press_events and not await self._has_central_link():
980
+ await self._device.client.report_value_usage(
981
+ address=self._address, value_id=REPORT_VALUE_USAGE_VALUE_ID, ref_counter=1
982
+ )
983
+
984
+ @inspector
985
+ async def remove_central_link(self) -> None:
986
+ """Remove a central link."""
987
+ if self._has_key_press_events and await self._has_central_link() and not await self._has_program_ids():
988
+ await self._device.client.report_value_usage(
989
+ address=self._address, value_id=REPORT_VALUE_USAGE_VALUE_ID, ref_counter=0
990
+ )
991
+
992
+ @inspector
993
+ async def cleanup_central_link_metadata(self) -> None:
994
+ """Cleanup the metadata for central links."""
995
+ if metadata := await self._device.client.get_metadata(address=self._address, data_id=REPORT_VALUE_USAGE_DATA):
996
+ await self._device.client.set_metadata(
997
+ address=self._address,
998
+ data_id=REPORT_VALUE_USAGE_DATA,
999
+ value={key: value for key, value in metadata.items() if key in CLICK_EVENTS},
1000
+ )
1001
+
1002
+ async def _has_central_link(self) -> bool:
1003
+ """Check if central link exists."""
1004
+ try:
1005
+ if metadata := await self._device.client.get_metadata(
1006
+ address=self._address, data_id=REPORT_VALUE_USAGE_DATA
1007
+ ):
1008
+ return any(
1009
+ key
1010
+ for key, value in metadata.items()
1011
+ if isinstance(key, str)
1012
+ and isinstance(value, int)
1013
+ and key == REPORT_VALUE_USAGE_VALUE_ID
1014
+ and value > 0
1015
+ )
1016
+ except BaseHomematicException as bhexc:
1017
+ _LOGGER.debug("HAS_CENTRAL_LINK failed: %s", extract_exc_args(exc=bhexc))
1018
+ return False
1019
+
1020
+ async def _has_program_ids(self) -> bool:
1021
+ """Return if a channel has program ids."""
1022
+ return bool(await self._device.client.has_program_ids(channel_hmid=self._id))
1023
+
1024
+ @property
1025
+ def _has_key_press_events(self) -> bool:
1026
+ """Return if channel has KEYPRESS events."""
1027
+ return any(event for event in self.generic_events if event.event_type is EventType.KEYPRESS)
1028
+
1029
+ def add_data_point(self, *, data_point: CallbackDataPoint) -> None:
1030
+ """Add a data_point to a channel."""
1031
+ if isinstance(data_point, BaseParameterDataPoint):
1032
+ self._central.add_event_subscription(data_point=data_point)
1033
+ if isinstance(data_point, CalculatedDataPoint):
1034
+ self._calculated_data_points[data_point.dpk] = data_point
1035
+ if isinstance(data_point, GenericDataPoint):
1036
+ self._generic_data_points[data_point.dpk] = data_point
1037
+ self._device.register_device_updated_callback(cb=data_point.emit_data_point_updated_event)
1038
+ if isinstance(data_point, hmce.CustomDataPoint):
1039
+ self._custom_data_point = data_point
1040
+ if isinstance(data_point, GenericEvent):
1041
+ self._generic_events[data_point.dpk] = data_point
1042
+
1043
+ def _remove_data_point(self, *, data_point: CallbackDataPoint) -> None:
1044
+ """Remove a data_point from a channel."""
1045
+ if isinstance(data_point, BaseParameterDataPoint):
1046
+ self._central.remove_event_subscription(data_point=data_point)
1047
+ if isinstance(data_point, CalculatedDataPoint):
1048
+ del self._calculated_data_points[data_point.dpk]
1049
+ if isinstance(data_point, GenericDataPoint):
1050
+ del self._generic_data_points[data_point.dpk]
1051
+ self._device.unregister_device_updated_callback(cb=data_point.emit_data_point_updated_event)
1052
+ if isinstance(data_point, hmce.CustomDataPoint):
1053
+ self._custom_data_point = None
1054
+ if isinstance(data_point, GenericEvent):
1055
+ del self._generic_events[data_point.dpk]
1056
+ data_point.emit_device_removed_event()
1057
+
1058
+ def remove(self) -> None:
1059
+ """Remove data points from collections and central."""
1060
+ for event in self.generic_events:
1061
+ self._remove_data_point(data_point=event)
1062
+ self._generic_events.clear()
1063
+
1064
+ for ccdp in self.calculated_data_points:
1065
+ self._remove_data_point(data_point=ccdp)
1066
+ self._calculated_data_points.clear()
1067
+
1068
+ for gdp in self.generic_data_points:
1069
+ self._remove_data_point(data_point=gdp)
1070
+ self._generic_data_points.clear()
1071
+
1072
+ if self._custom_data_point:
1073
+ self._remove_data_point(data_point=self._custom_data_point)
1074
+
1075
+ def _set_modified_at(self) -> None:
1076
+ self._modified_at = datetime.now()
1077
+
1078
+ def register_link_peer_changed_callback(self, *, cb: Callable) -> CALLBACK_TYPE:
1079
+ """Register the link peer changed callback."""
1080
+ if callable(cb) and cb not in self._link_peer_changed_callbacks:
1081
+ self._link_peer_changed_callbacks.append(cb)
1082
+ return partial(self._unregister_link_peer_changed_callback, cb=cb)
1083
+ return None
1084
+
1085
+ def _unregister_link_peer_changed_callback(self, *, cb: Callable) -> None:
1086
+ """Unregister the link peer changed callback."""
1087
+ if cb in self._link_peer_changed_callbacks:
1088
+ self._link_peer_changed_callbacks.remove(cb)
1089
+
1090
+ @loop_check
1091
+ def emit_link_peer_changed_event(self) -> None:
1092
+ """Do what is needed when the link peer has been changed for the device."""
1093
+ for callback_handler in self._link_peer_changed_callbacks:
1094
+ try:
1095
+ callback_handler()
1096
+ except Exception as exc:
1097
+ _LOGGER.warning("EMIT_LINK_PEER_CHANGED_EVENT failed: %s", extract_exc_args(exc=exc))
1098
+
1099
+ def get_data_points(
1100
+ self,
1101
+ *,
1102
+ category: DataPointCategory | None = None,
1103
+ exclude_no_create: bool = True,
1104
+ registered: bool | None = None,
1105
+ ) -> tuple[CallbackDataPoint, ...]:
1106
+ """Get all data points of the device."""
1107
+ all_data_points: list[CallbackDataPoint] = list(self._generic_data_points.values()) + list(
1108
+ self._calculated_data_points.values()
1109
+ )
1110
+ if self._custom_data_point:
1111
+ all_data_points.append(self._custom_data_point)
1112
+
1113
+ return tuple(
1114
+ dp
1115
+ for dp in all_data_points
1116
+ if dp is not None
1117
+ and (category is None or dp.category == category)
1118
+ and ((exclude_no_create and dp.usage != DataPointUsage.NO_CREATE) or exclude_no_create is False)
1119
+ and (registered is None or dp.is_registered == registered)
1120
+ )
1121
+
1122
+ def get_events(self, *, event_type: EventType, registered: bool | None = None) -> tuple[GenericEvent, ...]:
1123
+ """Return a list of specific events of a channel."""
1124
+ return tuple(
1125
+ event
1126
+ for event in self._generic_events.values()
1127
+ if (event.event_type == event_type and (registered is None or event.is_registered == registered))
1128
+ )
1129
+
1130
+ def get_calculated_data_point(self, *, parameter: str) -> CalculatedDataPoint | None:
1131
+ """Return a calculated data_point from device."""
1132
+ return self._calculated_data_points.get(
1133
+ DataPointKey(
1134
+ interface_id=self._device.interface_id,
1135
+ channel_address=self._address,
1136
+ paramset_key=ParamsetKey.CALCULATED,
1137
+ parameter=parameter,
1138
+ )
1139
+ )
1140
+
1141
+ def get_generic_data_point(
1142
+ self, *, parameter: str, paramset_key: ParamsetKey | None = None
1143
+ ) -> GenericDataPoint | None:
1144
+ """Return a generic data_point from device."""
1145
+ if paramset_key:
1146
+ return self._generic_data_points.get(
1147
+ DataPointKey(
1148
+ interface_id=self._device.interface_id,
1149
+ channel_address=self._address,
1150
+ paramset_key=paramset_key,
1151
+ parameter=parameter,
1152
+ )
1153
+ )
1154
+
1155
+ if dp := self._generic_data_points.get(
1156
+ DataPointKey(
1157
+ interface_id=self._device.interface_id,
1158
+ channel_address=self._address,
1159
+ paramset_key=ParamsetKey.VALUES,
1160
+ parameter=parameter,
1161
+ )
1162
+ ):
1163
+ return dp
1164
+ return self._generic_data_points.get(
1165
+ DataPointKey(
1166
+ interface_id=self._device.interface_id,
1167
+ channel_address=self._address,
1168
+ paramset_key=ParamsetKey.MASTER,
1169
+ parameter=parameter,
1170
+ )
1171
+ )
1172
+
1173
+ def get_generic_event(self, *, parameter: str) -> GenericEvent | None:
1174
+ """Return a generic event from device."""
1175
+ return self._generic_events.get(
1176
+ DataPointKey(
1177
+ interface_id=self._device.interface_id,
1178
+ channel_address=self._address,
1179
+ paramset_key=ParamsetKey.VALUES,
1180
+ parameter=parameter,
1181
+ )
1182
+ )
1183
+
1184
+ def get_readable_data_points(self, *, paramset_key: ParamsetKey) -> tuple[GenericDataPoint, ...]:
1185
+ """Return the list of readable master data points."""
1186
+ return tuple(
1187
+ ge for ge in self._generic_data_points.values() if ge.is_readable and ge.paramset_key == paramset_key
1188
+ )
1189
+
1190
+ def has_link_source_category(self, *, category: DataPointCategory) -> bool:
1191
+ """Return if channel is receiver."""
1192
+ return category in self._link_source_categories
1193
+
1194
+ def has_link_target_category(self, *, category: DataPointCategory) -> bool:
1195
+ """Return if channel is transmitter."""
1196
+ return category in self._link_target_categories
1197
+
1198
+ def __str__(self) -> str:
1199
+ """Provide some useful information."""
1200
+ return (
1201
+ f"address: {self._address}, "
1202
+ f"type: {self._type_name}, "
1203
+ f"generic dps: {len(self._generic_data_points)}, "
1204
+ f"calculated dps: {len(self._calculated_data_points)}, "
1205
+ f"custom dp: {self._custom_data_point is not None}, "
1206
+ f"events: {len(self._generic_events)}"
1207
+ )
1208
+
1209
+
1210
+ class _ValueCache:
1211
+ """A Cache to temporarily stored values."""
1212
+
1213
+ __slots__ = (
1214
+ "_device",
1215
+ "_device_cache",
1216
+ "_sema_get_or_load_value",
1217
+ )
1218
+
1219
+ _NO_VALUE_CACHE_ENTRY: Final = "NO_VALUE_CACHE_ENTRY"
1220
+
1221
+ def __init__(self, *, device: Device) -> None:
1222
+ """Init the value cache."""
1223
+ self._sema_get_or_load_value: Final = asyncio.Semaphore()
1224
+ self._device: Final = device
1225
+ # {key, CacheEntry}
1226
+ self._device_cache: Final[dict[DataPointKey, CacheEntry]] = {}
1227
+
1228
+ async def init_base_data_points(self) -> None:
1229
+ """Load data by get_value."""
1230
+ try:
1231
+ for dp in self._get_base_data_points():
1232
+ await dp.load_data_point_value(call_source=CallSource.HM_INIT)
1233
+ except BaseHomematicException as bhexc:
1234
+ _LOGGER.debug(
1235
+ "init_base_data_points: Failed to init cache for channel0 %s, %s [%s]",
1236
+ self._device.model,
1237
+ self._device.address,
1238
+ extract_exc_args(exc=bhexc),
1239
+ )
1240
+
1241
+ def _get_base_data_points(self) -> set[GenericDataPoint]:
1242
+ """Get data points of channel 0 and master."""
1243
+ return {
1244
+ dp
1245
+ for dp in self._device.generic_data_points
1246
+ if (
1247
+ dp.channel.no == 0
1248
+ and dp.paramset_key == ParamsetKey.VALUES
1249
+ and dp.parameter in RELEVANT_INIT_PARAMETERS
1250
+ )
1251
+ or dp.paramset_key == ParamsetKey.MASTER
1252
+ }
1253
+
1254
+ async def init_readable_events(self) -> None:
1255
+ """Load data by get_value."""
1256
+ try:
1257
+ for event in self._get_readable_events():
1258
+ await event.load_data_point_value(call_source=CallSource.HM_INIT)
1259
+ except BaseHomematicException as bhexc:
1260
+ _LOGGER.debug(
1261
+ "init_base_events: Failed to init cache for channel0 %s, %s [%s]",
1262
+ self._device.model,
1263
+ self._device.address,
1264
+ extract_exc_args(exc=bhexc),
1265
+ )
1266
+
1267
+ def _get_readable_events(self) -> set[GenericEvent]:
1268
+ """Get readable events."""
1269
+ return {event for event in self._device.generic_events if event.is_readable}
1270
+
1271
+ async def get_value(
1272
+ self,
1273
+ *,
1274
+ dpk: DataPointKey,
1275
+ call_source: CallSource,
1276
+ direct_call: bool = False,
1277
+ ) -> Any:
1278
+ """Load data."""
1279
+
1280
+ async with self._sema_get_or_load_value:
1281
+ if direct_call is False and (cached_value := self._get_value_from_cache(dpk=dpk)) != NO_CACHE_ENTRY:
1282
+ return NO_CACHE_ENTRY if cached_value == self._NO_VALUE_CACHE_ENTRY else cached_value
1283
+
1284
+ value_dict: dict[str, Any] = {dpk.parameter: self._NO_VALUE_CACHE_ENTRY}
1285
+ try:
1286
+ value_dict = await self._get_values_for_cache(dpk=dpk)
1287
+ except BaseHomematicException as bhexc:
1288
+ _LOGGER.debug(
1289
+ "GET_OR_LOAD_VALUE: Failed to get data for %s, %s, %s, %s: %s",
1290
+ self._device.model,
1291
+ dpk.channel_address,
1292
+ dpk.parameter,
1293
+ call_source,
1294
+ extract_exc_args(exc=bhexc),
1295
+ )
1296
+ for d_parameter, d_value in value_dict.items():
1297
+ self._add_entry_to_device_cache(
1298
+ dpk=DataPointKey(
1299
+ interface_id=dpk.interface_id,
1300
+ channel_address=dpk.channel_address,
1301
+ paramset_key=dpk.paramset_key,
1302
+ parameter=d_parameter,
1303
+ ),
1304
+ value=d_value,
1305
+ )
1306
+ return (
1307
+ NO_CACHE_ENTRY
1308
+ if (value := value_dict.get(dpk.parameter)) and value == self._NO_VALUE_CACHE_ENTRY
1309
+ else value
1310
+ )
1311
+
1312
+ async def _get_values_for_cache(self, *, dpk: DataPointKey) -> dict[str, Any]:
1313
+ """Return a value from the backend to store in cache."""
1314
+ if not self._device.available:
1315
+ _LOGGER.debug(
1316
+ "GET_VALUES_FOR_CACHE failed: device %s (%s) is not available", self._device.name, self._device.address
1317
+ )
1318
+ return {}
1319
+ if dpk.paramset_key == ParamsetKey.VALUES:
1320
+ return {
1321
+ dpk.parameter: await self._device.client.get_value(
1322
+ channel_address=dpk.channel_address,
1323
+ paramset_key=dpk.paramset_key,
1324
+ parameter=dpk.parameter,
1325
+ call_source=CallSource.HM_INIT,
1326
+ )
1327
+ }
1328
+ return await self._device.client.get_paramset(
1329
+ address=dpk.channel_address, paramset_key=dpk.paramset_key, call_source=CallSource.HM_INIT
1330
+ )
1331
+
1332
+ def _add_entry_to_device_cache(self, *, dpk: DataPointKey, value: Any) -> None:
1333
+ """Add value to cache."""
1334
+ # write value to cache even if an exception has occurred
1335
+ # to avoid repetitive calls to the backend within max_age
1336
+ self._device_cache[dpk] = CacheEntry(value=value, refresh_at=datetime.now())
1337
+
1338
+ def _get_value_from_cache(
1339
+ self,
1340
+ *,
1341
+ dpk: DataPointKey,
1342
+ ) -> Any:
1343
+ """Load data from store."""
1344
+ # Try to get data from central cache
1345
+ if (
1346
+ dpk.paramset_key == ParamsetKey.VALUES
1347
+ and (
1348
+ global_value := self._device.central.data_cache.get_data(
1349
+ interface=self._device.interface,
1350
+ channel_address=dpk.channel_address,
1351
+ parameter=dpk.parameter,
1352
+ )
1353
+ )
1354
+ != NO_CACHE_ENTRY
1355
+ ):
1356
+ return global_value
1357
+
1358
+ if (cache_entry := self._device_cache.get(dpk, CacheEntry.empty())) and cache_entry.is_valid:
1359
+ return cache_entry.value
1360
+ return NO_CACHE_ENTRY
1361
+
1362
+
1363
+ class _DefinitionExporter:
1364
+ """Export device definitions from cache."""
1365
+
1366
+ __slots__ = (
1367
+ "_central",
1368
+ "_client",
1369
+ "_device_address",
1370
+ "_interface_id",
1371
+ "_random_id",
1372
+ "_storage_directory",
1373
+ )
1374
+
1375
+ def __init__(self, *, device: Device) -> None:
1376
+ """Init the device exporter."""
1377
+ self._client: Final = device.client
1378
+ self._central: Final = device.client.central
1379
+ self._storage_directory: Final = self._central.config.storage_directory
1380
+ self._interface_id: Final = device.interface_id
1381
+ self._device_address: Final = device.address
1382
+ self._random_id: Final[str] = f"VCU{int(random.randint(1000000, 9999999))}"
1383
+
1384
+ @inspector
1385
+ async def export_data(self) -> None:
1386
+ """Export data."""
1387
+ device_descriptions: Mapping[str, DeviceDescription] = (
1388
+ self._central.device_descriptions.get_device_with_channels(
1389
+ interface_id=self._interface_id, device_address=self._device_address
1390
+ )
1391
+ )
1392
+ paramset_descriptions: dict[
1393
+ str, dict[ParamsetKey, dict[str, ParameterData]]
1394
+ ] = await self._client.get_all_paramset_descriptions(device_descriptions=tuple(device_descriptions.values()))
1395
+ model = device_descriptions[self._device_address]["TYPE"]
1396
+ file_name = f"{model}.json"
1397
+
1398
+ # anonymize device_descriptions
1399
+ anonymize_device_descriptions: list[DeviceDescription] = []
1400
+ for device_description in device_descriptions.values():
1401
+ new_device_description: DeviceDescription = device_description.copy()
1402
+ new_device_description["ADDRESS"] = self._anonymize_address(address=new_device_description["ADDRESS"])
1403
+ if new_device_description.get("PARENT"):
1404
+ new_device_description["PARENT"] = new_device_description["ADDRESS"].split(ADDRESS_SEPARATOR)[0]
1405
+ elif new_device_description.get("CHILDREN"):
1406
+ new_device_description["CHILDREN"] = [
1407
+ self._anonymize_address(address=a) for a in new_device_description["CHILDREN"]
1408
+ ]
1409
+ anonymize_device_descriptions.append(new_device_description)
1410
+
1411
+ # anonymize paramset_descriptions
1412
+ anonymize_paramset_descriptions: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
1413
+ for address, paramset_description in paramset_descriptions.items():
1414
+ anonymize_paramset_descriptions[self._anonymize_address(address=address)] = paramset_description
1415
+
1416
+ # Save device_descriptions for device to file.
1417
+ await self._save(
1418
+ directory=f"{self._storage_directory}/{DEVICE_DESCRIPTIONS_DIR}",
1419
+ file_name=file_name,
1420
+ data=anonymize_device_descriptions,
1421
+ )
1422
+
1423
+ # Save device_descriptions for device to file.
1424
+ await self._save(
1425
+ directory=f"{self._storage_directory}/{PARAMSET_DESCRIPTIONS_DIR}",
1426
+ file_name=file_name,
1427
+ data=anonymize_paramset_descriptions,
1428
+ )
1429
+
1430
+ def _anonymize_address(self, *, address: str) -> str:
1431
+ address_parts = address.split(ADDRESS_SEPARATOR)
1432
+ address_parts[0] = self._random_id
1433
+ return ADDRESS_SEPARATOR.join(address_parts)
1434
+
1435
+ async def _save(self, *, directory: str, file_name: str, data: Any) -> DataOperationResult:
1436
+ """Save file to disk."""
1437
+
1438
+ def perform_save() -> DataOperationResult:
1439
+ if not check_or_create_directory(directory=directory):
1440
+ return DataOperationResult.NO_SAVE # pragma: no cover
1441
+ with open(file=os.path.join(directory, file_name), mode="wb") as fptr:
1442
+ fptr.write(orjson.dumps(data, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS))
1443
+ return DataOperationResult.SAVE_SUCCESS
1444
+
1445
+ return await self._central.looper.async_add_executor_job(perform_save, name="save-device-description")