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,592 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """Support for data points used within aiohomematic."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import abstractmethod
8
+ from collections.abc import Mapping
9
+ from enum import StrEnum
10
+ from functools import lru_cache
11
+ import logging
12
+ from typing import Any, Final
13
+
14
+ from aiohomematic.const import (
15
+ ADDRESS_SEPARATOR,
16
+ HUB_ADDRESS,
17
+ HUB_SET_PATH_ROOT,
18
+ HUB_STATE_PATH_ROOT,
19
+ INSTALL_MODE_ADDRESS,
20
+ PROGRAM_ADDRESS,
21
+ PROGRAM_SET_PATH_ROOT,
22
+ PROGRAM_STATE_PATH_ROOT,
23
+ SET_PATH_ROOT,
24
+ STATE_PATH_ROOT,
25
+ SYSVAR_ADDRESS,
26
+ SYSVAR_SET_PATH_ROOT,
27
+ SYSVAR_STATE_PATH_ROOT,
28
+ SYSVAR_TYPE,
29
+ VIRTDEV_SET_PATH_ROOT,
30
+ VIRTDEV_STATE_PATH_ROOT,
31
+ VIRTUAL_REMOTE_ADDRESSES,
32
+ DataPointUsage,
33
+ Interface,
34
+ ParameterData,
35
+ ParameterType,
36
+ )
37
+ from aiohomematic.interfaces import ChannelProtocol, ConfigProviderProtocol
38
+ from aiohomematic.property_decorators import DelegatedProperty
39
+ from aiohomematic.support import to_bool
40
+
41
+ __all__ = [
42
+ "ChannelNameData",
43
+ "DataPointNameData",
44
+ "HubPathData",
45
+ "check_channel_is_the_only_primary_channel",
46
+ "convert_value",
47
+ "generate_channel_unique_id",
48
+ "generate_unique_id",
49
+ "get_channel_name_data",
50
+ "get_custom_data_point_name",
51
+ "get_device_name",
52
+ "get_data_point_name_data",
53
+ "get_event_name",
54
+ "get_index_of_value_from_value_list",
55
+ "get_value_from_value_list",
56
+ "is_binary_sensor",
57
+ ]
58
+
59
+
60
+ _LOGGER: Final = logging.getLogger(__name__)
61
+
62
+ # dict with binary_sensor relevant value lists and the corresponding TRUE value
63
+ _BINARY_SENSOR_TRUE_VALUE_DICT_FOR_VALUE_LIST: Final[Mapping[tuple[str, ...], str]] = {
64
+ ("CLOSED", "OPEN"): "OPEN",
65
+ ("DRY", "RAIN"): "RAIN",
66
+ ("STABLE", "NOT_STABLE"): "NOT_STABLE",
67
+ }
68
+
69
+
70
+ class ChannelNameData:
71
+ """Dataclass for channel name parts."""
72
+
73
+ __slots__ = (
74
+ "channel_name",
75
+ "device_name",
76
+ "full_name",
77
+ "sub_device_name",
78
+ )
79
+
80
+ def __init__(self, *, device_name: str, channel_name: str) -> None:
81
+ """Initialize the DataPointNameData class."""
82
+ self.device_name: Final = device_name
83
+ self.channel_name: Final = self._get_channel_name(device_name=device_name, channel_name=channel_name)
84
+ self.full_name = f"{device_name} {self.channel_name}".strip() if self.channel_name else device_name
85
+ self.sub_device_name = channel_name if channel_name else device_name
86
+
87
+ @staticmethod
88
+ def _get_channel_name(*, device_name: str, channel_name: str) -> str:
89
+ """Return the channel_name of the data_point only name."""
90
+ if device_name and channel_name and channel_name.startswith(device_name):
91
+ c_name = channel_name.replace(device_name, "").strip()
92
+ if c_name.startswith(ADDRESS_SEPARATOR):
93
+ c_name = c_name[1:]
94
+ return c_name
95
+ return channel_name.strip()
96
+
97
+ @staticmethod
98
+ def empty() -> ChannelNameData:
99
+ """Return an empty DataPointNameData."""
100
+ return ChannelNameData(device_name="", channel_name="")
101
+
102
+
103
+ class DataPointNameData(ChannelNameData):
104
+ """Dataclass for data_point name parts."""
105
+
106
+ __slots__ = (
107
+ "name",
108
+ "parameter_name",
109
+ )
110
+
111
+ def __init__(self, *, device_name: str, channel_name: str, parameter_name: str | None = None) -> None:
112
+ """Initialize the DataPointNameData class."""
113
+ super().__init__(device_name=device_name, channel_name=channel_name)
114
+
115
+ self.name: Final = self._get_data_point_name(
116
+ device_name=device_name, channel_name=channel_name, parameter_name=parameter_name
117
+ )
118
+ self.full_name = f"{device_name} {self.name}".strip() if self.name else device_name
119
+ self.parameter_name = parameter_name
120
+
121
+ @staticmethod
122
+ def _get_channel_parameter_name(*, channel_name: str, parameter_name: str | None) -> str:
123
+ """Return the channel parameter name of the data_point."""
124
+ if channel_name and parameter_name:
125
+ return f"{channel_name} {parameter_name}".strip()
126
+ return channel_name.strip()
127
+
128
+ @staticmethod
129
+ def empty() -> DataPointNameData:
130
+ """Return an empty DataPointNameData."""
131
+ return DataPointNameData(device_name="", channel_name="")
132
+
133
+ def _get_data_point_name(self, *, device_name: str, channel_name: str, parameter_name: str | None) -> str:
134
+ """Return the name of the data_point only name."""
135
+ channel_parameter_name = self._get_channel_parameter_name(
136
+ channel_name=channel_name, parameter_name=parameter_name
137
+ )
138
+ if device_name and channel_parameter_name and channel_parameter_name.startswith(device_name):
139
+ return channel_parameter_name[len(device_name) :].lstrip()
140
+ return channel_parameter_name
141
+
142
+
143
+ class HubNameData:
144
+ """Class for hub data_point name parts."""
145
+
146
+ __slots__ = (
147
+ "full_name",
148
+ "name",
149
+ )
150
+
151
+ def __init__(self, *, name: str, central_name: str | None = None, channel_name: str | None = None) -> None:
152
+ """Initialize the DataPointNameData class."""
153
+ self.name: Final = name
154
+ self.full_name = (
155
+ f"{channel_name} {self.name}".strip() if channel_name else f"{central_name} {self.name}".strip()
156
+ )
157
+
158
+ @staticmethod
159
+ def empty() -> HubNameData:
160
+ """Return an empty HubNameData."""
161
+ return HubNameData(name="")
162
+
163
+
164
+ def check_length_and_log(*, name: str | None, value: Any) -> Any:
165
+ """Check the length of a data point and log if too long."""
166
+ if isinstance(value, str) and len(value) > 255:
167
+ _LOGGER.debug(
168
+ "Value of data point %s exceedes maximum allowed length of 255 chars. Value will be limited to 255 chars",
169
+ name,
170
+ )
171
+ return value[0:255:1]
172
+ return value
173
+
174
+
175
+ def get_device_name(*, device_details_provider: Any, device_address: str, model: str) -> str:
176
+ """Return the cached name for a device, or an auto-generated."""
177
+ if name := device_details_provider.get_name(address=device_address):
178
+ return name # type: ignore[no-any-return]
179
+
180
+ _LOGGER.debug(
181
+ "GET_DEVICE_NAME: Using auto-generated name for %s %s",
182
+ model,
183
+ device_address,
184
+ )
185
+ return _get_generic_name(address=device_address, model=model) # Already using keyword args
186
+
187
+
188
+ def _get_generic_name(*, address: str, model: str) -> str:
189
+ """Return auto-generated device/channel name."""
190
+ return f"{model}_{address}"
191
+
192
+
193
+ def get_channel_name_data(*, channel: ChannelProtocol) -> ChannelNameData:
194
+ """Get name for data_point."""
195
+ if channel_base_name := _get_base_name_from_channel_or_device(channel=channel):
196
+ return ChannelNameData(
197
+ device_name=channel.device.name,
198
+ channel_name=channel_base_name,
199
+ )
200
+
201
+ _LOGGER.debug(
202
+ "GET_CHANNEL_NAME_DATA: Using unique_id for %s %s",
203
+ channel.device.model,
204
+ channel.address,
205
+ )
206
+ return ChannelNameData.empty()
207
+
208
+
209
+ class PathData:
210
+ """The data point path data."""
211
+
212
+ @property
213
+ @abstractmethod
214
+ def set_path(self) -> str:
215
+ """Return the base set path of the data_point."""
216
+
217
+ @property
218
+ @abstractmethod
219
+ def state_path(self) -> str:
220
+ """Return the base state path of the data_point."""
221
+
222
+
223
+ class DataPointPathData(PathData):
224
+ """The data point path data."""
225
+
226
+ __slots__ = (
227
+ "_set_path",
228
+ "_state_path",
229
+ )
230
+
231
+ def __init__(
232
+ self,
233
+ *,
234
+ interface: Interface | None,
235
+ address: str,
236
+ channel_no: int | None,
237
+ kind: str,
238
+ name: str | None = None,
239
+ ):
240
+ """Initialize the path data."""
241
+ path_item: Final = f"{address.upper()}/{channel_no}/{kind.upper()}"
242
+ self._set_path: Final = (
243
+ f"{VIRTDEV_SET_PATH_ROOT if interface == Interface.CCU_JACK else SET_PATH_ROOT}/{path_item}"
244
+ )
245
+ self._state_path: Final = (
246
+ f"{VIRTDEV_STATE_PATH_ROOT if interface == Interface.CCU_JACK else STATE_PATH_ROOT}/{path_item}"
247
+ )
248
+
249
+ set_path: Final = DelegatedProperty[str](path="_set_path")
250
+ state_path: Final = DelegatedProperty[str](path="_state_path")
251
+
252
+
253
+ class ProgramPathData(PathData):
254
+ """The program path data."""
255
+
256
+ __slots__ = (
257
+ "_set_path",
258
+ "_state_path",
259
+ )
260
+
261
+ def __init__(self, *, pid: str):
262
+ """Initialize the path data."""
263
+ self._set_path: Final = f"{PROGRAM_SET_PATH_ROOT}/{pid}"
264
+ self._state_path: Final = f"{PROGRAM_STATE_PATH_ROOT}/{pid}"
265
+
266
+ set_path: Final = DelegatedProperty[str](path="_set_path")
267
+ state_path: Final = DelegatedProperty[str](path="_state_path")
268
+
269
+
270
+ class SysvarPathData(PathData):
271
+ """The sysvar path data."""
272
+
273
+ __slots__ = (
274
+ "_set_path",
275
+ "_state_path",
276
+ )
277
+
278
+ def __init__(self, *, vid: str):
279
+ """Initialize the path data."""
280
+ self._set_path: Final = f"{SYSVAR_SET_PATH_ROOT}/{vid}"
281
+ self._state_path: Final = f"{SYSVAR_STATE_PATH_ROOT}/{vid}"
282
+
283
+ set_path: Final = DelegatedProperty[str](path="_set_path")
284
+ state_path: Final = DelegatedProperty[str](path="_state_path")
285
+
286
+
287
+ class HubPathData(PathData):
288
+ """The hub path data."""
289
+
290
+ __slots__ = (
291
+ "_set_path",
292
+ "_state_path",
293
+ )
294
+
295
+ def __init__(self, *, name: str):
296
+ """Initialize the path data."""
297
+ self._set_path: Final = f"{HUB_SET_PATH_ROOT}/{name}"
298
+ self._state_path: Final = f"{HUB_STATE_PATH_ROOT}/{name}"
299
+
300
+ set_path: Final = DelegatedProperty[str](path="_set_path")
301
+ state_path: Final = DelegatedProperty[str](path="_state_path")
302
+
303
+
304
+ def get_data_point_name_data(
305
+ *,
306
+ channel: ChannelProtocol,
307
+ parameter: str,
308
+ ) -> DataPointNameData:
309
+ """Get name for data_point."""
310
+ if channel_name := _get_base_name_from_channel_or_device(channel=channel):
311
+ p_name = parameter.title().replace("_", " ")
312
+
313
+ if _check_channel_name_with_channel_no(name=channel_name):
314
+ c_name = channel_name.split(ADDRESS_SEPARATOR)[0]
315
+ c_postfix = ""
316
+ if channel.device.paramset_description_provider.is_in_multiple_channels(
317
+ channel_address=channel.address, parameter=parameter
318
+ ):
319
+ c_postfix = "" if channel.no in (0, None) else f" ch{channel.no}"
320
+ data_point_name = DataPointNameData(
321
+ device_name=channel.device.name,
322
+ channel_name=c_name,
323
+ parameter_name=f"{p_name}{c_postfix}",
324
+ )
325
+ else:
326
+ data_point_name = DataPointNameData(
327
+ device_name=channel.device.name,
328
+ channel_name=channel_name,
329
+ parameter_name=p_name,
330
+ )
331
+ return data_point_name
332
+
333
+ _LOGGER.debug(
334
+ "GET_DATA_POINT_NAME: Using unique_id for %s %s %s",
335
+ channel.device.model,
336
+ channel.address,
337
+ parameter,
338
+ )
339
+ return DataPointNameData.empty()
340
+
341
+
342
+ def get_hub_data_point_name_data(
343
+ *,
344
+ channel: ChannelProtocol | None,
345
+ legacy_name: str,
346
+ central_name: str,
347
+ ) -> HubNameData:
348
+ """Get name for hub data_point."""
349
+ if not channel:
350
+ return HubNameData(
351
+ central_name=central_name,
352
+ name=legacy_name,
353
+ )
354
+ if channel_name := _get_base_name_from_channel_or_device(channel=channel):
355
+ p_name = (
356
+ legacy_name.replace("_", " ")
357
+ .replace(channel.address, "")
358
+ .replace(str(channel.rega_id), "")
359
+ .replace(str(channel.device.rega_id), "")
360
+ .strip()
361
+ )
362
+
363
+ if _check_channel_name_with_channel_no(name=channel_name):
364
+ channel_name = channel_name.split(":")[0]
365
+
366
+ return HubNameData(channel_name=channel_name, name=p_name)
367
+
368
+ _LOGGER.debug(
369
+ "GET_DATA_POINT_NAME: Using unique_id for %s %s %s",
370
+ channel.device.model,
371
+ channel.address,
372
+ legacy_name,
373
+ )
374
+ return HubNameData.empty()
375
+
376
+
377
+ def get_event_name(
378
+ *,
379
+ channel: ChannelProtocol,
380
+ parameter: str,
381
+ ) -> DataPointNameData:
382
+ """Get name for event."""
383
+ if channel_name := _get_base_name_from_channel_or_device(channel=channel):
384
+ p_name = parameter.title().replace("_", " ")
385
+ if _check_channel_name_with_channel_no(name=channel_name):
386
+ c_name = "" if channel.no in (0, None) else f" ch{channel.no}"
387
+ event_name = DataPointNameData(
388
+ device_name=channel.device.name,
389
+ channel_name=c_name,
390
+ parameter_name=p_name,
391
+ )
392
+ else:
393
+ event_name = DataPointNameData(
394
+ device_name=channel.device.name,
395
+ channel_name=channel_name,
396
+ parameter_name=p_name,
397
+ )
398
+ return event_name
399
+
400
+ _LOGGER.debug(
401
+ "GET_EVENT_NAME: Using unique_id for %s %s %s",
402
+ channel.device.model,
403
+ channel.address,
404
+ parameter,
405
+ )
406
+ return DataPointNameData.empty()
407
+
408
+
409
+ def get_custom_data_point_name(
410
+ *,
411
+ channel: ChannelProtocol,
412
+ is_only_primary_channel: bool,
413
+ ignore_multiple_channels_for_name: bool,
414
+ usage: DataPointUsage,
415
+ postfix: str = "",
416
+ ) -> DataPointNameData:
417
+ """Get name for custom data_point."""
418
+ if channel_name := _get_base_name_from_channel_or_device(channel=channel):
419
+ if (is_only_primary_channel or ignore_multiple_channels_for_name) and _check_channel_name_with_channel_no(
420
+ name=channel_name
421
+ ):
422
+ return DataPointNameData(
423
+ device_name=channel.device.name,
424
+ channel_name=channel_name.split(ADDRESS_SEPARATOR)[0],
425
+ parameter_name=postfix,
426
+ )
427
+ if _check_channel_name_with_channel_no(name=channel_name):
428
+ c_name = channel_name.split(ADDRESS_SEPARATOR)[0]
429
+ p_name = channel_name.split(ADDRESS_SEPARATOR)[1]
430
+ marker = "ch" if usage == DataPointUsage.CDP_PRIMARY else "vch"
431
+ p_name = f"{marker}{p_name}"
432
+ return DataPointNameData(device_name=channel.device.name, channel_name=c_name, parameter_name=p_name)
433
+ return DataPointNameData(device_name=channel.device.name, channel_name=channel_name)
434
+
435
+ _LOGGER.debug(
436
+ "GET_CUSTOM_DATA_POINT_NAME: Using unique_id for %s %s %s",
437
+ channel.device.model,
438
+ channel.address,
439
+ channel.no,
440
+ )
441
+ return DataPointNameData.empty()
442
+
443
+
444
+ def generate_unique_id(
445
+ *,
446
+ config_provider: ConfigProviderProtocol,
447
+ address: str,
448
+ parameter: str | None = None,
449
+ prefix: str | None = None,
450
+ ) -> str:
451
+ """
452
+ Build unique identifier from address and parameter.
453
+
454
+ Central id is additionally used for heating groups.
455
+ Prefix is used for events and buttons.
456
+ """
457
+ unique_id = address.replace(ADDRESS_SEPARATOR, "_").replace("-", "_")
458
+ if parameter:
459
+ unique_id = f"{unique_id}_{parameter}"
460
+
461
+ if prefix:
462
+ unique_id = f"{prefix}_{unique_id}"
463
+ if (
464
+ address in (HUB_ADDRESS, INSTALL_MODE_ADDRESS, PROGRAM_ADDRESS, SYSVAR_ADDRESS)
465
+ or address.startswith("INT000")
466
+ or address.split(ADDRESS_SEPARATOR)[0] in VIRTUAL_REMOTE_ADDRESSES
467
+ ):
468
+ return f"{config_provider.config.central_id}_{unique_id}".lower()
469
+ return f"{unique_id}".lower()
470
+
471
+
472
+ def generate_channel_unique_id(
473
+ *,
474
+ config_provider: ConfigProviderProtocol,
475
+ address: str,
476
+ ) -> str:
477
+ """Build unique identifier for a channel from address."""
478
+ unique_id = address.replace(ADDRESS_SEPARATOR, "_").replace("-", "_")
479
+ if address.split(ADDRESS_SEPARATOR)[0] in VIRTUAL_REMOTE_ADDRESSES:
480
+ return f"{config_provider.config.central_id}_{unique_id}".lower()
481
+ return unique_id.lower()
482
+
483
+
484
+ def _get_base_name_from_channel_or_device(*, channel: ChannelProtocol) -> str | None:
485
+ """Get the name from channel if it's not default, otherwise from device."""
486
+ default_channel_name = f"{channel.device.model} {channel.address}"
487
+ # Access device details provider through channel's device
488
+ name = channel.device.device_details_provider.get_name(address=channel.address)
489
+ if name is None or name == default_channel_name:
490
+ return channel.device.name if channel.no is None else f"{channel.device.name}:{channel.no}"
491
+ return name
492
+
493
+
494
+ def _check_channel_name_with_channel_no(*, name: str) -> bool:
495
+ """Check if name contains channel and this is an int."""
496
+ if name.count(ADDRESS_SEPARATOR) == 1:
497
+ channel_part = name.split(ADDRESS_SEPARATOR)[1]
498
+ try:
499
+ int(channel_part)
500
+ except ValueError:
501
+ return False
502
+ return True
503
+ return False
504
+
505
+
506
+ def convert_value(*, value: Any, target_type: ParameterType, value_list: tuple[str, ...] | None) -> Any:
507
+ """
508
+ Convert a value to target_type with safe memoization.
509
+
510
+ To avoid redundant conversions across layers, we use an internal
511
+ LRU-cached helper for hashable inputs. For unhashable inputs, we
512
+ fall back to a direct conversion path.
513
+ """
514
+ # Normalize value_list to tuple to ensure hashability where possible
515
+ norm_value_list: tuple[str, ...] | None = tuple(value_list) if isinstance(value_list, list) else value_list
516
+ try:
517
+ # This will be cached if all arguments are hashable
518
+ return _convert_value_cached(value=value, target_type=target_type, value_list=norm_value_list)
519
+ except TypeError:
520
+ # Fallback non-cached path if any argument is unhashable
521
+ return _convert_value_noncached(value=value, target_type=target_type, value_list=norm_value_list)
522
+
523
+
524
+ @lru_cache(maxsize=2048)
525
+ def _convert_value_cached(*, value: Any, target_type: ParameterType, value_list: tuple[str, ...] | None) -> Any:
526
+ return _convert_value_noncached(value=value, target_type=target_type, value_list=value_list)
527
+
528
+
529
+ def _convert_value_noncached(*, value: Any, target_type: ParameterType, value_list: tuple[str, ...] | None) -> Any:
530
+ if value is None:
531
+ return None
532
+ if target_type == ParameterType.BOOL:
533
+ if value_list:
534
+ # relevant for ENUMs retyped to a BOOL
535
+ return _get_binary_sensor_value(value=value, value_list=value_list)
536
+ if isinstance(value, str):
537
+ return to_bool(value=value)
538
+ return bool(value)
539
+ if target_type == ParameterType.FLOAT:
540
+ return float(value)
541
+ if target_type == ParameterType.INTEGER:
542
+ return int(float(value))
543
+ if target_type == ParameterType.STRING:
544
+ return str(value)
545
+ return value
546
+
547
+
548
+ def is_binary_sensor(*, parameter_data: ParameterData) -> bool:
549
+ """Check, if the sensor is a binary_sensor."""
550
+ if parameter_data["TYPE"] == ParameterType.BOOL:
551
+ return True
552
+ if value_list := parameter_data.get("VALUE_LIST"):
553
+ return tuple(value_list) in _BINARY_SENSOR_TRUE_VALUE_DICT_FOR_VALUE_LIST
554
+ return False
555
+
556
+
557
+ def _get_binary_sensor_value(*, value: int, value_list: tuple[str, ...]) -> bool:
558
+ """Return, the value of a binary_sensor."""
559
+ try:
560
+ str_value = value_list[value]
561
+ if true_value := _BINARY_SENSOR_TRUE_VALUE_DICT_FOR_VALUE_LIST.get(value_list):
562
+ return str_value == true_value
563
+ except IndexError:
564
+ pass
565
+ return False
566
+
567
+
568
+ def check_channel_is_the_only_primary_channel(
569
+ *,
570
+ current_channel_no: int | None,
571
+ primary_channel: int | None,
572
+ device_has_multiple_channels: bool,
573
+ ) -> bool:
574
+ """Check if this channel is the only primary channel."""
575
+ return bool(primary_channel == current_channel_no and device_has_multiple_channels is False)
576
+
577
+
578
+ def get_value_from_value_list(*, value: SYSVAR_TYPE, value_list: tuple[str, ...] | list[str] | None) -> str | None:
579
+ """Check if value is in value list."""
580
+ if value is not None and isinstance(value, int) and value_list is not None and value < len(value_list):
581
+ return value_list[int(value)]
582
+ return None
583
+
584
+
585
+ def get_index_of_value_from_value_list(
586
+ *, value: SYSVAR_TYPE, value_list: tuple[str, ...] | list[str] | None
587
+ ) -> int | None:
588
+ """Check if value is in value list."""
589
+ if value is not None and isinstance(value, str | StrEnum) and value_list is not None and value in value_list:
590
+ return value_list.index(value)
591
+
592
+ return None