aiohomematic 2025.8.6__py3-none-any.whl

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

Potentially problematic release.


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

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