aiohomematic 2026.1.29__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. aiohomematic/__init__.py +110 -0
  2. aiohomematic/_log_context_protocol.py +29 -0
  3. aiohomematic/api.py +410 -0
  4. aiohomematic/async_support.py +250 -0
  5. aiohomematic/backend_detection.py +462 -0
  6. aiohomematic/central/__init__.py +103 -0
  7. aiohomematic/central/async_rpc_server.py +760 -0
  8. aiohomematic/central/central_unit.py +1152 -0
  9. aiohomematic/central/config.py +463 -0
  10. aiohomematic/central/config_builder.py +772 -0
  11. aiohomematic/central/connection_state.py +160 -0
  12. aiohomematic/central/coordinators/__init__.py +38 -0
  13. aiohomematic/central/coordinators/cache.py +414 -0
  14. aiohomematic/central/coordinators/client.py +480 -0
  15. aiohomematic/central/coordinators/connection_recovery.py +1141 -0
  16. aiohomematic/central/coordinators/device.py +1166 -0
  17. aiohomematic/central/coordinators/event.py +514 -0
  18. aiohomematic/central/coordinators/hub.py +532 -0
  19. aiohomematic/central/decorators.py +184 -0
  20. aiohomematic/central/device_registry.py +229 -0
  21. aiohomematic/central/events/__init__.py +104 -0
  22. aiohomematic/central/events/bus.py +1392 -0
  23. aiohomematic/central/events/integration.py +424 -0
  24. aiohomematic/central/events/types.py +194 -0
  25. aiohomematic/central/health.py +762 -0
  26. aiohomematic/central/rpc_server.py +353 -0
  27. aiohomematic/central/scheduler.py +794 -0
  28. aiohomematic/central/state_machine.py +391 -0
  29. aiohomematic/client/__init__.py +203 -0
  30. aiohomematic/client/_rpc_errors.py +187 -0
  31. aiohomematic/client/backends/__init__.py +48 -0
  32. aiohomematic/client/backends/base.py +335 -0
  33. aiohomematic/client/backends/capabilities.py +138 -0
  34. aiohomematic/client/backends/ccu.py +487 -0
  35. aiohomematic/client/backends/factory.py +116 -0
  36. aiohomematic/client/backends/homegear.py +294 -0
  37. aiohomematic/client/backends/json_ccu.py +252 -0
  38. aiohomematic/client/backends/protocol.py +316 -0
  39. aiohomematic/client/ccu.py +1857 -0
  40. aiohomematic/client/circuit_breaker.py +459 -0
  41. aiohomematic/client/config.py +64 -0
  42. aiohomematic/client/handlers/__init__.py +40 -0
  43. aiohomematic/client/handlers/backup.py +157 -0
  44. aiohomematic/client/handlers/base.py +79 -0
  45. aiohomematic/client/handlers/device_ops.py +1085 -0
  46. aiohomematic/client/handlers/firmware.py +144 -0
  47. aiohomematic/client/handlers/link_mgmt.py +199 -0
  48. aiohomematic/client/handlers/metadata.py +436 -0
  49. aiohomematic/client/handlers/programs.py +144 -0
  50. aiohomematic/client/handlers/sysvars.py +100 -0
  51. aiohomematic/client/interface_client.py +1304 -0
  52. aiohomematic/client/json_rpc.py +2068 -0
  53. aiohomematic/client/request_coalescer.py +282 -0
  54. aiohomematic/client/rpc_proxy.py +629 -0
  55. aiohomematic/client/state_machine.py +324 -0
  56. aiohomematic/const.py +2207 -0
  57. aiohomematic/context.py +275 -0
  58. aiohomematic/converter.py +270 -0
  59. aiohomematic/decorators.py +390 -0
  60. aiohomematic/exceptions.py +185 -0
  61. aiohomematic/hmcli.py +997 -0
  62. aiohomematic/i18n.py +193 -0
  63. aiohomematic/interfaces/__init__.py +407 -0
  64. aiohomematic/interfaces/central.py +1067 -0
  65. aiohomematic/interfaces/client.py +1096 -0
  66. aiohomematic/interfaces/coordinators.py +63 -0
  67. aiohomematic/interfaces/model.py +1921 -0
  68. aiohomematic/interfaces/operations.py +217 -0
  69. aiohomematic/logging_context.py +134 -0
  70. aiohomematic/metrics/__init__.py +125 -0
  71. aiohomematic/metrics/_protocols.py +140 -0
  72. aiohomematic/metrics/aggregator.py +534 -0
  73. aiohomematic/metrics/dataclasses.py +489 -0
  74. aiohomematic/metrics/emitter.py +292 -0
  75. aiohomematic/metrics/events.py +183 -0
  76. aiohomematic/metrics/keys.py +300 -0
  77. aiohomematic/metrics/observer.py +563 -0
  78. aiohomematic/metrics/stats.py +172 -0
  79. aiohomematic/model/__init__.py +189 -0
  80. aiohomematic/model/availability.py +65 -0
  81. aiohomematic/model/calculated/__init__.py +89 -0
  82. aiohomematic/model/calculated/climate.py +276 -0
  83. aiohomematic/model/calculated/data_point.py +315 -0
  84. aiohomematic/model/calculated/field.py +147 -0
  85. aiohomematic/model/calculated/operating_voltage_level.py +286 -0
  86. aiohomematic/model/calculated/support.py +232 -0
  87. aiohomematic/model/custom/__init__.py +214 -0
  88. aiohomematic/model/custom/capabilities/__init__.py +67 -0
  89. aiohomematic/model/custom/capabilities/climate.py +41 -0
  90. aiohomematic/model/custom/capabilities/light.py +87 -0
  91. aiohomematic/model/custom/capabilities/lock.py +44 -0
  92. aiohomematic/model/custom/capabilities/siren.py +63 -0
  93. aiohomematic/model/custom/climate.py +1130 -0
  94. aiohomematic/model/custom/cover.py +722 -0
  95. aiohomematic/model/custom/data_point.py +360 -0
  96. aiohomematic/model/custom/definition.py +300 -0
  97. aiohomematic/model/custom/field.py +89 -0
  98. aiohomematic/model/custom/light.py +1174 -0
  99. aiohomematic/model/custom/lock.py +322 -0
  100. aiohomematic/model/custom/mixins.py +445 -0
  101. aiohomematic/model/custom/profile.py +945 -0
  102. aiohomematic/model/custom/registry.py +251 -0
  103. aiohomematic/model/custom/siren.py +462 -0
  104. aiohomematic/model/custom/switch.py +195 -0
  105. aiohomematic/model/custom/text_display.py +289 -0
  106. aiohomematic/model/custom/valve.py +78 -0
  107. aiohomematic/model/data_point.py +1416 -0
  108. aiohomematic/model/device.py +1840 -0
  109. aiohomematic/model/event.py +216 -0
  110. aiohomematic/model/generic/__init__.py +327 -0
  111. aiohomematic/model/generic/action.py +40 -0
  112. aiohomematic/model/generic/action_select.py +62 -0
  113. aiohomematic/model/generic/binary_sensor.py +30 -0
  114. aiohomematic/model/generic/button.py +31 -0
  115. aiohomematic/model/generic/data_point.py +177 -0
  116. aiohomematic/model/generic/dummy.py +150 -0
  117. aiohomematic/model/generic/number.py +76 -0
  118. aiohomematic/model/generic/select.py +56 -0
  119. aiohomematic/model/generic/sensor.py +76 -0
  120. aiohomematic/model/generic/switch.py +54 -0
  121. aiohomematic/model/generic/text.py +33 -0
  122. aiohomematic/model/hub/__init__.py +100 -0
  123. aiohomematic/model/hub/binary_sensor.py +24 -0
  124. aiohomematic/model/hub/button.py +28 -0
  125. aiohomematic/model/hub/connectivity.py +190 -0
  126. aiohomematic/model/hub/data_point.py +342 -0
  127. aiohomematic/model/hub/hub.py +864 -0
  128. aiohomematic/model/hub/inbox.py +135 -0
  129. aiohomematic/model/hub/install_mode.py +393 -0
  130. aiohomematic/model/hub/metrics.py +208 -0
  131. aiohomematic/model/hub/number.py +42 -0
  132. aiohomematic/model/hub/select.py +52 -0
  133. aiohomematic/model/hub/sensor.py +37 -0
  134. aiohomematic/model/hub/switch.py +43 -0
  135. aiohomematic/model/hub/text.py +30 -0
  136. aiohomematic/model/hub/update.py +221 -0
  137. aiohomematic/model/support.py +592 -0
  138. aiohomematic/model/update.py +140 -0
  139. aiohomematic/model/week_profile.py +1827 -0
  140. aiohomematic/property_decorators.py +719 -0
  141. aiohomematic/py.typed +0 -0
  142. aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
  143. aiohomematic/rega_scripts/create_backup_start.fn +28 -0
  144. aiohomematic/rega_scripts/create_backup_status.fn +89 -0
  145. aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
  146. aiohomematic/rega_scripts/get_backend_info.fn +25 -0
  147. aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
  148. aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
  149. aiohomematic/rega_scripts/get_serial.fn +44 -0
  150. aiohomematic/rega_scripts/get_service_messages.fn +83 -0
  151. aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
  152. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
  153. aiohomematic/rega_scripts/set_program_state.fn +17 -0
  154. aiohomematic/rega_scripts/set_system_variable.fn +19 -0
  155. aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
  156. aiohomematic/schemas.py +256 -0
  157. aiohomematic/store/__init__.py +55 -0
  158. aiohomematic/store/dynamic/__init__.py +43 -0
  159. aiohomematic/store/dynamic/command.py +250 -0
  160. aiohomematic/store/dynamic/data.py +175 -0
  161. aiohomematic/store/dynamic/details.py +187 -0
  162. aiohomematic/store/dynamic/ping_pong.py +416 -0
  163. aiohomematic/store/persistent/__init__.py +71 -0
  164. aiohomematic/store/persistent/base.py +285 -0
  165. aiohomematic/store/persistent/device.py +233 -0
  166. aiohomematic/store/persistent/incident.py +380 -0
  167. aiohomematic/store/persistent/paramset.py +241 -0
  168. aiohomematic/store/persistent/session.py +556 -0
  169. aiohomematic/store/serialization.py +150 -0
  170. aiohomematic/store/storage.py +689 -0
  171. aiohomematic/store/types.py +526 -0
  172. aiohomematic/store/visibility/__init__.py +40 -0
  173. aiohomematic/store/visibility/parser.py +141 -0
  174. aiohomematic/store/visibility/registry.py +722 -0
  175. aiohomematic/store/visibility/rules.py +307 -0
  176. aiohomematic/strings.json +237 -0
  177. aiohomematic/support.py +706 -0
  178. aiohomematic/tracing.py +236 -0
  179. aiohomematic/translations/de.json +237 -0
  180. aiohomematic/translations/en.json +237 -0
  181. aiohomematic/type_aliases.py +51 -0
  182. aiohomematic/validator.py +128 -0
  183. aiohomematic-2026.1.29.dist-info/METADATA +296 -0
  184. aiohomematic-2026.1.29.dist-info/RECORD +188 -0
  185. aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
  186. aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
  187. aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
  188. aiohomematic-2026.1.29.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1840 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
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
+ -----------
13
+ - Device: Encapsulates metadata, channels, and operations for a single device.
14
+ - Channel: Represents a functional channel with its data points and events.
15
+
16
+ Other components
17
+ ----------------
18
+ - _ValueCache: Lazy loading and caching of parameter values to minimize RPCs.
19
+ Accessed externally via ``device.value_cache``.
20
+ - _DefinitionExporter: Internal utility to export device and paramset descriptions.
21
+
22
+ Architecture notes
23
+ ------------------
24
+ Device acts as a **Facade** aggregating 15+ protocol interfaces injected via
25
+ constructor. This design enables:
26
+ - Centralized access to all protocol interfaces for DataPoints
27
+ - Single instantiation point in DeviceCoordinator.create_devices()
28
+ - Full Protocol-based dependency injection (no direct CentralUnit reference)
29
+
30
+ Device responsibilities are organized into 7 areas:
31
+ 1. **Metadata & Identity**: Address, model, name, manufacturer, firmware version
32
+ 2. **Channel Hierarchy**: Channel management, grouping, data point access
33
+ 3. **Value Caching**: Lazy loading and caching of parameter values
34
+ 4. **Availability & State**: Device availability, config pending status
35
+ 5. **Firmware Management**: Firmware updates, available updates, update state
36
+ 6. **Links & Export**: Central link management, device definition export
37
+ 7. **Week Profile**: Schedule/time program support
38
+
39
+ The Device/Channel classes are the anchor used by generic, custom, calculated,
40
+ and hub model code to attach data points and events.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import asyncio
46
+ from collections.abc import Mapping
47
+ from datetime import datetime
48
+ from functools import partial
49
+ import logging
50
+ import os
51
+ import random
52
+ from typing import Any, Final, cast
53
+ import zipfile
54
+
55
+ import orjson
56
+
57
+ from aiohomematic import i18n
58
+ from aiohomematic.async_support import loop_check
59
+ from aiohomematic.central.events import DeviceStateChangedEvent, FirmwareStateChangedEvent, LinkPeerChangedEvent
60
+ from aiohomematic.const import (
61
+ ADDRESS_SEPARATOR,
62
+ CLICK_EVENTS,
63
+ DEVICE_DESCRIPTIONS_ZIP_DIR,
64
+ IDENTIFIER_SEPARATOR,
65
+ INIT_DATETIME,
66
+ NO_CACHE_ENTRY,
67
+ PARAMSET_DESCRIPTIONS_ZIP_DIR,
68
+ RELEVANT_INIT_PARAMETERS,
69
+ REPORT_VALUE_USAGE_DATA,
70
+ REPORT_VALUE_USAGE_VALUE_ID,
71
+ VIRTUAL_REMOTE_MODELS,
72
+ WEEK_PROFILE_PATTERN,
73
+ CalculatedParameter,
74
+ CallSource,
75
+ DataPointCategory,
76
+ DataPointKey,
77
+ DataPointUsage,
78
+ DeviceDescription,
79
+ DeviceFirmwareState,
80
+ DeviceTriggerEventType,
81
+ ForcedDeviceAvailability,
82
+ Interface,
83
+ Manufacturer,
84
+ Parameter,
85
+ ParameterData,
86
+ ParamsetKey,
87
+ ProductGroup,
88
+ RxMode,
89
+ ServiceScope,
90
+ check_ignore_model_on_initial_load,
91
+ get_link_source_categories,
92
+ get_link_target_categories,
93
+ )
94
+ from aiohomematic.decorators import inspector
95
+ from aiohomematic.exceptions import (
96
+ AioHomematicException,
97
+ BaseHomematicException,
98
+ ClientException,
99
+ DescriptionNotFoundException,
100
+ )
101
+ from aiohomematic.interfaces import (
102
+ BaseParameterDataPointProtocol,
103
+ CalculatedDataPointProtocol,
104
+ CallbackDataPointProtocol,
105
+ CentralInfoProtocol,
106
+ ChannelLookupProtocol,
107
+ ChannelProtocol,
108
+ ClientProtocol,
109
+ ClientProviderProtocol,
110
+ ConfigProviderProtocol,
111
+ CustomDataPointProtocol,
112
+ DataCacheProviderProtocol,
113
+ DataPointProviderProtocol,
114
+ DeviceDescriptionProviderProtocol,
115
+ DeviceDetailsProviderProtocol,
116
+ DeviceProtocol,
117
+ EventBusProviderProtocol,
118
+ EventPublisherProtocol,
119
+ EventSubscriptionManagerProtocol,
120
+ FileOperationsProtocol,
121
+ GenericDataPointProtocol,
122
+ GenericDataPointProtocolAny,
123
+ GenericEventProtocol,
124
+ GenericEventProtocolAny,
125
+ ParameterVisibilityProviderProtocol,
126
+ ParamsetDescriptionProviderProtocol,
127
+ TaskSchedulerProtocol,
128
+ )
129
+ from aiohomematic.interfaces.central import FirmwareDataRefresherProtocol
130
+ from aiohomematic.model import week_profile as wp
131
+ from aiohomematic.model.availability import AvailabilityInfo
132
+ from aiohomematic.model.custom import data_point as hmce, definition as hmed
133
+ from aiohomematic.model.generic import DpBinarySensor
134
+ from aiohomematic.model.support import (
135
+ ChannelNameData,
136
+ generate_channel_unique_id,
137
+ get_channel_name_data,
138
+ get_device_name,
139
+ )
140
+ from aiohomematic.model.update import DpUpdate
141
+ from aiohomematic.property_decorators import DelegatedProperty, Kind, hm_property, info_property, state_property
142
+ from aiohomematic.support import (
143
+ CacheEntry,
144
+ LogContextMixin,
145
+ PayloadMixin,
146
+ extract_exc_args,
147
+ get_channel_address,
148
+ get_channel_no,
149
+ get_rx_modes,
150
+ )
151
+ from aiohomematic.type_aliases import (
152
+ DeviceUpdatedHandler,
153
+ FirmwareUpdateHandler,
154
+ LinkPeerChangedHandler,
155
+ UnsubscribeCallback,
156
+ )
157
+
158
+ __all__ = ["Channel", "Device"]
159
+
160
+ _LOGGER: Final = logging.getLogger(__name__)
161
+
162
+
163
+ class Device(DeviceProtocol, LogContextMixin, PayloadMixin):
164
+ """
165
+ Represent a Homematic device with channels and data points.
166
+
167
+ Device is the central runtime model for a physical Homematic device. It acts
168
+ as a **Facade** that aggregates 15+ protocol interfaces (injected via constructor)
169
+ and provides unified access for all DataPoint classes.
170
+
171
+ Responsibilities
172
+ ----------------
173
+ 1. **Metadata & Identity**: Device address, model, name, manufacturer, interface.
174
+ 2. **Channel Hierarchy**: Channel creation, grouping, data point lookup.
175
+ 3. **Value Caching**: Lazy loading via ``_ValueCache`` (accessed via ``value_cache``).
176
+ 4. **Availability & State**: UN_REACH/STICKY_UN_REACH handling, forced availability.
177
+ 5. **Firmware Management**: Firmware version, available updates, update operations.
178
+ 6. **Links & Export**: Central link management for press events, definition export.
179
+ 7. **Week Profile**: Schedule support for climate devices.
180
+
181
+ Instantiation
182
+ -------------
183
+ Devices are created exclusively by ``DeviceCoordinator.create_devices()``. All
184
+ dependencies are injected as protocol interfaces, enabling full dependency
185
+ injection without direct CentralUnit references.
186
+
187
+ Protocol compliance
188
+ -------------------
189
+ Implements ``DeviceProtocol`` which is a composite of sub-protocols:
190
+
191
+ - ``DeviceIdentityProtocol``: Basic identification (address, name, model, manufacturer)
192
+ - ``DeviceChannelAccessProtocol``: Channel and DataPoint access methods
193
+ - ``DeviceAvailabilityProtocol``: Availability state management
194
+ - ``DeviceFirmwareProtocol``: Firmware information and update operations
195
+ - ``DeviceLinkManagementProtocol``: Central link operations
196
+ - ``DeviceGroupManagementProtocol``: Channel group management
197
+ - ``DeviceConfigurationProtocol``: Device configuration and metadata
198
+ - ``DeviceWeekProfileProtocol``: Week profile support
199
+ - ``DeviceProvidersProtocol``: Protocol interface providers
200
+ - ``DeviceLifecycleProtocol``: Lifecycle methods
201
+
202
+ Consumers can depend on specific sub-protocols for narrower contracts.
203
+ """
204
+
205
+ __slots__ = (
206
+ "_address",
207
+ "_cached_allow_undefined_generic_data_points",
208
+ "_cached_has_sub_devices",
209
+ "_cached_relevant_for_central_link_management",
210
+ "_central_info",
211
+ "_channel_to_group",
212
+ "_channel_lookup",
213
+ "_channels",
214
+ "_client",
215
+ "_client_provider",
216
+ "_config_provider",
217
+ "_data_cache_provider",
218
+ "_data_point_provider",
219
+ "_device_description",
220
+ "_device_data_refresher",
221
+ "_device_description_provider",
222
+ "_device_details_provider",
223
+ "_event_bus_provider",
224
+ "_event_publisher",
225
+ "_event_subscription_manager",
226
+ "_file_operations",
227
+ "_forced_availability",
228
+ "_group_channels",
229
+ "_has_custom_data_point_definition",
230
+ "_rega_id",
231
+ "_ignore_for_custom_data_point",
232
+ "_ignore_on_initial_load",
233
+ "_interface",
234
+ "_interface_id",
235
+ "_is_updatable",
236
+ "_manufacturer",
237
+ "_model",
238
+ "_modified_at",
239
+ "_name",
240
+ "_parameter_visibility_provider",
241
+ "_paramset_description_provider",
242
+ "_product_group",
243
+ "_rooms",
244
+ "_rx_modes",
245
+ "_sub_model",
246
+ "_task_scheduler",
247
+ "_update_data_point",
248
+ "_value_cache",
249
+ "_week_profile",
250
+ )
251
+
252
+ def __init__(
253
+ self,
254
+ *,
255
+ interface_id: str,
256
+ device_address: str,
257
+ central_info: CentralInfoProtocol,
258
+ channel_lookup: ChannelLookupProtocol,
259
+ client_provider: ClientProviderProtocol,
260
+ config_provider: ConfigProviderProtocol,
261
+ data_cache_provider: DataCacheProviderProtocol,
262
+ data_point_provider: DataPointProviderProtocol,
263
+ device_data_refresher: FirmwareDataRefresherProtocol,
264
+ device_description_provider: DeviceDescriptionProviderProtocol,
265
+ device_details_provider: DeviceDetailsProviderProtocol,
266
+ event_bus_provider: EventBusProviderProtocol,
267
+ event_publisher: EventPublisherProtocol,
268
+ event_subscription_manager: EventSubscriptionManagerProtocol,
269
+ file_operations: FileOperationsProtocol,
270
+ parameter_visibility_provider: ParameterVisibilityProviderProtocol,
271
+ paramset_description_provider: ParamsetDescriptionProviderProtocol,
272
+ task_scheduler: TaskSchedulerProtocol,
273
+ ) -> None:
274
+ """Initialize the device object."""
275
+ PayloadMixin.__init__(self)
276
+ self._interface_id: Final = interface_id
277
+ self._address: Final = device_address
278
+ self._device_details_provider: Final = device_details_provider
279
+ self._device_description_provider: Final = device_description_provider
280
+ self._paramset_description_provider: Final = paramset_description_provider
281
+ self._parameter_visibility_provider: Final = parameter_visibility_provider
282
+ self._client_provider: Final = client_provider
283
+ self._config_provider: Final = config_provider
284
+ self._central_info: Final = central_info
285
+ self._event_bus_provider: Final = event_bus_provider
286
+ self._event_publisher: Final = event_publisher
287
+ self._task_scheduler: Final = task_scheduler
288
+ self._file_operations: Final = file_operations
289
+ self._device_data_refresher: Final = device_data_refresher
290
+ self._data_cache_provider: Final = data_cache_provider
291
+ self._data_point_provider: Final = data_point_provider
292
+ self._channel_lookup: Final = channel_lookup
293
+ self._event_subscription_manager: Final = event_subscription_manager
294
+ self._channel_to_group: Final[dict[int | None, int]] = {}
295
+ self._group_channels: Final[dict[int, set[int | None]]] = {}
296
+ self._rega_id: Final = device_details_provider.get_address_id(address=device_address)
297
+ self._interface: Final = device_details_provider.get_interface(address=device_address)
298
+ self._client: Final = client_provider.get_client(interface_id=interface_id)
299
+ self._device_description = self._device_description_provider.get_device_description(
300
+ interface_id=interface_id, address=device_address
301
+ )
302
+ _LOGGER.debug(
303
+ "__INIT__: Initializing device: %s, %s",
304
+ interface_id,
305
+ device_address,
306
+ )
307
+
308
+ self._modified_at: datetime = INIT_DATETIME
309
+ self._forced_availability: ForcedDeviceAvailability = ForcedDeviceAvailability.NOT_SET
310
+ self._model: Final[str] = self._device_description["TYPE"]
311
+ self._ignore_on_initial_load: Final[bool] = check_ignore_model_on_initial_load(model=self._model)
312
+ self._is_updatable: Final = self._device_description.get("UPDATABLE") or False
313
+ self._rx_modes: Final = get_rx_modes(mode=self._device_description.get("RX_MODE", 0))
314
+ self._sub_model: Final[str | None] = self._device_description.get("SUBTYPE")
315
+ self._ignore_for_custom_data_point: Final[bool] = parameter_visibility_provider.model_is_ignored(
316
+ model=self._model
317
+ )
318
+ self._manufacturer = self._identify_manufacturer()
319
+ self._product_group: Final[ProductGroup] = self._client.get_product_group(model=self._model)
320
+ # marker if device will be created as custom data_point
321
+ self._has_custom_data_point_definition: Final = (
322
+ hmed.data_point_definition_exists(model=self._model) and not self._ignore_for_custom_data_point
323
+ )
324
+ self._name: Final = get_device_name(
325
+ device_details_provider=device_details_provider,
326
+ device_address=device_address,
327
+ model=self._model,
328
+ )
329
+ channel_addresses = tuple(
330
+ [device_address] + [address for address in self._device_description.get("CHILDREN", []) if address != ""]
331
+ )
332
+ self._channels: Final[dict[str, ChannelProtocol]] = {}
333
+ for address in channel_addresses:
334
+ try:
335
+ self._channels[address] = Channel(device=self, channel_address=address)
336
+ except DescriptionNotFoundException:
337
+ _LOGGER.warning(i18n.tr(key="log.model.device.channel_description_not_found", address=address))
338
+ self._value_cache: Final[_ValueCache] = _ValueCache(device=self)
339
+ self._rooms: Final = device_details_provider.get_device_rooms(device_address=device_address)
340
+ self._update_data_point: Final = DpUpdate(device=self) if self.is_updatable else None
341
+ self._week_profile: wp.WeekProfile[dict[Any, Any]] | None = None
342
+ _LOGGER.debug(
343
+ "__INIT__: Initialized device: %s, %s, %s, %s",
344
+ self._interface_id,
345
+ self._address,
346
+ self._model,
347
+ self._name,
348
+ )
349
+
350
+ def __str__(self) -> str:
351
+ """Provide some useful information."""
352
+ return (
353
+ f"address: {self._address}, "
354
+ f"model: {self._model}, "
355
+ f"name: {self._name}, "
356
+ f"generic dps: {len(self.generic_data_points)}, "
357
+ f"calculated dps: {len(self.calculated_data_points)}, "
358
+ f"custom dps: {len(self.custom_data_points)}, "
359
+ f"events: {len(self.generic_events)}"
360
+ )
361
+
362
+ address: Final = DelegatedProperty[str](path="_address", kind=Kind.INFO, log_context=True)
363
+ central_info: Final = DelegatedProperty[CentralInfoProtocol](path="_central_info")
364
+ channel_lookup: Final = DelegatedProperty[ChannelLookupProtocol](path="_channel_lookup")
365
+ channels: Final = DelegatedProperty[Mapping[str, ChannelProtocol]](path="_channels")
366
+ client: Final = DelegatedProperty[ClientProtocol](path="_client")
367
+ config_provider: Final = DelegatedProperty[ConfigProviderProtocol](path="_config_provider")
368
+ data_cache_provider: Final = DelegatedProperty[DataCacheProviderProtocol](path="_data_cache_provider")
369
+ data_point_provider: Final = DelegatedProperty[DataPointProviderProtocol](path="_data_point_provider")
370
+ device_data_refresher: Final = DelegatedProperty[FirmwareDataRefresherProtocol](path="_device_data_refresher")
371
+ device_description_provider: Final = DelegatedProperty[DeviceDescriptionProviderProtocol](
372
+ path="_device_description_provider"
373
+ )
374
+ device_details_provider: Final = DelegatedProperty[DeviceDetailsProviderProtocol](path="_device_details_provider")
375
+ event_bus_provider: Final = DelegatedProperty[EventBusProviderProtocol](path="_event_bus_provider")
376
+ event_publisher: Final = DelegatedProperty[EventPublisherProtocol](path="_event_publisher")
377
+ event_subscription_manager: Final = DelegatedProperty[EventSubscriptionManagerProtocol](
378
+ path="_event_subscription_manager"
379
+ )
380
+ has_custom_data_point_definition: Final = DelegatedProperty[bool](path="_has_custom_data_point_definition")
381
+ ignore_for_custom_data_point: Final = DelegatedProperty[bool](path="_ignore_for_custom_data_point")
382
+ ignore_on_initial_load: Final = DelegatedProperty[bool](path="_ignore_on_initial_load")
383
+ interface: Final = DelegatedProperty[Interface](path="_interface")
384
+ interface_id: Final = DelegatedProperty[str](path="_interface_id", log_context=True)
385
+ is_updatable: Final = DelegatedProperty[bool](path="_is_updatable")
386
+ manufacturer: Final = DelegatedProperty[str](path="_manufacturer", kind=Kind.INFO)
387
+ model: Final = DelegatedProperty[str](path="_model", kind=Kind.INFO, log_context=True)
388
+ name: Final = DelegatedProperty[str](path="_name", kind=Kind.INFO)
389
+ parameter_visibility_provider: Final = DelegatedProperty[ParameterVisibilityProviderProtocol](
390
+ path="_parameter_visibility_provider"
391
+ )
392
+ paramset_description_provider: Final = DelegatedProperty[ParamsetDescriptionProviderProtocol](
393
+ path="_paramset_description_provider"
394
+ )
395
+ product_group: Final = DelegatedProperty[ProductGroup](path="_product_group")
396
+ rega_id: Final = DelegatedProperty[int](path="_rega_id")
397
+ rooms: Final = DelegatedProperty[set[str]](path="_rooms")
398
+ rx_modes: Final = DelegatedProperty[tuple[RxMode, ...]](path="_rx_modes")
399
+ sub_model: Final = DelegatedProperty[str | None](path="_sub_model")
400
+ task_scheduler: Final = DelegatedProperty[TaskSchedulerProtocol](path="_task_scheduler")
401
+ update_data_point: Final = DelegatedProperty[DpUpdate | None](path="_update_data_point")
402
+ value_cache: Final = DelegatedProperty["_ValueCache"](path="_value_cache")
403
+ week_profile: Final = DelegatedProperty[wp.WeekProfile[dict[Any, Any]] | None](path="_week_profile")
404
+
405
+ @property
406
+ def _dp_config_pending(self) -> DpBinarySensor | None:
407
+ """Return the CONFIG_PENDING data point."""
408
+ return cast(
409
+ DpBinarySensor | None,
410
+ self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.CONFIG_PENDING),
411
+ )
412
+
413
+ @property
414
+ def _dp_sticky_un_reach(self) -> DpBinarySensor | None:
415
+ """Return the STICKY_UN_REACH data point."""
416
+ return cast(
417
+ DpBinarySensor | None,
418
+ self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.STICKY_UN_REACH),
419
+ )
420
+
421
+ @property
422
+ def _dp_un_reach(self) -> DpBinarySensor | None:
423
+ """Return the UN_REACH data point."""
424
+ return cast(
425
+ DpBinarySensor | None,
426
+ self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.UN_REACH),
427
+ )
428
+
429
+ @property
430
+ def availability(self) -> AvailabilityInfo:
431
+ """
432
+ Return bundled availability information for the device.
433
+
434
+ Provides a unified view of:
435
+ - Reachability (from UNREACH/STICKY_UNREACH)
436
+ - Last updated timestamp (from most recent data point)
437
+ - Battery level (from OperatingVoltageLevel or BATTERY_STATE)
438
+ - Low battery indicator (from LOW_BAT)
439
+ - Signal strength (from RSSI_DEVICE)
440
+ """
441
+ return AvailabilityInfo(
442
+ is_reachable=self._get_is_reachable(),
443
+ last_updated=self._get_last_updated(),
444
+ battery_level=self._get_battery_level(),
445
+ low_battery=self._get_low_battery(),
446
+ signal_strength=self._get_signal_strength(),
447
+ )
448
+
449
+ @property
450
+ def available_firmware(self) -> str | None:
451
+ """Return the available firmware of the device."""
452
+ return str(self._device_description.get("AVAILABLE_FIRMWARE", ""))
453
+
454
+ @property
455
+ def calculated_data_points(self) -> tuple[CalculatedDataPointProtocol, ...]:
456
+ """Return the generic data points."""
457
+ data_points: list[CalculatedDataPointProtocol] = []
458
+ for channel in self._channels.values():
459
+ data_points.extend(channel.calculated_data_points)
460
+ return tuple(data_points)
461
+
462
+ @property
463
+ def config_pending(self) -> bool:
464
+ """Return if a config change of the device is pending."""
465
+ if self._dp_config_pending is not None and self._dp_config_pending.value is not None:
466
+ return self._dp_config_pending.value is True
467
+ return False
468
+
469
+ @property
470
+ def custom_data_points(self) -> tuple[hmce.CustomDataPoint, ...]:
471
+ """Return the custom data points."""
472
+ return tuple(
473
+ cast(hmce.CustomDataPoint, channel.custom_data_point)
474
+ for channel in self._channels.values()
475
+ if channel.custom_data_point is not None
476
+ )
477
+
478
+ @property
479
+ def data_point_paths(self) -> tuple[str, ...]:
480
+ """Return the data point paths."""
481
+ data_point_paths: list[str] = []
482
+ for channel in self._channels.values():
483
+ data_point_paths.extend(channel.data_point_paths)
484
+ return tuple(data_point_paths)
485
+
486
+ @property
487
+ def default_schedule_channel(self) -> ChannelProtocol | None:
488
+ """Return the schedule channel address."""
489
+ for channel in self._channels.values():
490
+ if channel.is_schedule_channel:
491
+ return channel
492
+ return None
493
+
494
+ @property
495
+ def firmware_updatable(self) -> bool:
496
+ """Return the firmware update state of the device."""
497
+ return self._device_description.get("FIRMWARE_UPDATABLE") or False
498
+
499
+ @property
500
+ def firmware_update_state(self) -> DeviceFirmwareState:
501
+ """Return the firmware update state of the device."""
502
+ return DeviceFirmwareState(self._device_description.get("FIRMWARE_UPDATE_STATE") or DeviceFirmwareState.UNKNOWN)
503
+
504
+ @property
505
+ def generic_data_points(self) -> tuple[GenericDataPointProtocolAny, ...]:
506
+ """Return the generic data points."""
507
+ data_points: list[GenericDataPointProtocolAny] = []
508
+ for channel in self._channels.values():
509
+ data_points.extend(channel.generic_data_points)
510
+ return tuple(data_points)
511
+
512
+ @property
513
+ def generic_events(self) -> tuple[GenericEventProtocolAny, ...]:
514
+ """Return the generic events."""
515
+ events: list[GenericEventProtocolAny] = []
516
+ for channel in self._channels.values():
517
+ events.extend(channel.generic_events)
518
+ return tuple(events)
519
+
520
+ @property
521
+ def has_week_profile(self) -> bool:
522
+ """Return if the device supports week profiles."""
523
+ if self._week_profile is None:
524
+ return False
525
+ return self._week_profile.has_schedule
526
+
527
+ @property
528
+ def info(self) -> Mapping[str, Any]:
529
+ """Return the device info."""
530
+ device_info = dict(self.info_payload)
531
+ device_info["central"] = self._central_info.info_payload
532
+ return device_info
533
+
534
+ @property
535
+ def link_peer_channels(self) -> Mapping[ChannelProtocol, tuple[ChannelProtocol, ...]]:
536
+ """Return the link peer channels."""
537
+ return {
538
+ channel: channel.link_peer_channels for channel in self._channels.values() if channel.link_peer_channels
539
+ }
540
+
541
+ @state_property
542
+ def available(self) -> bool:
543
+ """Return the availability of the device."""
544
+ return self._get_is_reachable()
545
+
546
+ @info_property
547
+ def firmware(self) -> str:
548
+ """Return the firmware of the device."""
549
+ return self._device_description.get("FIRMWARE") or "0.0"
550
+
551
+ @info_property
552
+ def identifier(self) -> str:
553
+ """Return the identifier of the device."""
554
+ return f"{self._address}{IDENTIFIER_SEPARATOR}{self._interface_id}"
555
+
556
+ @info_property
557
+ def room(self) -> str | None:
558
+ """Return the room of the device, if only one assigned in the backend."""
559
+ if self._rooms and len(self._rooms) == 1:
560
+ return list(self._rooms)[0]
561
+ if (maintenance_channel := self.get_channel(channel_address=f"{self._address}:0")) is not None:
562
+ return maintenance_channel.room
563
+ return None
564
+
565
+ @hm_property(cached=True)
566
+ def allow_undefined_generic_data_points(self) -> bool:
567
+ """Return if undefined generic data points of this device are allowed."""
568
+ return bool(
569
+ all(
570
+ channel.custom_data_point.allow_undefined_generic_data_points
571
+ for channel in self._channels.values()
572
+ if channel.custom_data_point is not None
573
+ )
574
+ )
575
+
576
+ @hm_property(cached=True)
577
+ def has_sub_devices(self) -> bool:
578
+ """Return if device has multiple sub device channels."""
579
+ # If there is only one channel group, no sub devices are needed
580
+ if len(self._group_channels) <= 1:
581
+ return False
582
+ count = 0
583
+ # If there are multiple channel groups with more than one channel, there are sub devices
584
+ for gcs in self._group_channels.values():
585
+ if len(gcs) > 1:
586
+ count += 1
587
+ if count > 1:
588
+ return True
589
+
590
+ return False
591
+
592
+ @hm_property(cached=True)
593
+ def relevant_for_central_link_management(self) -> bool:
594
+ """Return if channel is relevant for central link management."""
595
+ return (
596
+ self._interface in (Interface.BIDCOS_RF, Interface.BIDCOS_WIRED, Interface.HMIP_RF)
597
+ and self._model not in VIRTUAL_REMOTE_MODELS
598
+ )
599
+
600
+ def add_channel_to_group(self, *, group_no: int, channel_no: int | None) -> None:
601
+ """Add channel to group."""
602
+ if group_no not in self._group_channels:
603
+ self._group_channels[group_no] = set()
604
+ self._group_channels[group_no].add(channel_no)
605
+
606
+ if group_no not in self._channel_to_group:
607
+ self._channel_to_group[group_no] = group_no
608
+ if channel_no not in self._channel_to_group:
609
+ self._channel_to_group[channel_no] = group_no
610
+
611
+ @inspector
612
+ async def create_central_links(self) -> None:
613
+ """Create a central links to support press events on all channels with click events."""
614
+ if self.relevant_for_central_link_management: # pylint: disable=using-constant-test
615
+ for channel in self._channels.values():
616
+ await channel.create_central_link()
617
+
618
+ @inspector
619
+ async def export_device_definition(self) -> None:
620
+ """Export the device definition for current device."""
621
+ try:
622
+ device_exporter = _DefinitionExporter(device=self)
623
+ await device_exporter.export_data()
624
+ except Exception as exc:
625
+ raise AioHomematicException(
626
+ i18n.tr(
627
+ key="exception.model.device.export_device_definition.failed",
628
+ reason=extract_exc_args(exc=exc),
629
+ )
630
+ ) from exc
631
+
632
+ async def finalize_init(self) -> None:
633
+ """Finalize the device init action after model setup."""
634
+ await self.load_value_cache()
635
+ for channel in self._channels.values():
636
+ await channel.finalize_init()
637
+
638
+ def get_calculated_data_point(self, *, channel_address: str, parameter: str) -> CalculatedDataPointProtocol | None:
639
+ """Return a calculated data_point from device."""
640
+ if channel := self.get_channel(channel_address=channel_address):
641
+ return channel.get_calculated_data_point(parameter=parameter)
642
+ return None
643
+
644
+ def get_channel(self, *, channel_address: str) -> ChannelProtocol | None:
645
+ """Get channel of device."""
646
+ return self._channels.get(channel_address)
647
+
648
+ def get_channel_group_no(self, *, channel_no: int | None) -> int | None:
649
+ """Return the group no of the channel."""
650
+ return self._channel_to_group.get(channel_no)
651
+
652
+ def get_custom_data_point(self, *, channel_no: int) -> hmce.CustomDataPoint | None:
653
+ """Return a custom data_point from device."""
654
+ if channel := self.get_channel(
655
+ channel_address=get_channel_address(device_address=self._address, channel_no=channel_no)
656
+ ):
657
+ return cast(hmce.CustomDataPoint | None, channel.custom_data_point)
658
+ return None
659
+
660
+ def get_data_points(
661
+ self,
662
+ *,
663
+ category: DataPointCategory | None = None,
664
+ exclude_no_create: bool = True,
665
+ registered: bool | None = None,
666
+ ) -> tuple[CallbackDataPointProtocol, ...]:
667
+ """Get all data points of the device."""
668
+ all_data_points: list[CallbackDataPointProtocol] = []
669
+ if (
670
+ self._update_data_point
671
+ and (category is None or self._update_data_point.category == category)
672
+ and (
673
+ (exclude_no_create and self._update_data_point.usage != DataPointUsage.NO_CREATE)
674
+ or exclude_no_create is False
675
+ )
676
+ and (registered is None or self._update_data_point.is_registered == registered)
677
+ ):
678
+ all_data_points.append(self._update_data_point)
679
+ for channel in self._channels.values():
680
+ all_data_points.extend(
681
+ channel.get_data_points(category=category, exclude_no_create=exclude_no_create, registered=registered)
682
+ )
683
+ return tuple(all_data_points)
684
+
685
+ def get_events(
686
+ self, *, event_type: DeviceTriggerEventType, registered: bool | None = None
687
+ ) -> Mapping[int | None, tuple[GenericEventProtocolAny, ...]]:
688
+ """Return a list of specific events of a channel."""
689
+ events: dict[int | None, tuple[GenericEventProtocolAny, ...]] = {}
690
+ for channel in self._channels.values():
691
+ if (values := channel.get_events(event_type=event_type, registered=registered)) and len(values) > 0:
692
+ events[channel.no] = values
693
+ return events
694
+
695
+ def get_generic_data_point(
696
+ self,
697
+ *,
698
+ channel_address: str | None = None,
699
+ parameter: str | None = None,
700
+ paramset_key: ParamsetKey | None = None,
701
+ state_path: str | None = None,
702
+ ) -> GenericDataPointProtocolAny | None:
703
+ """Return a generic data_point from device."""
704
+ if channel_address is None:
705
+ for ch in self._channels.values():
706
+ if dp := ch.get_generic_data_point(
707
+ parameter=parameter, paramset_key=paramset_key, state_path=state_path
708
+ ):
709
+ return dp
710
+ return None
711
+
712
+ if channel := self.get_channel(channel_address=channel_address):
713
+ return channel.get_generic_data_point(parameter=parameter, paramset_key=paramset_key, state_path=state_path)
714
+ return None
715
+
716
+ def get_generic_event(
717
+ self, *, channel_address: str | None = None, parameter: str | None = None, state_path: str | None = None
718
+ ) -> GenericEventProtocolAny | None:
719
+ """Return a generic event from device."""
720
+ if channel_address is None:
721
+ for ch in self._channels.values():
722
+ if event := ch.get_generic_event(parameter=parameter, state_path=state_path):
723
+ return event
724
+ return None
725
+
726
+ if channel := self.get_channel(channel_address=channel_address):
727
+ return channel.get_generic_event(parameter=parameter, state_path=state_path)
728
+ return None
729
+
730
+ def get_readable_data_points(self, *, paramset_key: ParamsetKey) -> tuple[GenericDataPointProtocolAny, ...]:
731
+ """Return the list of readable master data points."""
732
+ data_points: list[GenericDataPointProtocolAny] = []
733
+ for channel in self._channels.values():
734
+ data_points.extend(channel.get_readable_data_points(paramset_key=paramset_key))
735
+ return tuple(data_points)
736
+
737
+ def identify_channel(self, *, text: str) -> ChannelProtocol | None:
738
+ """Identify channel within a text."""
739
+ for channel_address, channel in self._channels.items():
740
+ if text.endswith(channel_address):
741
+ return channel
742
+ if str(channel.rega_id) in text:
743
+ return channel
744
+ if str(channel.device.rega_id) in text:
745
+ return channel
746
+
747
+ return None
748
+
749
+ def init_week_profile(self, *, data_point: CustomDataPointProtocol) -> None:
750
+ """Initialize the device schedule."""
751
+ # Only initialize if week_profile supports schedule
752
+ if (
753
+ self._week_profile is None
754
+ and (week_profile := wp.create_week_profile(data_point=data_point)) is not None
755
+ and week_profile.has_schedule
756
+ ):
757
+ self._week_profile = week_profile
758
+
759
+ def is_in_multi_channel_group(self, *, channel_no: int | None) -> bool:
760
+ """Return if multiple channels are in the group."""
761
+ if channel_no is None:
762
+ return False
763
+
764
+ return len([s for s, m in self._channel_to_group.items() if m == self._channel_to_group.get(channel_no)]) > 1
765
+
766
+ @inspector(scope=ServiceScope.INTERNAL)
767
+ async def load_value_cache(self) -> None:
768
+ """Initialize the parameter cache."""
769
+ if len(self.generic_data_points) > 0:
770
+ await self._value_cache.init_base_data_points()
771
+ if len(self.generic_events) > 0:
772
+ await self._value_cache.init_readable_events()
773
+
774
+ async def on_config_changed(self) -> None:
775
+ """Do what is needed on device config change."""
776
+ for channel in self._channels.values():
777
+ await channel.on_config_changed()
778
+ if self._update_data_point:
779
+ await self._update_data_point.on_config_changed()
780
+
781
+ if self._week_profile:
782
+ await self._week_profile.reload_and_cache_schedule()
783
+
784
+ await self._file_operations.save_files(save_paramset_descriptions=True)
785
+ self.publish_device_updated_event()
786
+
787
+ @loop_check
788
+ def publish_device_updated_event(self, *, notify_data_points: bool = False) -> None:
789
+ """
790
+ Do what is needed when the state of the device has been updated.
791
+
792
+ Args:
793
+ notify_data_points: If True, also notify all data points so entities
794
+ refresh their state. This is needed for availability changes where
795
+ UN_REACH on channel :0 affects entities on other channels.
796
+
797
+ """
798
+ self._set_modified_at()
799
+
800
+ # Notify all data points so entities refresh their availability state.
801
+ # Entities subscribe to their own data point's updated event, not to device events.
802
+ if notify_data_points:
803
+ for dp in self.generic_data_points:
804
+ dp.publish_data_point_updated_event()
805
+
806
+ # Publish to EventBus asynchronously
807
+ async def _publish_device_updated() -> None:
808
+ await self._event_bus_provider.event_bus.publish(
809
+ event=DeviceStateChangedEvent(
810
+ timestamp=datetime.now(),
811
+ device_address=self._address,
812
+ )
813
+ )
814
+
815
+ self._task_scheduler.create_task(
816
+ target=_publish_device_updated,
817
+ name=f"device-updated-{self._address}",
818
+ )
819
+
820
+ def refresh_firmware_data(self) -> None:
821
+ """Refresh firmware data of the device."""
822
+ old_available_firmware = self.available_firmware
823
+ old_firmware = self.firmware
824
+ old_firmware_update_state = self.firmware_update_state
825
+ old_firmware_updatable = self.firmware_updatable
826
+
827
+ self._device_description = self._device_description_provider.get_device_description(
828
+ interface_id=self._interface_id, address=self._address
829
+ )
830
+
831
+ if (
832
+ old_available_firmware != self.available_firmware
833
+ or old_firmware != self.firmware
834
+ or old_firmware_update_state != self.firmware_update_state
835
+ or old_firmware_updatable != self.firmware_updatable
836
+ ):
837
+ # Publish to EventBus asynchronously
838
+ async def _publish_firmware_updated() -> None:
839
+ await self._event_bus_provider.event_bus.publish(
840
+ event=FirmwareStateChangedEvent(
841
+ timestamp=datetime.now(),
842
+ device_address=self._address,
843
+ )
844
+ )
845
+
846
+ self._task_scheduler.create_task(
847
+ target=_publish_firmware_updated,
848
+ name=f"firmware-updated-{self._address}",
849
+ )
850
+
851
+ def remove(self) -> None:
852
+ """Remove data points from collections and central."""
853
+ for channel in self._channels.values():
854
+ channel.remove()
855
+
856
+ @inspector
857
+ async def remove_central_links(self) -> None:
858
+ """Remove central links."""
859
+ if self.relevant_for_central_link_management: # pylint: disable=using-constant-test
860
+ for channel in self._channels.values():
861
+ await channel.remove_central_link()
862
+
863
+ @inspector
864
+ async def rename(self, *, new_name: str) -> bool:
865
+ """Rename the device on the CCU."""
866
+ return await self._client.rename_device(rega_id=self._rega_id, new_name=new_name)
867
+
868
+ def set_forced_availability(self, *, forced_availability: ForcedDeviceAvailability) -> None:
869
+ """Set the availability of the device."""
870
+ if self._forced_availability != forced_availability:
871
+ self._forced_availability = forced_availability
872
+ for dp in self.generic_data_points:
873
+ dp.publish_data_point_updated_event()
874
+
875
+ def subscribe_to_device_updated(self, *, handler: DeviceUpdatedHandler) -> UnsubscribeCallback:
876
+ """Subscribe update handler."""
877
+
878
+ # Create adapter that filters for this device's events
879
+ def event_handler(*, event: DeviceStateChangedEvent) -> None:
880
+ if event.device_address == self._address:
881
+ handler()
882
+
883
+ return self._event_bus_provider.event_bus.subscribe(
884
+ event_type=DeviceStateChangedEvent,
885
+ event_key=self._address,
886
+ handler=event_handler,
887
+ )
888
+
889
+ def subscribe_to_firmware_updated(self, *, handler: FirmwareUpdateHandler) -> UnsubscribeCallback:
890
+ """Subscribe firmware update handler."""
891
+
892
+ # Create adapter that filters for this device's events
893
+ def event_handler(*, event: FirmwareStateChangedEvent) -> None:
894
+ if event.device_address == self._address:
895
+ handler()
896
+
897
+ return self._event_bus_provider.event_bus.subscribe(
898
+ event_type=FirmwareStateChangedEvent,
899
+ event_key=self._address,
900
+ handler=event_handler,
901
+ )
902
+
903
+ @inspector
904
+ async def update_firmware(self, *, refresh_after_update_intervals: tuple[int, ...]) -> bool:
905
+ """Update the firmware of the Homematic device."""
906
+ update_result = await self._client.update_device_firmware(device_address=self._address)
907
+
908
+ async def refresh_data() -> None:
909
+ for refresh_interval in refresh_after_update_intervals:
910
+ await asyncio.sleep(refresh_interval)
911
+ await self._device_data_refresher.refresh_firmware_data(device_address=self._address)
912
+
913
+ if refresh_after_update_intervals:
914
+ self._task_scheduler.create_task(target=refresh_data, name="refresh_firmware_data")
915
+
916
+ return update_result
917
+
918
+ def _get_battery_level(self) -> int | None:
919
+ """Return battery level percentage (0-100)."""
920
+ # First try OperatingVoltageLevel calculated data point on channel 0
921
+ if (
922
+ ovl := self.get_calculated_data_point(
923
+ channel_address=f"{self._address}:0",
924
+ parameter=CalculatedParameter.OPERATING_VOLTAGE_LEVEL,
925
+ )
926
+ ) is not None and ovl.value is not None:
927
+ return int(ovl.value)
928
+
929
+ # Fallback to BATTERY_STATE if available (channel 0)
930
+ # BATTERY_STATE is typically 0-100 percentage or voltage
931
+ # If value > 10, assume it's already a percentage
932
+ # If value <= 10, assume it's voltage and can't be converted without battery info
933
+ if (
934
+ (
935
+ dp := self.get_generic_data_point(
936
+ channel_address=f"{self._address}:0",
937
+ parameter=Parameter.BATTERY_STATE,
938
+ )
939
+ )
940
+ is not None
941
+ and dp.value is not None
942
+ and (value := float(dp.value)) > 10
943
+ ):
944
+ return int(value)
945
+ return None
946
+
947
+ def _get_is_reachable(self) -> bool:
948
+ """Return if device is reachable."""
949
+ if self._forced_availability != ForcedDeviceAvailability.NOT_SET:
950
+ return self._forced_availability == ForcedDeviceAvailability.FORCE_TRUE
951
+ if (un_reach := self._dp_un_reach) is None:
952
+ un_reach = self._dp_sticky_un_reach
953
+ if un_reach is not None and un_reach.value is not None:
954
+ return not un_reach.value
955
+ return True
956
+
957
+ def _get_last_updated(self) -> datetime | None:
958
+ """Return the most recent data point modification time."""
959
+ latest = INIT_DATETIME
960
+ for channel in self._channels.values():
961
+ for dp in channel.get_data_points(exclude_no_create=False):
962
+ latest = max(latest, dp.modified_at)
963
+ return latest if latest > INIT_DATETIME else None
964
+
965
+ def _get_low_battery(self) -> bool | None:
966
+ """Return low battery indicator from LOW_BAT parameter."""
967
+ # Check channels 0, 1, 2 for LOW_BAT (different devices use different channels)
968
+ for channel_no in (0, 1, 2):
969
+ if (
970
+ dp := self.get_generic_data_point(
971
+ channel_address=f"{self._address}:{channel_no}",
972
+ parameter=Parameter.LOW_BAT,
973
+ )
974
+ ) is not None and dp.value is not None:
975
+ return dp.value is True
976
+ return None
977
+
978
+ def _get_signal_strength(self) -> int | None:
979
+ """Return signal strength in dBm from RSSI_DEVICE."""
980
+ if (
981
+ dp := self.get_generic_data_point(
982
+ channel_address=f"{self._address}:0",
983
+ parameter=Parameter.RSSI_DEVICE,
984
+ )
985
+ ) is not None and dp.value is not None:
986
+ return int(dp.value)
987
+ return None
988
+
989
+ def _identify_manufacturer(self) -> Manufacturer:
990
+ """Identify the manufacturer of a device."""
991
+ if self._model.lower().startswith("hb"):
992
+ return Manufacturer.HB
993
+ if self._model.lower().startswith("alpha"):
994
+ return Manufacturer.MOEHLENHOFF
995
+ return Manufacturer.EQ3
996
+
997
+ def _set_modified_at(self) -> None:
998
+ self._modified_at = datetime.now()
999
+
1000
+
1001
+ class Channel(ChannelProtocol, LogContextMixin, PayloadMixin):
1002
+ """
1003
+ Represent a device channel containing data points and events.
1004
+
1005
+ Channels group related parameters and provide the organizational structure
1006
+ for data points within a device. Each channel has a unique address (e.g.,
1007
+ ``VCU0000001:1``) and contains generic, custom, and calculated data points.
1008
+
1009
+ Responsibilities
1010
+ ----------------
1011
+ 1. **Data Point Storage**: Stores generic, custom, and calculated data points.
1012
+ 2. **Event Storage**: Stores generic events (KEYPRESS, MOTION, etc.).
1013
+ 3. **Grouping**: Multi-channel grouping support (e.g., for multi-gang switches).
1014
+ 4. **Link Peers**: Link partner management for peer-to-peer communication.
1015
+ 5. **Paramset Access**: Access to VALUES and MASTER paramset descriptions.
1016
+
1017
+ Access pattern
1018
+ --------------
1019
+ Channels access protocol interfaces through their parent device:
1020
+ ``self._device.event_bus_provider``, ``self._device.client``, etc.
1021
+
1022
+ Protocol compliance
1023
+ -------------------
1024
+ Implements ``ChannelProtocol`` which is a composite of sub-protocols:
1025
+
1026
+ - ``ChannelIdentityProtocol``: Basic identification (address, name, no, type_name)
1027
+ - ``ChannelDataPointAccessProtocol``: DataPoint and event access methods
1028
+ - ``ChannelGroupingProtocol``: Channel group management (group_master, link_peer_channels)
1029
+ - ``ChannelMetadataProtocol``: Additional metadata (device, function, room, paramset_descriptions)
1030
+ - ``ChannelLinkManagementProtocol``: Central link operations
1031
+ - ``ChannelLifecycleProtocol``: Lifecycle methods (finalize_init, on_config_changed, remove)
1032
+
1033
+ Consumers can depend on specific sub-protocols for narrower contracts.
1034
+ """
1035
+
1036
+ __slots__ = (
1037
+ "_address",
1038
+ "_cached_group_master",
1039
+ "_cached_group_no",
1040
+ "_cached_is_in_multi_group",
1041
+ "_calculated_data_points",
1042
+ "_custom_data_point",
1043
+ "_channel_description",
1044
+ "_device",
1045
+ "_function",
1046
+ "_generic_data_points",
1047
+ "_generic_events",
1048
+ "_rega_id",
1049
+ "_is_schedule_channel",
1050
+ "_link_peer_addresses",
1051
+ "_link_source_categories",
1052
+ "_link_source_roles",
1053
+ "_link_target_categories",
1054
+ "_link_target_roles",
1055
+ "_modified_at",
1056
+ "_name_data",
1057
+ "_no",
1058
+ "_paramset_keys",
1059
+ "_rooms",
1060
+ "_state_path_to_dpk",
1061
+ "_type_name",
1062
+ "_unique_id",
1063
+ )
1064
+
1065
+ def __init__(self, *, device: DeviceProtocol, channel_address: str) -> None:
1066
+ """Initialize the channel object."""
1067
+ PayloadMixin.__init__(self)
1068
+
1069
+ self._device: Final = device
1070
+ self._address: Final = channel_address
1071
+ self._rega_id: Final = self._device.device_details_provider.get_address_id(address=channel_address)
1072
+ self._no: Final[int | None] = get_channel_no(address=channel_address)
1073
+ self._name_data: Final = get_channel_name_data(channel=self)
1074
+ self._channel_description: DeviceDescription = self._device.device_description_provider.get_device_description(
1075
+ interface_id=self._device.interface_id, address=channel_address
1076
+ )
1077
+ self._type_name: Final[str] = self._channel_description["TYPE"]
1078
+ self._is_schedule_channel: Final[bool] = WEEK_PROFILE_PATTERN.match(self._type_name) is not None
1079
+ self._paramset_keys: Final = tuple(
1080
+ ParamsetKey(paramset_key) for paramset_key in self._channel_description["PARAMSETS"]
1081
+ )
1082
+
1083
+ self._unique_id: Final = generate_channel_unique_id(
1084
+ config_provider=self._device.config_provider, address=channel_address
1085
+ )
1086
+ self._calculated_data_points: Final[dict[DataPointKey, CalculatedDataPointProtocol]] = {}
1087
+ self._custom_data_point: hmce.CustomDataPoint | None = None
1088
+ self._generic_data_points: Final[dict[DataPointKey, GenericDataPointProtocolAny]] = {}
1089
+ self._generic_events: Final[dict[DataPointKey, GenericEventProtocolAny]] = {}
1090
+ self._state_path_to_dpk: Final[dict[str, DataPointKey]] = {}
1091
+ self._link_peer_addresses: tuple[str, ...] = ()
1092
+ self._link_source_roles: tuple[str, ...] = (
1093
+ tuple(source_roles.split(" "))
1094
+ if (source_roles := self._channel_description.get("LINK_SOURCE_ROLES"))
1095
+ else ()
1096
+ )
1097
+ self._link_source_categories: Final = get_link_source_categories(
1098
+ source_roles=self._link_source_roles, channel_type_name=self._type_name
1099
+ )
1100
+ self._link_target_roles: tuple[str, ...] = (
1101
+ tuple(target_roles.split(" "))
1102
+ if (target_roles := self._channel_description.get("LINK_TARGET_ROLES"))
1103
+ else ()
1104
+ )
1105
+ self._link_target_categories: Final = get_link_target_categories(
1106
+ target_roles=self._link_target_roles, channel_type_name=self._type_name
1107
+ )
1108
+ self._modified_at: datetime = INIT_DATETIME
1109
+ self._rooms: Final = self._device.device_details_provider.get_channel_rooms(channel_address=channel_address)
1110
+ self._function: Final = self._device.device_details_provider.get_function_text(address=self._address)
1111
+ self.init_channel()
1112
+
1113
+ def __str__(self) -> str:
1114
+ """Provide some useful information."""
1115
+ return (
1116
+ f"address: {self._address}, "
1117
+ f"type: {self._type_name}, "
1118
+ f"generic dps: {len(self._generic_data_points)}, "
1119
+ f"calculated dps: {len(self._calculated_data_points)}, "
1120
+ f"custom dp: {self._custom_data_point is not None}, "
1121
+ f"events: {len(self._generic_events)}"
1122
+ )
1123
+
1124
+ address: Final = DelegatedProperty[str](path="_address", kind=Kind.INFO)
1125
+ custom_data_point: Final = DelegatedProperty[hmce.CustomDataPoint | None](path="_custom_data_point")
1126
+ description: Final = DelegatedProperty[DeviceDescription](path="_channel_description")
1127
+ device: Final = DelegatedProperty[DeviceProtocol](path="_device", log_context=True)
1128
+ full_name: Final = DelegatedProperty[str](path="_name_data.full_name")
1129
+ function: Final = DelegatedProperty[str | None](path="_function")
1130
+ is_schedule_channel: Final = DelegatedProperty[bool](path="_is_schedule_channel")
1131
+ link_peer_addresses: Final = DelegatedProperty[tuple[str, ...]](path="_link_peer_addresses")
1132
+ link_peer_source_categories: Final = DelegatedProperty[tuple[str, ...]](path="_link_source_categories")
1133
+ link_peer_target_categories: Final = DelegatedProperty[tuple[str, ...]](path="_link_target_categories")
1134
+ name: Final = DelegatedProperty[str](path="_name_data.channel_name")
1135
+ name_data: Final = DelegatedProperty[ChannelNameData](path="_name_data")
1136
+ no: Final = DelegatedProperty[int | None](path="_no", log_context=True)
1137
+ paramset_keys: Final = DelegatedProperty[tuple[ParamsetKey, ...]](path="_paramset_keys")
1138
+ rega_id: Final = DelegatedProperty[int](path="_rega_id")
1139
+ rooms: Final = DelegatedProperty[set[str]](path="_rooms")
1140
+ type_name: Final = DelegatedProperty[str](path="_type_name")
1141
+ unique_id: Final = DelegatedProperty[str](path="_unique_id")
1142
+
1143
+ @property
1144
+ def _has_key_press_events(self) -> bool:
1145
+ """Return if channel has KEYPRESS events."""
1146
+ return any(event for event in self.generic_events if event.event_type is DeviceTriggerEventType.KEYPRESS)
1147
+
1148
+ @property
1149
+ def calculated_data_points(self) -> tuple[CalculatedDataPointProtocol, ...]:
1150
+ """Return the generic data points."""
1151
+ return tuple(self._calculated_data_points.values())
1152
+
1153
+ @property
1154
+ def data_point_paths(self) -> tuple[str, ...]:
1155
+ """Return the data point paths."""
1156
+ return tuple(self._state_path_to_dpk.keys())
1157
+
1158
+ @property
1159
+ def generic_data_points(self) -> tuple[GenericDataPointProtocolAny, ...]:
1160
+ """Return the generic data points."""
1161
+ return tuple(self._generic_data_points.values())
1162
+
1163
+ @property
1164
+ def generic_events(self) -> tuple[GenericEventProtocolAny, ...]:
1165
+ """Return the generic events."""
1166
+ return tuple(self._generic_events.values())
1167
+
1168
+ @property
1169
+ def is_group_master(self) -> bool:
1170
+ """Return if group master of channel."""
1171
+ return self.group_no == self._no
1172
+
1173
+ @property
1174
+ def link_peer_channels(self) -> tuple[ChannelProtocol, ...]:
1175
+ """Return the link peer channel."""
1176
+ return tuple(
1177
+ channel
1178
+ for address in self._link_peer_addresses
1179
+ if self._link_peer_addresses
1180
+ and (channel := self._device.channel_lookup.get_channel(channel_address=address)) is not None
1181
+ )
1182
+
1183
+ @property
1184
+ def operation_mode(self) -> str | None:
1185
+ """Return the channel operation mode if available."""
1186
+ if (
1187
+ cop := self.get_generic_data_point(parameter=Parameter.CHANNEL_OPERATION_MODE)
1188
+ ) is not None and cop.value is not None:
1189
+ return str(cop.value)
1190
+ return None
1191
+
1192
+ @property
1193
+ def paramset_descriptions(self) -> Mapping[ParamsetKey, Mapping[str, ParameterData]]:
1194
+ """Return the paramset descriptions of the channel."""
1195
+ return self._device.paramset_description_provider.get_channel_paramset_descriptions(
1196
+ interface_id=self._device.interface_id, channel_address=self._address
1197
+ )
1198
+
1199
+ @info_property
1200
+ def room(self) -> str | None:
1201
+ """Return the room of the device, if only one assigned in the backend."""
1202
+ if self._rooms and len(self._rooms) == 1:
1203
+ return list(self._rooms)[0]
1204
+ if self.is_group_master:
1205
+ return None
1206
+ if (master_channel := self.group_master) is not None:
1207
+ return master_channel.room
1208
+ return None
1209
+
1210
+ @hm_property(cached=True)
1211
+ def group_master(self) -> ChannelProtocol | None:
1212
+ """Return the master channel of the group."""
1213
+ if self.group_no is None:
1214
+ return None
1215
+ return (
1216
+ self
1217
+ if self.is_group_master
1218
+ else self._device.get_channel(channel_address=f"{self._device.address}:{self.group_no}")
1219
+ )
1220
+
1221
+ @hm_property(cached=True)
1222
+ def group_no(self) -> int | None:
1223
+ """Return the no of the channel group."""
1224
+ return self._device.get_channel_group_no(channel_no=self._no)
1225
+
1226
+ @hm_property(cached=True)
1227
+ def is_in_multi_group(self) -> bool:
1228
+ """Return if multiple channels are in the group."""
1229
+ return self._device.is_in_multi_channel_group(channel_no=self._no)
1230
+
1231
+ def add_data_point(self, *, data_point: CallbackDataPointProtocol) -> None:
1232
+ """Add a data_point to a channel."""
1233
+ if isinstance(data_point, BaseParameterDataPointProtocol):
1234
+ self._device.event_subscription_manager.add_data_point_subscription(data_point=data_point)
1235
+ self._state_path_to_dpk[data_point.state_path] = data_point.dpk
1236
+ if isinstance(data_point, CalculatedDataPointProtocol):
1237
+ self._calculated_data_points[data_point.dpk] = data_point
1238
+ if isinstance(data_point, GenericDataPointProtocol):
1239
+ self._generic_data_points[data_point.dpk] = data_point
1240
+ if isinstance(data_point, hmce.CustomDataPoint):
1241
+ self._custom_data_point = data_point
1242
+ if isinstance(data_point, GenericEventProtocol):
1243
+ self._generic_events[data_point.dpk] = data_point
1244
+
1245
+ @inspector(scope=ServiceScope.INTERNAL)
1246
+ async def cleanup_central_link_metadata(self) -> None:
1247
+ """Cleanup the metadata for central links."""
1248
+ if metadata := await self._device.client.get_metadata(address=self._address, data_id=REPORT_VALUE_USAGE_DATA):
1249
+ await self._device.client.set_metadata(
1250
+ address=self._address,
1251
+ data_id=REPORT_VALUE_USAGE_DATA,
1252
+ value={key: value for key, value in metadata.items() if key in CLICK_EVENTS},
1253
+ )
1254
+
1255
+ @inspector(scope=ServiceScope.INTERNAL)
1256
+ async def create_central_link(self) -> None:
1257
+ """Create a central link to support press events."""
1258
+ if self._has_key_press_events and not await self._has_central_link():
1259
+ await self._device.client.report_value_usage(
1260
+ address=self._address, value_id=REPORT_VALUE_USAGE_VALUE_ID, ref_counter=1
1261
+ )
1262
+
1263
+ async def finalize_init(self) -> None:
1264
+ """Finalize the channel init action after model setup."""
1265
+ for ge in self._generic_data_points.values():
1266
+ await ge.finalize_init()
1267
+ for gev in self._generic_events.values():
1268
+ await gev.finalize_init()
1269
+ for cdp in self._calculated_data_points.values():
1270
+ await cdp.finalize_init()
1271
+ if self._custom_data_point:
1272
+ await self._custom_data_point.finalize_init()
1273
+
1274
+ def get_calculated_data_point(self, *, parameter: str) -> CalculatedDataPointProtocol | None:
1275
+ """Return a calculated data_point from device."""
1276
+ return self._calculated_data_points.get(
1277
+ DataPointKey(
1278
+ interface_id=self._device.interface_id,
1279
+ channel_address=self._address,
1280
+ paramset_key=ParamsetKey.CALCULATED,
1281
+ parameter=parameter,
1282
+ )
1283
+ )
1284
+
1285
+ def get_data_points(
1286
+ self,
1287
+ *,
1288
+ category: DataPointCategory | None = None,
1289
+ exclude_no_create: bool = True,
1290
+ registered: bool | None = None,
1291
+ ) -> tuple[CallbackDataPointProtocol, ...]:
1292
+ """Get all data points of the device."""
1293
+ all_data_points: list[CallbackDataPointProtocol] = list(self._generic_data_points.values()) + list(
1294
+ self._calculated_data_points.values()
1295
+ )
1296
+ if self._custom_data_point:
1297
+ all_data_points.append(self._custom_data_point)
1298
+
1299
+ return tuple(
1300
+ dp
1301
+ for dp in all_data_points
1302
+ if dp is not None
1303
+ and (category is None or dp.category == category)
1304
+ and ((exclude_no_create and dp.usage != DataPointUsage.NO_CREATE) or exclude_no_create is False)
1305
+ and (registered is None or dp.is_registered == registered)
1306
+ )
1307
+
1308
+ def get_events(
1309
+ self, *, event_type: DeviceTriggerEventType, registered: bool | None = None
1310
+ ) -> tuple[GenericEventProtocolAny, ...]:
1311
+ """Return a list of specific events of a channel."""
1312
+ return tuple(
1313
+ event
1314
+ for event in self._generic_events.values()
1315
+ if (event.event_type == event_type and (registered is None or event.is_registered == registered))
1316
+ )
1317
+
1318
+ def get_generic_data_point(
1319
+ self, *, parameter: str | None = None, paramset_key: ParamsetKey | None = None, state_path: str | None = None
1320
+ ) -> GenericDataPointProtocolAny | None:
1321
+ """Return a generic data_point from device."""
1322
+ if state_path is not None and (dpk := self._state_path_to_dpk.get(state_path)) is not None:
1323
+ return self._generic_data_points.get(dpk)
1324
+ if parameter is None:
1325
+ return None
1326
+ if paramset_key:
1327
+ return self._generic_data_points.get(
1328
+ DataPointKey(
1329
+ interface_id=self._device.interface_id,
1330
+ channel_address=self._address,
1331
+ paramset_key=paramset_key,
1332
+ parameter=parameter,
1333
+ )
1334
+ )
1335
+
1336
+ if dp := self._generic_data_points.get(
1337
+ DataPointKey(
1338
+ interface_id=self._device.interface_id,
1339
+ channel_address=self._address,
1340
+ paramset_key=ParamsetKey.VALUES,
1341
+ parameter=parameter,
1342
+ )
1343
+ ):
1344
+ return dp
1345
+ return self._generic_data_points.get(
1346
+ DataPointKey(
1347
+ interface_id=self._device.interface_id,
1348
+ channel_address=self._address,
1349
+ paramset_key=ParamsetKey.MASTER,
1350
+ parameter=parameter,
1351
+ )
1352
+ )
1353
+
1354
+ def get_generic_event(
1355
+ self, *, parameter: str | None = None, state_path: str | None = None
1356
+ ) -> GenericEventProtocolAny | None:
1357
+ """Return a generic event from device."""
1358
+ if state_path is not None and (dpk := self._state_path_to_dpk.get(state_path)) is not None:
1359
+ return self._generic_events.get(dpk)
1360
+ if parameter is None:
1361
+ return None
1362
+
1363
+ return self._generic_events.get(
1364
+ DataPointKey(
1365
+ interface_id=self._device.interface_id,
1366
+ channel_address=self._address,
1367
+ paramset_key=ParamsetKey.VALUES,
1368
+ parameter=parameter,
1369
+ )
1370
+ )
1371
+
1372
+ def get_readable_data_points(self, *, paramset_key: ParamsetKey) -> tuple[GenericDataPointProtocolAny, ...]:
1373
+ """Return the list of readable master data points."""
1374
+ return tuple(
1375
+ ge for ge in self._generic_data_points.values() if ge.is_readable and ge.paramset_key == paramset_key
1376
+ )
1377
+
1378
+ def has_link_source_category(self, *, category: DataPointCategory) -> bool:
1379
+ """Return if channel is receiver."""
1380
+ return category in self._link_source_categories
1381
+
1382
+ def has_link_target_category(self, *, category: DataPointCategory) -> bool:
1383
+ """Return if channel is transmitter."""
1384
+ return category in self._link_target_categories
1385
+
1386
+ def init_channel(self) -> None:
1387
+ """Initialize the channel."""
1388
+ self._device.task_scheduler.create_task(target=self.init_link_peer(), name=f"init_channel_{self._address}")
1389
+
1390
+ async def init_link_peer(self) -> None:
1391
+ """Initialize the link partners."""
1392
+ if self._link_source_categories and self._device.model not in VIRTUAL_REMOTE_MODELS:
1393
+ try:
1394
+ link_peer_addresses = await self._device.client.get_link_peers(address=self._address)
1395
+ except ClientException:
1396
+ # Device may have been deleted or is temporarily unavailable
1397
+ _LOGGER.debug("INIT_LINK_PEER: Failed to get link peers for %s", self._address)
1398
+ return
1399
+ if self._link_peer_addresses != link_peer_addresses:
1400
+ self._link_peer_addresses = link_peer_addresses
1401
+ self.publish_link_peer_changed_event()
1402
+
1403
+ async def load_values(self, *, call_source: CallSource, direct_call: bool = False) -> None:
1404
+ """Load data for the channel."""
1405
+ for ge in self._generic_data_points.values():
1406
+ await ge.load_data_point_value(call_source=call_source, direct_call=direct_call)
1407
+ for gev in self._generic_events.values():
1408
+ await gev.load_data_point_value(call_source=call_source, direct_call=direct_call)
1409
+ for cdp in self._calculated_data_points.values():
1410
+ await cdp.load_data_point_value(call_source=call_source, direct_call=direct_call)
1411
+ if self._custom_data_point:
1412
+ await self._custom_data_point.load_data_point_value(call_source=call_source, direct_call=direct_call)
1413
+
1414
+ async def on_config_changed(self) -> None:
1415
+ """Do what is needed on device config change."""
1416
+ # reload paramset_descriptions
1417
+ await self._reload_paramset_descriptions()
1418
+
1419
+ # re init link peers
1420
+ await self.init_link_peer()
1421
+
1422
+ for ge in self._generic_data_points.values():
1423
+ await ge.on_config_changed()
1424
+ for gev in self._generic_events.values():
1425
+ await gev.on_config_changed()
1426
+ for cdp in self._calculated_data_points.values():
1427
+ await cdp.on_config_changed()
1428
+ if self._custom_data_point:
1429
+ await self._custom_data_point.on_config_changed()
1430
+
1431
+ @loop_check
1432
+ def publish_link_peer_changed_event(self) -> None:
1433
+ """Do what is needed when the link peer has been changed for the device."""
1434
+
1435
+ # Publish to EventBus asynchronously
1436
+ async def _publish_link_peer_changed() -> None:
1437
+ await self._device.event_bus_provider.event_bus.publish(
1438
+ event=LinkPeerChangedEvent(
1439
+ timestamp=datetime.now(),
1440
+ channel_address=self._address,
1441
+ )
1442
+ )
1443
+
1444
+ self._device.task_scheduler.create_task(
1445
+ target=_publish_link_peer_changed,
1446
+ name=f"link-peer-changed-{self._address}",
1447
+ )
1448
+
1449
+ def remove(self) -> None:
1450
+ """Remove data points from collections and central."""
1451
+ for event in self.generic_events:
1452
+ self._remove_data_point(data_point=event)
1453
+ self._generic_events.clear()
1454
+
1455
+ for ccdp in self.calculated_data_points:
1456
+ self._remove_data_point(data_point=ccdp)
1457
+ self._calculated_data_points.clear()
1458
+
1459
+ for gdp in self.generic_data_points:
1460
+ self._remove_data_point(data_point=gdp)
1461
+ self._generic_data_points.clear()
1462
+
1463
+ if self._custom_data_point:
1464
+ self._remove_data_point(data_point=self._custom_data_point)
1465
+ self._state_path_to_dpk.clear()
1466
+
1467
+ # Clean up channel-level EventBus subscriptions (e.g., LinkPeerChangedEvent)
1468
+ self._device.event_bus_provider.event_bus.clear_subscriptions_by_key(event_key=self._address)
1469
+
1470
+ @inspector(scope=ServiceScope.INTERNAL)
1471
+ async def remove_central_link(self) -> None:
1472
+ """Remove a central link."""
1473
+ if self._has_key_press_events and await self._has_central_link() and not await self._has_program_ids():
1474
+ await self._device.client.report_value_usage(
1475
+ address=self._address, value_id=REPORT_VALUE_USAGE_VALUE_ID, ref_counter=0
1476
+ )
1477
+
1478
+ @inspector
1479
+ async def rename(self, *, new_name: str) -> bool:
1480
+ """Rename the channel on the CCU."""
1481
+ return await self._device.client.rename_channel(rega_id=self._rega_id, new_name=new_name)
1482
+
1483
+ def subscribe_to_link_peer_changed(self, *, handler: LinkPeerChangedHandler) -> UnsubscribeCallback:
1484
+ """Subscribe to the link peer changed event."""
1485
+
1486
+ # Create adapter that filters for this channel's events
1487
+ def event_handler(*, event: LinkPeerChangedEvent) -> None:
1488
+ if event.channel_address == self._address:
1489
+ handler()
1490
+
1491
+ return self._device.event_bus_provider.event_bus.subscribe(
1492
+ event_type=LinkPeerChangedEvent,
1493
+ event_key=self._address,
1494
+ handler=event_handler,
1495
+ )
1496
+
1497
+ async def _has_central_link(self) -> bool:
1498
+ """Check if central link exists."""
1499
+ try:
1500
+ if metadata := await self._device.client.get_metadata(
1501
+ address=self._address, data_id=REPORT_VALUE_USAGE_DATA
1502
+ ):
1503
+ return any(
1504
+ key
1505
+ for key, value in metadata.items()
1506
+ if isinstance(key, str)
1507
+ and isinstance(value, int)
1508
+ and key == REPORT_VALUE_USAGE_VALUE_ID
1509
+ and value > 0
1510
+ )
1511
+ except BaseHomematicException as bhexc:
1512
+ _LOGGER.debug("HAS_CENTRAL_LINK failed: %s", extract_exc_args(exc=bhexc))
1513
+ return False
1514
+
1515
+ async def _has_program_ids(self) -> bool:
1516
+ """Return if a channel has program ids."""
1517
+ return bool(await self._device.client.has_program_ids(rega_id=self._rega_id))
1518
+
1519
+ @inspector(scope=ServiceScope.INTERNAL)
1520
+ async def _reload_paramset_descriptions(self) -> None:
1521
+ """Reload paramset for channel."""
1522
+ for paramset_key in self._paramset_keys:
1523
+ await self._device.client.fetch_paramset_description(
1524
+ channel_address=self._address,
1525
+ paramset_key=paramset_key,
1526
+ )
1527
+
1528
+ def _remove_data_point(self, *, data_point: CallbackDataPointProtocol) -> None:
1529
+ """Remove a data_point from a channel."""
1530
+ # Clean up internal subscriptions for custom/calculated data points
1531
+ if isinstance(data_point, (hmce.CustomDataPoint, CalculatedDataPointProtocol)):
1532
+ data_point.unsubscribe_from_data_point_updated()
1533
+
1534
+ # Remove from collections
1535
+ if isinstance(data_point, BaseParameterDataPointProtocol):
1536
+ self._state_path_to_dpk.pop(data_point.state_path, None)
1537
+ if isinstance(data_point, CalculatedDataPointProtocol):
1538
+ self._calculated_data_points.pop(data_point.dpk, None)
1539
+ if isinstance(data_point, GenericDataPointProtocol):
1540
+ self._generic_data_points.pop(data_point.dpk, None)
1541
+ if isinstance(data_point, hmce.CustomDataPoint):
1542
+ self._custom_data_point = None
1543
+ if isinstance(data_point, GenericEventProtocol):
1544
+ self._generic_events.pop(data_point.dpk, None)
1545
+
1546
+ # Publish removed event and cleanup subscriptions (async, cleanup after event)
1547
+ data_point.publish_device_removed_event()
1548
+
1549
+ def _set_modified_at(self) -> None:
1550
+ self._modified_at = datetime.now()
1551
+
1552
+
1553
+ class _ValueCache:
1554
+ """
1555
+ Cache for lazy loading and temporary storage of parameter values.
1556
+
1557
+ This cache minimizes RPC calls by storing fetched parameter values and
1558
+ providing them on subsequent requests within a validity period.
1559
+
1560
+ External access
1561
+ ---------------
1562
+ Accessed via ``device.value_cache`` property. Used by DataPoint classes in
1563
+ ``model/data_point.py`` to load values via ``get_value()``.
1564
+
1565
+ Cache strategy
1566
+ --------------
1567
+ - First checks the central data cache for VALUES paramset
1568
+ - Falls back to device-local cache with timestamp-based validity
1569
+ - Uses semaphore for thread-safe concurrent access
1570
+ """
1571
+
1572
+ __slots__ = (
1573
+ "_device",
1574
+ "_device_cache",
1575
+ "_sema_get_or_load_value",
1576
+ )
1577
+
1578
+ _NO_VALUE_CACHE_ENTRY: Final = "NO_VALUE_CACHE_ENTRY"
1579
+
1580
+ def __init__(self, *, device: DeviceProtocol) -> None:
1581
+ """Initialize the value cache."""
1582
+ self._sema_get_or_load_value: Final = asyncio.Semaphore()
1583
+ self._device: Final = device
1584
+ # {key, CacheEntry}
1585
+ self._device_cache: Final[dict[DataPointKey, CacheEntry]] = {}
1586
+
1587
+ async def get_value(
1588
+ self,
1589
+ *,
1590
+ dpk: DataPointKey,
1591
+ call_source: CallSource,
1592
+ direct_call: bool = False,
1593
+ ) -> Any:
1594
+ """Load data."""
1595
+ async with self._sema_get_or_load_value:
1596
+ if direct_call is False and (cached_value := self._get_value_from_cache(dpk=dpk)) != NO_CACHE_ENTRY:
1597
+ return NO_CACHE_ENTRY if cached_value == self._NO_VALUE_CACHE_ENTRY else cached_value
1598
+
1599
+ value_dict: dict[str, Any] = {dpk.parameter: self._NO_VALUE_CACHE_ENTRY}
1600
+ try:
1601
+ value_dict = await self._get_values_for_cache(dpk=dpk)
1602
+ except BaseHomematicException as bhexc:
1603
+ _LOGGER.debug(
1604
+ "GET_OR_LOAD_VALUE: Failed to get data for %s, %s, %s, %s: %s",
1605
+ self._device.model,
1606
+ dpk.channel_address,
1607
+ dpk.parameter,
1608
+ call_source,
1609
+ extract_exc_args(exc=bhexc),
1610
+ )
1611
+ for d_parameter, d_value in value_dict.items():
1612
+ self._add_entry_to_device_cache(
1613
+ dpk=DataPointKey(
1614
+ interface_id=dpk.interface_id,
1615
+ channel_address=dpk.channel_address,
1616
+ paramset_key=dpk.paramset_key,
1617
+ parameter=d_parameter,
1618
+ ),
1619
+ value=d_value,
1620
+ )
1621
+ return (
1622
+ NO_CACHE_ENTRY
1623
+ if (value := value_dict.get(dpk.parameter)) and value == self._NO_VALUE_CACHE_ENTRY
1624
+ else value
1625
+ )
1626
+
1627
+ async def init_base_data_points(self) -> None:
1628
+ """Load data by get_value."""
1629
+ try:
1630
+ for dp in self._get_base_data_points():
1631
+ await dp.load_data_point_value(call_source=CallSource.HM_INIT)
1632
+ except BaseHomematicException as bhexc:
1633
+ _LOGGER.debug(
1634
+ "init_base_data_points: Failed to init cache for channel0 %s, %s [%s]",
1635
+ self._device.model,
1636
+ self._device.address,
1637
+ extract_exc_args(exc=bhexc),
1638
+ )
1639
+
1640
+ async def init_readable_events(self) -> None:
1641
+ """Load data by get_value."""
1642
+ try:
1643
+ for event in self._get_readable_events():
1644
+ await event.load_data_point_value(call_source=CallSource.HM_INIT)
1645
+ except BaseHomematicException as bhexc:
1646
+ _LOGGER.debug(
1647
+ "init_base_events: Failed to init cache for channel0 %s, %s [%s]",
1648
+ self._device.model,
1649
+ self._device.address,
1650
+ extract_exc_args(exc=bhexc),
1651
+ )
1652
+
1653
+ def _add_entry_to_device_cache(self, *, dpk: DataPointKey, value: Any) -> None:
1654
+ """Add value to cache."""
1655
+ # write value to cache even if an exception has occurred
1656
+ # to avoid repetitive calls to the backend within max_age
1657
+ self._device_cache[dpk] = CacheEntry(value=value, refresh_at=datetime.now())
1658
+
1659
+ def _get_base_data_points(self) -> set[GenericDataPointProtocolAny]:
1660
+ """Get data points of channel 0 and master."""
1661
+ return {
1662
+ dp
1663
+ for dp in self._device.generic_data_points
1664
+ if (
1665
+ dp.channel.no == 0
1666
+ and dp.paramset_key == ParamsetKey.VALUES
1667
+ and dp.parameter in RELEVANT_INIT_PARAMETERS
1668
+ )
1669
+ or dp.paramset_key == ParamsetKey.MASTER
1670
+ }
1671
+
1672
+ def _get_readable_events(self) -> set[GenericEventProtocolAny]:
1673
+ """Get readable events."""
1674
+ return {event for event in self._device.generic_events if event.is_readable}
1675
+
1676
+ def _get_value_from_cache(
1677
+ self,
1678
+ *,
1679
+ dpk: DataPointKey,
1680
+ ) -> Any:
1681
+ """Load data from store."""
1682
+ # Try to get data from central cache
1683
+ if (
1684
+ dpk.paramset_key == ParamsetKey.VALUES
1685
+ and (
1686
+ global_value := self._device.data_cache_provider.get_data(
1687
+ interface=self._device.interface,
1688
+ channel_address=dpk.channel_address,
1689
+ parameter=dpk.parameter,
1690
+ )
1691
+ )
1692
+ != NO_CACHE_ENTRY
1693
+ ):
1694
+ return global_value
1695
+
1696
+ if (cache_entry := self._device_cache.get(dpk, CacheEntry.empty())) and cache_entry.is_valid:
1697
+ return cache_entry.value
1698
+ return NO_CACHE_ENTRY
1699
+
1700
+ async def _get_values_for_cache(self, *, dpk: DataPointKey) -> dict[str, Any]:
1701
+ """Return a value from the backend to store in cache."""
1702
+ if not self._device.available:
1703
+ _LOGGER.debug(
1704
+ "GET_VALUES_FOR_CACHE failed: device %s (%s) is not available", self._device.name, self._device.address
1705
+ )
1706
+ return {}
1707
+ if dpk.paramset_key == ParamsetKey.VALUES:
1708
+ return {
1709
+ dpk.parameter: await self._device.client.get_value(
1710
+ channel_address=dpk.channel_address,
1711
+ paramset_key=dpk.paramset_key,
1712
+ parameter=dpk.parameter,
1713
+ call_source=CallSource.HM_INIT,
1714
+ )
1715
+ }
1716
+ return await self._device.client.get_paramset(
1717
+ address=dpk.channel_address, paramset_key=dpk.paramset_key, call_source=CallSource.HM_INIT
1718
+ )
1719
+
1720
+
1721
+ class _DefinitionExporter:
1722
+ """
1723
+ Export device and paramset descriptions for diagnostics.
1724
+
1725
+ This internal utility class exports device definitions to JSON files for
1726
+ debugging and issue reporting. Device addresses are anonymized before export.
1727
+
1728
+ Internal use only
1729
+ -----------------
1730
+ Used exclusively by ``Device.export_device_definition()``. Not accessible
1731
+ from external code.
1732
+
1733
+ Output files
1734
+ ------------
1735
+ - ``{storage_dir}/device_descriptions/{model}.json`` - Device descriptions
1736
+ - ``{storage_dir}/paramset_descriptions/{model}.json`` - Paramset descriptions
1737
+ """
1738
+
1739
+ __slots__ = (
1740
+ "_client",
1741
+ "_device_address",
1742
+ "_device_description_provider",
1743
+ "_interface_id",
1744
+ "_random_id",
1745
+ "_storage_directory",
1746
+ "_task_scheduler",
1747
+ )
1748
+
1749
+ def __init__(self, *, device: DeviceProtocol) -> None:
1750
+ """Initialize the device exporter."""
1751
+ self._client: Final = device.client
1752
+ self._device_description_provider: Final = device.device_description_provider
1753
+ self._task_scheduler: Final = device.task_scheduler
1754
+ self._storage_directory: Final = device.config_provider.config.storage_directory
1755
+ self._interface_id: Final = device.interface_id
1756
+ self._device_address: Final = device.address
1757
+ self._random_id: Final[str] = f"VCU{int(random.randint(1000000, 9999999))}"
1758
+
1759
+ @inspector(scope=ServiceScope.INTERNAL)
1760
+ async def export_data(self) -> None:
1761
+ """Export device and paramset descriptions as a single ZIP file."""
1762
+ device_descriptions: Mapping[str, DeviceDescription] = (
1763
+ self._device_description_provider.get_device_with_channels(
1764
+ interface_id=self._interface_id, device_address=self._device_address
1765
+ )
1766
+ )
1767
+ paramset_descriptions: dict[
1768
+ str, dict[ParamsetKey, dict[str, ParameterData]]
1769
+ ] = await self._client.get_all_paramset_descriptions(device_descriptions=tuple(device_descriptions.values()))
1770
+ model = device_descriptions[self._device_address]["TYPE"]
1771
+
1772
+ # anonymize device_descriptions (list format matching pydevccu)
1773
+ anonymize_device_descriptions: list[DeviceDescription] = []
1774
+ for device_description in device_descriptions.values():
1775
+ new_device_description: DeviceDescription = device_description.copy()
1776
+ new_address = self._anonymize_address(address=new_device_description["ADDRESS"])
1777
+ new_device_description["ADDRESS"] = new_address
1778
+ if new_device_description.get("PARENT"):
1779
+ new_device_description["PARENT"] = new_address.split(ADDRESS_SEPARATOR, maxsplit=1)[0]
1780
+ elif new_device_description.get("CHILDREN"):
1781
+ new_device_description["CHILDREN"] = [
1782
+ self._anonymize_address(address=a) for a in new_device_description["CHILDREN"]
1783
+ ]
1784
+ anonymize_device_descriptions.append(new_device_description)
1785
+
1786
+ # anonymize paramset_descriptions
1787
+ anonymize_paramset_descriptions: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
1788
+ for address, paramset_description in paramset_descriptions.items():
1789
+ anonymize_paramset_descriptions[self._anonymize_address(address=address)] = paramset_description
1790
+
1791
+ # Write single ZIP file with subdirectories
1792
+ if self._task_scheduler:
1793
+ await self._task_scheduler.async_add_executor_job(
1794
+ partial(
1795
+ self._write_export_zip,
1796
+ model=model,
1797
+ device_descriptions=anonymize_device_descriptions,
1798
+ paramset_descriptions=anonymize_paramset_descriptions,
1799
+ ),
1800
+ name="export-device-definition",
1801
+ )
1802
+ else:
1803
+ await asyncio.to_thread(
1804
+ self._write_export_zip,
1805
+ model=model,
1806
+ device_descriptions=anonymize_device_descriptions,
1807
+ paramset_descriptions=anonymize_paramset_descriptions,
1808
+ )
1809
+
1810
+ def _anonymize_address(self, *, address: str) -> str:
1811
+ """Anonymize device address with random ID."""
1812
+ address_parts = address.split(ADDRESS_SEPARATOR)
1813
+ address_parts[0] = self._random_id
1814
+ return ADDRESS_SEPARATOR.join(address_parts)
1815
+
1816
+ def _write_export_zip(
1817
+ self,
1818
+ *,
1819
+ model: str,
1820
+ device_descriptions: list[DeviceDescription],
1821
+ paramset_descriptions: dict[str, dict[ParamsetKey, dict[str, ParameterData]]],
1822
+ ) -> None:
1823
+ """Write export data to a ZIP file with subdirectories."""
1824
+ # Ensure directory exists
1825
+ os.makedirs(self._storage_directory, exist_ok=True)
1826
+
1827
+ zip_path = os.path.join(self._storage_directory, f"{model}.zip")
1828
+ temp_path = f"{zip_path}.tmp"
1829
+
1830
+ # Serialize JSON with formatting
1831
+ opts = orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS
1832
+ device_json = orjson.dumps(device_descriptions, option=opts)
1833
+ paramset_json = orjson.dumps(paramset_descriptions, option=opts)
1834
+
1835
+ # Write ZIP with subdirectories
1836
+ with zipfile.ZipFile(temp_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
1837
+ zf.writestr(f"{DEVICE_DESCRIPTIONS_ZIP_DIR}/{model}.json", device_json)
1838
+ zf.writestr(f"{PARAMSET_DESCRIPTIONS_ZIP_DIR}/{model}.json", paramset_json)
1839
+
1840
+ os.replace(temp_path, zip_path)