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,360 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Base implementation for custom device-specific data points.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ import contextlib
13
+ from datetime import datetime
14
+ import logging
15
+ from typing import Any, Final, Unpack
16
+
17
+ from aiohomematic.const import INIT_DATETIME, CallSource, DataPointKey, DataPointUsage, DeviceProfile, Field, Parameter
18
+ from aiohomematic.decorators import inspector
19
+ from aiohomematic.interfaces import ChannelProtocol, CustomDataPointProtocol, GenericDataPointProtocolAny
20
+ from aiohomematic.model.custom import definition as hmed
21
+ from aiohomematic.model.custom.mixins import StateChangeArgs
22
+ from aiohomematic.model.custom.profile import RebasedChannelGroupConfig
23
+ from aiohomematic.model.custom.registry import DeviceConfig
24
+ from aiohomematic.model.data_point import BaseDataPoint
25
+ from aiohomematic.model.support import (
26
+ DataPointNameData,
27
+ DataPointPathData,
28
+ PathData,
29
+ check_channel_is_the_only_primary_channel,
30
+ get_custom_data_point_name,
31
+ )
32
+ from aiohomematic.property_decorators import DelegatedProperty, state_property
33
+ from aiohomematic.support import get_channel_address
34
+ from aiohomematic.type_aliases import UnsubscribeCallback
35
+
36
+ _LOGGER: Final = logging.getLogger(__name__)
37
+
38
+
39
+ class CustomDataPoint(BaseDataPoint, CustomDataPointProtocol):
40
+ """Base class for custom data point."""
41
+
42
+ __slots__ = (
43
+ "_allow_undefined_generic_data_points",
44
+ "_channel_group",
45
+ "_custom_data_point_def",
46
+ "_data_points",
47
+ "_device_config",
48
+ "_device_profile",
49
+ "_extended",
50
+ "_group_no",
51
+ "_schedule_channel_no",
52
+ "_unsubscribe_callbacks",
53
+ )
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ channel: ChannelProtocol,
59
+ unique_id: str,
60
+ device_profile: DeviceProfile,
61
+ channel_group: RebasedChannelGroupConfig,
62
+ custom_data_point_def: Mapping[int | tuple[int, ...], tuple[Parameter, ...]],
63
+ group_no: int | None,
64
+ device_config: DeviceConfig,
65
+ ) -> None:
66
+ """Initialize the data point."""
67
+ self._unsubscribe_callbacks: list[UnsubscribeCallback] = []
68
+ self._device_profile: Final = device_profile
69
+ self._channel_group: Final = channel_group
70
+ self._custom_data_point_def: Final = custom_data_point_def
71
+ self._group_no: int | None = group_no
72
+ self._device_config: Final = device_config
73
+ self._extended: Final = device_config.extended
74
+ super().__init__(
75
+ channel=channel,
76
+ unique_id=unique_id,
77
+ is_in_multiple_channels=hmed.is_multi_channel_device(model=channel.device.model, category=self.category),
78
+ )
79
+ self._allow_undefined_generic_data_points: Final[bool] = channel_group.allow_undefined_generic_data_points
80
+ self._data_points: Final[dict[Field, GenericDataPointProtocolAny]] = {}
81
+ self._init_data_points()
82
+ self._post_init()
83
+ if self.usage == DataPointUsage.CDP_PRIMARY:
84
+ self._device.init_week_profile(data_point=self)
85
+
86
+ def __del__(self) -> None:
87
+ """Clean up subscriptions when the object is garbage collected."""
88
+ with contextlib.suppress(Exception):
89
+ self.unsubscribe_from_data_point_updated()
90
+
91
+ allow_undefined_generic_data_points: Final = DelegatedProperty[bool](path="_allow_undefined_generic_data_points")
92
+ device_config: Final = DelegatedProperty[DeviceConfig](path="_device_config")
93
+ group_no: Final = DelegatedProperty[int | None](path="_group_no")
94
+
95
+ @property
96
+ def _readable_data_points(self) -> tuple[GenericDataPointProtocolAny, ...]:
97
+ """Returns the list of readable data points."""
98
+ return tuple(dp for dp in self._data_points.values() if dp.is_readable)
99
+
100
+ @property
101
+ def _relevant_data_points(self) -> tuple[GenericDataPointProtocolAny, ...]:
102
+ """Returns the list of relevant data points. To be overridden by subclasses."""
103
+ return self._readable_data_points
104
+
105
+ @property
106
+ def data_point_name_postfix(self) -> str:
107
+ """Return the data point name postfix."""
108
+ return ""
109
+
110
+ @property
111
+ def has_data_points(self) -> bool:
112
+ """Return if there are data points."""
113
+ return len(self._data_points) > 0
114
+
115
+ @property
116
+ def has_schedule(self) -> bool:
117
+ """Flag if device supports schedule."""
118
+ if self._device.week_profile:
119
+ return self._device.week_profile.has_schedule
120
+ return False
121
+
122
+ @property
123
+ def is_refreshed(self) -> bool:
124
+ """Return if all relevant data_point have been refreshed (received a value)."""
125
+ return all(dp.is_refreshed for dp in self._relevant_data_points)
126
+
127
+ @property
128
+ def is_status_valid(self) -> bool:
129
+ """Return if all relevant data points have valid status."""
130
+ return all(dp.is_status_valid for dp in self._relevant_data_points)
131
+
132
+ @property
133
+ def schedule(self) -> dict[Any, Any]:
134
+ """Return cached schedule entries from device week profile."""
135
+ if self._device.week_profile:
136
+ return self._device.week_profile.schedule
137
+ return {}
138
+
139
+ @property
140
+ def state_uncertain(self) -> bool:
141
+ """Return, if the state is uncertain."""
142
+ return any(dp.state_uncertain for dp in self._relevant_data_points)
143
+
144
+ @property
145
+ def unconfirmed_last_values_send(self) -> Mapping[Field, Any]:
146
+ """Return the unconfirmed values send for the data point."""
147
+ unconfirmed_values: dict[Field, Any] = {}
148
+ for field, dp in self._data_points.items():
149
+ if (unconfirmed_value := dp.unconfirmed_last_value_send) is not None:
150
+ unconfirmed_values[field] = unconfirmed_value
151
+ return unconfirmed_values
152
+
153
+ @state_property
154
+ def modified_at(self) -> datetime:
155
+ """Return the latest last update timestamp."""
156
+ modified_at: datetime = INIT_DATETIME
157
+ for dp in self._readable_data_points:
158
+ if (data_point_modified_at := dp.modified_at) and data_point_modified_at > modified_at:
159
+ modified_at = data_point_modified_at
160
+ return modified_at
161
+
162
+ @state_property
163
+ def refreshed_at(self) -> datetime:
164
+ """Return the latest last refresh timestamp."""
165
+ refreshed_at: datetime = INIT_DATETIME
166
+ for dp in self._readable_data_points:
167
+ if (data_point_refreshed_at := dp.refreshed_at) and data_point_refreshed_at > refreshed_at:
168
+ refreshed_at = data_point_refreshed_at
169
+ return refreshed_at
170
+
171
+ async def get_schedule(self, *, force_load: bool = False) -> dict[Any, Any]:
172
+ """Get schedule from device week profile."""
173
+ if self._device.week_profile:
174
+ return await self._device.week_profile.get_schedule(force_load=force_load)
175
+ return {}
176
+
177
+ def has_data_point_key(self, *, data_point_keys: set[DataPointKey]) -> bool:
178
+ """Return if a data_point with one of the data points is part of this data_point."""
179
+ result = [dp for dp in self._data_points.values() if dp.dpk in data_point_keys]
180
+ return len(result) > 0
181
+
182
+ def is_state_change(self, **kwargs: Unpack[StateChangeArgs]) -> bool:
183
+ """
184
+ Check if the state changes due to kwargs.
185
+
186
+ If the state is uncertain, the state should also marked as changed.
187
+ """
188
+ if self.state_uncertain:
189
+ return True
190
+ _LOGGER.debug("NO_STATE_CHANGE: %s", self.name)
191
+ return False
192
+
193
+ @inspector(re_raise=False)
194
+ async def load_data_point_value(self, *, call_source: CallSource, direct_call: bool = False) -> None:
195
+ """Initialize the data point values."""
196
+ for dp in self._readable_data_points:
197
+ await dp.load_data_point_value(call_source=call_source, direct_call=direct_call)
198
+ if self._device.week_profile and self.usage == DataPointUsage.CDP_PRIMARY:
199
+ await self._device.week_profile.reload_and_cache_schedule()
200
+ self.publish_data_point_updated_event()
201
+
202
+ async def set_schedule(self, *, schedule_data: dict[Any, Any]) -> None:
203
+ """Set schedule on device week profile."""
204
+ if self._device.week_profile:
205
+ await self._device.week_profile.set_schedule(schedule_data=schedule_data)
206
+
207
+ def unsubscribe_from_data_point_updated(self) -> None:
208
+ """Unregister all internal update handlers."""
209
+ for unreg in self._unsubscribe_callbacks:
210
+ if unreg is not None:
211
+ unreg()
212
+ self._unsubscribe_callbacks.clear()
213
+
214
+ def _add_channel_data_points(
215
+ self,
216
+ *,
217
+ channel_fields: Mapping[int | None, Mapping[Field, Parameter]],
218
+ is_visible: bool | None = None,
219
+ ) -> None:
220
+ """Add channel-specific data points to custom data point."""
221
+ for channel_no, ch_fields in channel_fields.items():
222
+ for field_name, parameter in ch_fields.items():
223
+ channel_address = get_channel_address(device_address=self._device.address, channel_no=channel_no)
224
+ if dp := self._device.get_generic_data_point(channel_address=channel_address, parameter=parameter):
225
+ self._add_data_point(field=field_name, data_point=dp, is_visible=is_visible)
226
+
227
+ def _add_data_point(
228
+ self,
229
+ *,
230
+ field: Field,
231
+ data_point: GenericDataPointProtocolAny | None,
232
+ is_visible: bool | None = None,
233
+ ) -> None:
234
+ """Add data point to collection and subscribed handler."""
235
+ if not data_point:
236
+ return
237
+ if is_visible is True and data_point.is_forced_sensor is False:
238
+ data_point.force_usage(forced_usage=DataPointUsage.CDP_VISIBLE)
239
+ elif is_visible is False and data_point.is_forced_sensor is False:
240
+ data_point.force_usage(forced_usage=DataPointUsage.NO_CREATE)
241
+
242
+ self._unsubscribe_callbacks.append(
243
+ data_point.subscribe_to_internal_data_point_updated(handler=self.publish_data_point_updated_event)
244
+ )
245
+ self._data_points[field] = data_point
246
+
247
+ def _add_fixed_channel_data_points(
248
+ self,
249
+ *,
250
+ fixed_channel_fields: Mapping[int, Mapping[Field, Parameter]],
251
+ is_visible: bool | None = None,
252
+ ) -> None:
253
+ """Add fixed channel data points (absolute channel numbers) to custom data point."""
254
+ for channel_no, ch_fields in fixed_channel_fields.items():
255
+ channel_address = get_channel_address(device_address=self._device.address, channel_no=channel_no)
256
+ for field_name, parameter in ch_fields.items():
257
+ if dp := self._device.get_generic_data_point(channel_address=channel_address, parameter=parameter):
258
+ self._add_data_point(field=field_name, data_point=dp, is_visible=is_visible)
259
+
260
+ def _get_data_point_name(self) -> DataPointNameData:
261
+ """Create the name for the data point."""
262
+ is_only_primary_channel = check_channel_is_the_only_primary_channel(
263
+ current_channel_no=self._channel.no,
264
+ primary_channel=self._channel_group.primary_channel,
265
+ device_has_multiple_channels=self.is_in_multiple_channels,
266
+ )
267
+ return get_custom_data_point_name(
268
+ channel=self._channel,
269
+ is_only_primary_channel=is_only_primary_channel,
270
+ ignore_multiple_channels_for_name=self._ignore_multiple_channels_for_name,
271
+ usage=self._get_data_point_usage(),
272
+ postfix=self.data_point_name_postfix.replace("_", " ").title(),
273
+ )
274
+
275
+ def _get_data_point_usage(self) -> DataPointUsage:
276
+ """Generate the usage for the data point."""
277
+ if self._forced_usage:
278
+ return self._forced_usage
279
+ if self._channel.no in self._device_config.channels:
280
+ return DataPointUsage.CDP_PRIMARY
281
+ return DataPointUsage.CDP_SECONDARY
282
+
283
+ def _get_path_data(self) -> PathData:
284
+ """Return the path data of the data_point."""
285
+ return DataPointPathData(
286
+ interface=self._device.client.interface,
287
+ address=self._device.address,
288
+ channel_no=self._channel.no,
289
+ kind=self._category,
290
+ )
291
+
292
+ def _get_signature(self) -> str:
293
+ """Return the signature of the data_point."""
294
+ return f"{self._category}/{self._channel.device.model}/{self.data_point_name_postfix}"
295
+
296
+ def _init_data_points(self) -> None:
297
+ """Initialize data point collection."""
298
+ cg = self._channel_group
299
+
300
+ # Add primary channel fields (applied to the primary channel)
301
+ for field_name, parameter in cg.fields.items():
302
+ if dp := self._device.get_generic_data_point(channel_address=self._channel.address, parameter=parameter):
303
+ self._add_data_point(field=field_name, data_point=dp, is_visible=False)
304
+
305
+ # Add visible primary channel fields
306
+ for field_name, parameter in cg.visible_fields.items():
307
+ if dp := self._device.get_generic_data_point(channel_address=self._channel.address, parameter=parameter):
308
+ self._add_data_point(field=field_name, data_point=dp, is_visible=True)
309
+
310
+ # Add fixed channel fields (absolute channel numbers from profile config)
311
+ self._add_fixed_channel_data_points(fixed_channel_fields=cg.fixed_channel_fields)
312
+ self._add_fixed_channel_data_points(fixed_channel_fields=cg.visible_fixed_channel_fields, is_visible=True)
313
+
314
+ # Add fixed channel fields from extended config (legacy support)
315
+ if self._extended:
316
+ if fixed_channels := self._extended.fixed_channel_fields:
317
+ self._add_fixed_channel_data_points(fixed_channel_fields=fixed_channels)
318
+ if additional_dps := self._extended.additional_data_points:
319
+ self._mark_data_points(custom_data_point_def=additional_dps)
320
+
321
+ # Add channel-specific fields (relative channel numbers, rebased)
322
+ self._add_channel_data_points(channel_fields=cg.channel_fields)
323
+
324
+ # Add visible channel-specific fields
325
+ self._add_channel_data_points(channel_fields=cg.visible_channel_fields, is_visible=True)
326
+
327
+ # Add default device data points
328
+ self._mark_data_points(custom_data_point_def=self._custom_data_point_def)
329
+
330
+ # Add default data points
331
+ if hmed.get_include_default_data_points(device_profile=self._device_profile):
332
+ self._mark_data_points(custom_data_point_def=hmed.get_default_data_points())
333
+
334
+ def _mark_data_point(self, *, channel_no: int | None, parameters: tuple[Parameter, ...]) -> None:
335
+ """Mark data point to be created, even though a custom data point is present."""
336
+ channel_address = get_channel_address(device_address=self._device.address, channel_no=channel_no)
337
+
338
+ for parameter in parameters:
339
+ if dp := self._device.get_generic_data_point(channel_address=channel_address, parameter=parameter):
340
+ dp.force_usage(forced_usage=DataPointUsage.DATA_POINT)
341
+
342
+ def _mark_data_points(
343
+ self, *, custom_data_point_def: Mapping[int | tuple[int, ...], tuple[Parameter, ...]]
344
+ ) -> None:
345
+ """Mark data points to be created, even though a custom data point is present."""
346
+ if not custom_data_point_def:
347
+ return
348
+ for channel_nos, parameters in custom_data_point_def.items():
349
+ if isinstance(channel_nos, int):
350
+ self._mark_data_point(channel_no=channel_nos, parameters=parameters)
351
+ else:
352
+ for channel_no in channel_nos:
353
+ self._mark_data_point(channel_no=channel_no, parameters=parameters)
354
+
355
+ def _post_init(self) -> None:
356
+ """Post action after initialisation of the data point fields."""
357
+ _LOGGER.debug(
358
+ "POST_INIT_DATA_POINT_FIELDS: Post action after initialisation of the data point fields for %s",
359
+ self.full_name,
360
+ )
@@ -0,0 +1,300 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Device profile definitions for custom data point implementations.
5
+
6
+ This module provides profile definitions and factory functions for creating
7
+ custom data points. Device-to-profile mappings are managed by DeviceProfileRegistry
8
+ in registry.py.
9
+
10
+ Public API of this module is defined by __all__.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Mapping
16
+ import logging
17
+ from typing import Final, cast
18
+
19
+ from aiohomematic import i18n
20
+ from aiohomematic.const import DataPointCategory, DeviceProfile, Parameter
21
+ from aiohomematic.exceptions import AioHomematicException
22
+ from aiohomematic.interfaces import ChannelProtocol, DeviceProtocol
23
+ from aiohomematic.model.custom.profile import (
24
+ DEFAULT_DATA_POINTS,
25
+ PROFILE_CONFIGS,
26
+ ProfileConfig,
27
+ RebasedChannelGroupConfig,
28
+ get_profile_config,
29
+ rebase_channel_group,
30
+ )
31
+ from aiohomematic.model.custom.registry import DeviceConfig, DeviceProfileRegistry
32
+ from aiohomematic.model.support import generate_unique_id
33
+ from aiohomematic.support import extract_exc_args
34
+
35
+ _LOGGER: Final = logging.getLogger(__name__)
36
+
37
+
38
+ def create_custom_data_point(
39
+ *,
40
+ channel: ChannelProtocol,
41
+ device_config: DeviceConfig,
42
+ ) -> None:
43
+ """
44
+ Create a custom data point for a channel.
45
+
46
+ This is the main entry point for creating custom data points. It handles
47
+ channel group setup, determines the relevant channels, and creates the
48
+ actual data point instance.
49
+
50
+ Args:
51
+ channel: The channel to create the data point for.
52
+ device_config: The device configuration from DeviceProfileRegistry.
53
+
54
+ """
55
+ device_profile = device_config.profile_type
56
+ profile_config = get_profile_config(profile_type=device_profile)
57
+
58
+ # Set up channel groups on the device
59
+ _add_channel_groups_to_device(
60
+ device=channel.device,
61
+ profile_config=profile_config,
62
+ device_config=device_config,
63
+ )
64
+
65
+ # Get the group number for this channel
66
+ group_no = channel.device.get_channel_group_no(channel_no=channel.no)
67
+
68
+ # Determine which channels are relevant for this data point
69
+ relevant = _get_relevant_channels(
70
+ profile_config=profile_config,
71
+ device_config=device_config,
72
+ )
73
+
74
+ if channel.no not in relevant:
75
+ return
76
+
77
+ # Create the rebased channel group
78
+ channel_group = rebase_channel_group(profile_config=profile_config, group_no=group_no)
79
+
80
+ # Get rebased additional data points
81
+ custom_data_point_def = _rebase_additional_data_points(
82
+ profile_config=profile_config,
83
+ group_no=group_no,
84
+ )
85
+
86
+ # Rebase the device config channels
87
+ rebased_device_config = _rebase_device_config_channels(
88
+ profile_config=profile_config,
89
+ device_config=device_config,
90
+ )
91
+
92
+ # Create the data point instance
93
+ _instantiate_custom_data_point(
94
+ channel=channel,
95
+ device_config=rebased_device_config,
96
+ device_profile=device_profile,
97
+ channel_group=channel_group,
98
+ custom_data_point_def=custom_data_point_def,
99
+ group_no=group_no,
100
+ )
101
+
102
+
103
+ def _instantiate_custom_data_point(
104
+ *,
105
+ channel: ChannelProtocol,
106
+ device_config: DeviceConfig,
107
+ device_profile: DeviceProfile,
108
+ channel_group: RebasedChannelGroupConfig,
109
+ custom_data_point_def: Mapping[int | tuple[int, ...], tuple[Parameter, ...]],
110
+ group_no: int | None,
111
+ ) -> None:
112
+ """Instantiate and add a custom data point to the channel."""
113
+
114
+ unique_id = generate_unique_id(config_provider=channel.device.config_provider, address=channel.address)
115
+
116
+ try:
117
+ dp = device_config.data_point_class(
118
+ channel=channel,
119
+ unique_id=unique_id,
120
+ device_profile=device_profile,
121
+ channel_group=channel_group,
122
+ custom_data_point_def=custom_data_point_def,
123
+ group_no=group_no,
124
+ device_config=device_config,
125
+ )
126
+ if dp.has_data_points:
127
+ channel.add_data_point(data_point=dp)
128
+ except Exception as exc:
129
+ raise AioHomematicException(
130
+ i18n.tr(
131
+ key="exception.model.custom.definition.create_custom_data_point.failed",
132
+ reason=extract_exc_args(exc=exc),
133
+ )
134
+ ) from exc
135
+
136
+
137
+ def _add_channel_groups_to_device(
138
+ *,
139
+ device: DeviceProtocol,
140
+ profile_config: ProfileConfig,
141
+ device_config: DeviceConfig,
142
+ ) -> None:
143
+ """Add channel group mappings to the device."""
144
+ cg = profile_config.channel_group
145
+
146
+ if (primary_channel := cg.primary_channel) is None:
147
+ return
148
+
149
+ for conf_channel in device_config.channels:
150
+ if conf_channel is None:
151
+ continue
152
+
153
+ group_no = conf_channel + primary_channel
154
+ device.add_channel_to_group(channel_no=group_no, group_no=group_no)
155
+
156
+ if cg.state_channel_offset is not None:
157
+ device.add_channel_to_group(channel_no=conf_channel + cg.state_channel_offset, group_no=group_no)
158
+
159
+ for sec_channel in cg.secondary_channels:
160
+ device.add_channel_to_group(channel_no=conf_channel + sec_channel, group_no=group_no)
161
+
162
+
163
+ def _get_relevant_channels(
164
+ *,
165
+ profile_config: ProfileConfig,
166
+ device_config: DeviceConfig,
167
+ ) -> set[int | None]:
168
+ """Return the set of channels that are relevant for this data point."""
169
+
170
+ cg = profile_config.channel_group
171
+ primary_channel = cg.primary_channel
172
+
173
+ # Collect all definition channels (primary + secondary)
174
+ def_channels: list[int | None] = [primary_channel]
175
+ def_channels.extend(cg.secondary_channels)
176
+
177
+ # Calculate relevant channels by combining definition and config channels
178
+ relevant: set[int | None] = set()
179
+ for def_ch in def_channels:
180
+ for conf_ch in device_config.channels:
181
+ if def_ch is not None and conf_ch is not None:
182
+ relevant.add(def_ch + conf_ch)
183
+ else:
184
+ relevant.add(None)
185
+
186
+ return relevant
187
+
188
+
189
+ def _rebase_device_config_channels(
190
+ *,
191
+ profile_config: ProfileConfig,
192
+ device_config: DeviceConfig,
193
+ ) -> DeviceConfig:
194
+ """Rebase device config channels with the primary channel offset."""
195
+ if (primary_channel := profile_config.channel_group.primary_channel) is None:
196
+ return device_config
197
+
198
+ rebased_channels = tuple(ch + primary_channel for ch in device_config.channels if ch is not None)
199
+
200
+ return DeviceConfig(
201
+ data_point_class=device_config.data_point_class,
202
+ profile_type=device_config.profile_type,
203
+ channels=rebased_channels if rebased_channels else device_config.channels,
204
+ extended=device_config.extended,
205
+ schedule_channel_no=device_config.schedule_channel_no,
206
+ )
207
+
208
+
209
+ def _rebase_additional_data_points(
210
+ *,
211
+ profile_config: ProfileConfig,
212
+ group_no: int | None,
213
+ ) -> Mapping[int | tuple[int, ...], tuple[Parameter, ...]]:
214
+ """Rebase additional data points with the group offset."""
215
+ additional_dps = profile_config.additional_data_points
216
+ if not group_no:
217
+ # Cast is safe: Mapping[int, T] is a subtype of Mapping[int | tuple[int, ...], T]
218
+ return cast(Mapping[int | tuple[int, ...], tuple[Parameter, ...]], additional_dps)
219
+
220
+ new_dps: dict[int | tuple[int, ...], tuple[Parameter, ...]] = {}
221
+ for channel_no, params in additional_dps.items():
222
+ new_dps[channel_no + group_no] = params
223
+
224
+ return new_dps
225
+
226
+
227
+ # =============================================================================
228
+ # Public API functions
229
+ # =============================================================================
230
+
231
+
232
+ def create_custom_data_points(*, channel: ChannelProtocol) -> None:
233
+ """
234
+ Create custom data points for a channel.
235
+
236
+ Queries the DeviceProfileRegistry for configurations matching the device model
237
+ and creates custom data points for each configuration.
238
+
239
+ Args:
240
+ channel: The channel to create data points for.
241
+
242
+ """
243
+ device_configs = DeviceProfileRegistry.get_configs(model=channel.device.model)
244
+ for device_config in device_configs:
245
+ create_custom_data_point(channel=channel, device_config=device_config)
246
+
247
+
248
+ def data_point_definition_exists(*, model: str) -> bool:
249
+ """Check if a device definition exists for the model."""
250
+ return len(DeviceProfileRegistry.get_configs(model=model)) > 0
251
+
252
+
253
+ def get_default_data_points() -> Mapping[int | tuple[int, ...], tuple[Parameter, ...]]:
254
+ """Return the default data points configuration."""
255
+ return DEFAULT_DATA_POINTS
256
+
257
+
258
+ def get_include_default_data_points(*, device_profile: DeviceProfile) -> bool:
259
+ """Return if default data points should be included for this profile."""
260
+ return get_profile_config(profile_type=device_profile).include_default_data_points
261
+
262
+
263
+ def get_required_parameters() -> tuple[Parameter, ...]:
264
+ """Return all required parameters for custom data points."""
265
+ required_parameters: list[Parameter] = []
266
+
267
+ # Add default data points
268
+ for params in DEFAULT_DATA_POINTS.values():
269
+ required_parameters.extend(params)
270
+
271
+ # Add parameters from profile configurations
272
+ for profile_config in PROFILE_CONFIGS.values():
273
+ group = profile_config.channel_group
274
+ required_parameters.extend(group.fields.values())
275
+ required_parameters.extend(group.visible_fields.values())
276
+ for field_map in group.channel_fields.values():
277
+ required_parameters.extend(field_map.values())
278
+ for field_map in group.visible_channel_fields.values():
279
+ required_parameters.extend(field_map.values())
280
+ for field_map in group.fixed_channel_fields.values():
281
+ required_parameters.extend(field_map.values())
282
+ for field_map in group.visible_fixed_channel_fields.values():
283
+ required_parameters.extend(field_map.values())
284
+ for params in profile_config.additional_data_points.values():
285
+ required_parameters.extend(params)
286
+
287
+ # Add required parameters from DeviceProfileRegistry extended configs
288
+ for extended_config in DeviceProfileRegistry.get_all_extended_configs():
289
+ required_parameters.extend(extended_config.required_parameters)
290
+
291
+ return tuple(sorted(set(required_parameters)))
292
+
293
+
294
+ def is_multi_channel_device(*, model: str, category: DataPointCategory) -> bool:
295
+ """Return true if device has multiple channels for the given category."""
296
+ device_configs = DeviceProfileRegistry.get_configs(model=model, category=category)
297
+ channels: list[int | None] = []
298
+ for config in device_configs:
299
+ channels.extend(config.channels)
300
+ return len(channels) > 1