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,722 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Custom cover data points for blinds, shutters, and garage doors.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from enum import IntEnum, StrEnum
13
+ import logging
14
+ from typing import Final, Unpack
15
+
16
+ from aiohomematic.const import DataPointCategory, DataPointUsage, DeviceProfile, Field, Parameter
17
+ from aiohomematic.converter import convert_hm_level_to_cpv
18
+ from aiohomematic.model.custom.data_point import CustomDataPoint
19
+ from aiohomematic.model.custom.field import DataPointField
20
+ from aiohomematic.model.custom.mixins import PositionMixin, StateChangeArgs
21
+ from aiohomematic.model.custom.registry import DeviceProfileRegistry, ExtendedDeviceConfig
22
+ from aiohomematic.model.data_point import CallParameterCollector, bind_collector
23
+ from aiohomematic.model.generic import DpAction, DpActionSelect, DpFloat, DpSelect, DpSensor
24
+ from aiohomematic.property_decorators import state_property
25
+
26
+ _LOGGER: Final = logging.getLogger(__name__)
27
+
28
+ # Timeout for acquiring the per-instance command processing lock to avoid
29
+ # potential deadlocks or indefinite serialization if an awaited call inside
30
+ # the critical section stalls.
31
+ _COMMAND_LOCK_TIMEOUT: Final[float] = 5.0
32
+
33
+ _CLOSED_LEVEL: Final = 0.0
34
+ _COVER_VENT_MAX_POSITION: Final = 50
35
+ _LEVEL_TO_POSITION_MULTIPLIER: Final = 100.0
36
+ _MAX_LEVEL_POSITION: Final = 100.0
37
+ _MIN_LEVEL_POSITION: Final = 0.0
38
+ _OPEN_LEVEL: Final = 1.0
39
+ _OPEN_TILT_LEVEL: Final = 1.0
40
+ _WD_CLOSED_LEVEL: Final = -0.005
41
+
42
+
43
+ class _CoverActivity(StrEnum):
44
+ """Enum with cover activities."""
45
+
46
+ CLOSING = "DOWN"
47
+ OPENING = "UP"
48
+
49
+
50
+ class _CoverPosition(IntEnum):
51
+ """Enum with cover positions."""
52
+
53
+ OPEN = 100
54
+ VENT = 10
55
+ CLOSED = 0
56
+
57
+
58
+ class _GarageDoorActivity(IntEnum):
59
+ """Enum with garage door commands."""
60
+
61
+ CLOSING = 5
62
+ OPENING = 2
63
+
64
+
65
+ class _GarageDoorCommand(StrEnum):
66
+ """Enum with garage door commands."""
67
+
68
+ CLOSE = "CLOSE"
69
+ NOP = "NOP"
70
+ OPEN = "OPEN"
71
+ PARTIAL_OPEN = "PARTIAL_OPEN"
72
+ STOP = "STOP"
73
+
74
+
75
+ class _GarageDoorState(StrEnum):
76
+ """Enum with garage door states."""
77
+
78
+ CLOSED = "CLOSED"
79
+ OPEN = "OPEN"
80
+ VENTILATION_POSITION = "VENTILATION_POSITION"
81
+ POSITION_UNKNOWN = "_POSITION_UNKNOWN"
82
+
83
+
84
+ class _StateChangeArg(StrEnum):
85
+ """Enum with cover state change arguments."""
86
+
87
+ CLOSE = "close"
88
+ OPEN = "open"
89
+ POSITION = "position"
90
+ TILT_CLOSE = "tilt_close"
91
+ TILT_OPEN = "tilt_open"
92
+ TILT_POSITION = "tilt_position"
93
+ VENT = "vent"
94
+
95
+
96
+ class CustomDpCover(PositionMixin, CustomDataPoint):
97
+ """Class for Homematic cover data point."""
98
+
99
+ __slots__ = (
100
+ "_command_processing_lock",
101
+ "_use_group_channel_for_cover_state",
102
+ )
103
+
104
+ _category = DataPointCategory.COVER
105
+ _closed_level: float = _CLOSED_LEVEL
106
+ _closed_position: int = int(_CLOSED_LEVEL * _LEVEL_TO_POSITION_MULTIPLIER)
107
+ _open_level: float = _OPEN_LEVEL
108
+
109
+ # Declarative data point field definitions
110
+ _dp_direction: Final = DataPointField(field=Field.DIRECTION, dpt=DpSensor[str | None])
111
+ _dp_group_level: Final = DataPointField(field=Field.GROUP_LEVEL, dpt=DpSensor[float | None])
112
+ _dp_level: Final = DataPointField(field=Field.LEVEL, dpt=DpFloat)
113
+ _dp_stop: Final = DataPointField(field=Field.STOP, dpt=DpAction)
114
+
115
+ @property
116
+ def _group_level(self) -> float:
117
+ """Return the channel level of the cover."""
118
+ if (
119
+ self._use_group_channel_for_cover_state
120
+ and self._dp_group_level.value is not None
121
+ and self.usage == DataPointUsage.CDP_PRIMARY
122
+ ):
123
+ return float(self._dp_group_level.value)
124
+ return self._dp_level.value if self._dp_level.value is not None else self._closed_level
125
+
126
+ @state_property
127
+ def current_channel_position(self) -> int:
128
+ """Return current channel position of cover."""
129
+ return self.level_to_position(self._dp_level.value) or self._closed_position
130
+
131
+ @state_property
132
+ def current_position(self) -> int:
133
+ """Return current group position of cover."""
134
+ return self.level_to_position(self._group_level) or self._closed_position
135
+
136
+ @state_property
137
+ def is_closed(self) -> bool:
138
+ """Return if the cover is closed."""
139
+ return self._group_level == self._closed_level
140
+
141
+ @state_property
142
+ def is_closing(self) -> bool | None:
143
+ """Return if the cover is closing."""
144
+ if self._dp_direction.value is not None:
145
+ return str(self._dp_direction.value) == _CoverActivity.CLOSING
146
+ return None
147
+
148
+ @state_property
149
+ def is_opening(self) -> bool | None:
150
+ """Return if the cover is opening."""
151
+ if self._dp_direction.value is not None:
152
+ return str(self._dp_direction.value) == _CoverActivity.OPENING
153
+ return None
154
+
155
+ @bind_collector
156
+ async def close(self, *, collector: CallParameterCollector | None = None) -> None:
157
+ """Close the cover."""
158
+ if not self.is_state_change(close=True):
159
+ return
160
+ await self._set_level(level=self._closed_level, collector=collector)
161
+
162
+ def is_state_change(self, **kwargs: Unpack[StateChangeArgs]) -> bool:
163
+ """Check if the state changes due to kwargs."""
164
+ if kwargs.get(_StateChangeArg.OPEN) is not None and self._group_level != self._open_level:
165
+ return True
166
+ if kwargs.get(_StateChangeArg.CLOSE) is not None and self._group_level != self._closed_level:
167
+ return True
168
+ if (position := kwargs.get(_StateChangeArg.POSITION)) is not None and position != self.current_position:
169
+ return True
170
+ return super().is_state_change(**kwargs)
171
+
172
+ @bind_collector
173
+ async def open(self, *, collector: CallParameterCollector | None = None) -> None:
174
+ """Open the cover."""
175
+ if not self.is_state_change(open=True):
176
+ return
177
+ await self._set_level(level=self._open_level, collector=collector)
178
+
179
+ @bind_collector
180
+ async def set_position(
181
+ self,
182
+ *,
183
+ position: int | None = None,
184
+ tilt_position: int | None = None,
185
+ collector: CallParameterCollector | None = None,
186
+ ) -> None:
187
+ """Move the cover to a specific position."""
188
+ if not self.is_state_change(position=position):
189
+ return
190
+ level = (
191
+ self.position_to_level(int(min(_MAX_LEVEL_POSITION, max(_MIN_LEVEL_POSITION, position))))
192
+ if position is not None
193
+ else None
194
+ )
195
+ await self._set_level(level=level, collector=collector)
196
+
197
+ @bind_collector(enabled=False)
198
+ async def stop(self, *, collector: CallParameterCollector | None = None) -> None:
199
+ """Stop the device if in motion."""
200
+ await self._dp_stop.send_value(value=True, collector=collector)
201
+
202
+ def _post_init(self) -> None:
203
+ """Post action after initialisation of the data point fields."""
204
+ super()._post_init()
205
+
206
+ self._command_processing_lock = asyncio.Lock()
207
+ self._use_group_channel_for_cover_state = self._device.config_provider.config.use_group_channel_for_cover_state
208
+
209
+ async def _set_level(
210
+ self,
211
+ *,
212
+ level: float | None = None,
213
+ tilt_level: float | None = None,
214
+ collector: CallParameterCollector | None = None,
215
+ ) -> None:
216
+ """Move the cover to a specific position. Value range is 0.0 to 1.01."""
217
+ if level is None:
218
+ return
219
+ await self._dp_level.send_value(value=level, collector=collector)
220
+
221
+
222
+ class CustomDpWindowDrive(CustomDpCover):
223
+ """Class for Homematic window drive."""
224
+
225
+ __slots__ = ()
226
+
227
+ _closed_level: float = _WD_CLOSED_LEVEL
228
+ _open_level: float = _OPEN_LEVEL
229
+
230
+ @state_property
231
+ def current_position(self) -> int:
232
+ """Return current position of cover."""
233
+ level = self._dp_level.value if self._dp_level.value is not None else self._closed_level
234
+ if level == _WD_CLOSED_LEVEL:
235
+ level = _CLOSED_LEVEL
236
+ elif level == _CLOSED_LEVEL:
237
+ level = 0.01
238
+ return self.level_to_position(level) or self._closed_position
239
+
240
+ async def _set_level(
241
+ self,
242
+ *,
243
+ level: float | None = None,
244
+ tilt_level: float | None = None,
245
+ collector: CallParameterCollector | None = None,
246
+ ) -> None:
247
+ """Move the window drive to a specific position. Value range is -0.005 to 1.01."""
248
+ if level is None:
249
+ return
250
+
251
+ if level == _CLOSED_LEVEL:
252
+ wd_level = _WD_CLOSED_LEVEL
253
+ elif _CLOSED_LEVEL < level <= 0.01:
254
+ wd_level = 0
255
+ else:
256
+ wd_level = level
257
+ await self._dp_level.send_value(value=wd_level, collector=collector, do_validate=False)
258
+
259
+
260
+ class CustomDpBlind(CustomDpCover):
261
+ """Class for Homematic blind data point."""
262
+
263
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
264
+
265
+ _open_tilt_level: float = _OPEN_TILT_LEVEL
266
+
267
+ # Declarative data point field definitions
268
+ _dp_combined = DataPointField(field=Field.LEVEL_COMBINED, dpt=DpAction)
269
+ _dp_group_level_2: Final = DataPointField(field=Field.GROUP_LEVEL_2, dpt=DpSensor[float | None])
270
+ _dp_level_2: Final = DataPointField(field=Field.LEVEL_2, dpt=DpFloat)
271
+
272
+ @property
273
+ def _group_tilt_level(self) -> float:
274
+ """Return the group level of the tilt."""
275
+ if (
276
+ self._use_group_channel_for_cover_state
277
+ and self._dp_group_level_2.value is not None
278
+ and self.usage == DataPointUsage.CDP_PRIMARY
279
+ ):
280
+ return float(self._dp_group_level_2.value)
281
+ return self._dp_level_2.value if self._dp_level_2.value is not None else self._closed_level
282
+
283
+ @property
284
+ def _target_level(self) -> float | None:
285
+ """Return the level of last service call."""
286
+ if (last_value_send := self._dp_level.unconfirmed_last_value_send) is not None:
287
+ return float(last_value_send)
288
+ return None
289
+
290
+ @property
291
+ def _target_tilt_level(self) -> float | None:
292
+ """Return the tilt level of last service call."""
293
+ if (last_value_send := self._dp_level_2.unconfirmed_last_value_send) is not None:
294
+ return float(last_value_send)
295
+ return None
296
+
297
+ @state_property
298
+ def current_channel_tilt_position(self) -> int:
299
+ """Return current channel_tilt position of cover."""
300
+ return self.level_to_position(self._dp_level_2.value) or self._closed_position
301
+
302
+ @state_property
303
+ def current_tilt_position(self) -> int:
304
+ """Return current tilt position of cover."""
305
+ return self.level_to_position(self._group_tilt_level) or self._closed_position
306
+
307
+ @bind_collector(enabled=False)
308
+ async def close(self, *, collector: CallParameterCollector | None = None) -> None:
309
+ """Close the cover and close the tilt."""
310
+ if not self.is_state_change(close=True, tilt_close=True):
311
+ return
312
+ await self._set_level(
313
+ level=self._closed_level,
314
+ tilt_level=self._closed_level,
315
+ collector=collector,
316
+ )
317
+
318
+ @bind_collector(enabled=False)
319
+ async def close_tilt(self, *, collector: CallParameterCollector | None = None) -> None:
320
+ """Close the tilt."""
321
+ if not self.is_state_change(tilt_close=True):
322
+ return
323
+ await self._set_level(tilt_level=self._closed_level, collector=collector)
324
+
325
+ def is_state_change(self, **kwargs: Unpack[StateChangeArgs]) -> bool:
326
+ """Check if the state changes due to kwargs."""
327
+ if (
328
+ tilt_position := kwargs.get(_StateChangeArg.TILT_POSITION)
329
+ ) is not None and tilt_position != self.current_tilt_position:
330
+ return True
331
+ if kwargs.get(_StateChangeArg.TILT_OPEN) is not None and self.current_tilt_position != _CoverPosition.OPEN:
332
+ return True
333
+ if kwargs.get(_StateChangeArg.TILT_CLOSE) is not None and self.current_tilt_position != _CoverPosition.CLOSED:
334
+ return True
335
+ return super().is_state_change(**kwargs)
336
+
337
+ @bind_collector(enabled=False)
338
+ async def open(self, *, collector: CallParameterCollector | None = None) -> None:
339
+ """Open the cover and open the tilt."""
340
+ if not self.is_state_change(open=True, tilt_open=True):
341
+ return
342
+ await self._set_level(
343
+ level=self._open_level,
344
+ tilt_level=self._open_tilt_level,
345
+ collector=collector,
346
+ )
347
+
348
+ @bind_collector(enabled=False)
349
+ async def open_tilt(self, *, collector: CallParameterCollector | None = None) -> None:
350
+ """Open the tilt."""
351
+ if not self.is_state_change(tilt_open=True):
352
+ return
353
+ await self._set_level(tilt_level=self._open_tilt_level, collector=collector)
354
+
355
+ @bind_collector(enabled=False)
356
+ async def set_position(
357
+ self,
358
+ *,
359
+ position: int | None = None,
360
+ tilt_position: int | None = None,
361
+ collector: CallParameterCollector | None = None,
362
+ ) -> None:
363
+ """Move the blind to a specific position."""
364
+ if not self.is_state_change(position=position, tilt_position=tilt_position):
365
+ return
366
+ level = (
367
+ self.position_to_level(int(min(_MAX_LEVEL_POSITION, max(_MIN_LEVEL_POSITION, position))))
368
+ if position is not None
369
+ else None
370
+ )
371
+ tilt_level = (
372
+ self.position_to_level(int(min(_MAX_LEVEL_POSITION, max(_MIN_LEVEL_POSITION, tilt_position))))
373
+ if tilt_position is not None
374
+ else None
375
+ )
376
+ await self._set_level(level=level, tilt_level=tilt_level, collector=collector)
377
+
378
+ @bind_collector(enabled=False)
379
+ async def stop(self, *, collector: CallParameterCollector | None = None) -> None:
380
+ """Stop the device if in motion."""
381
+ try:
382
+ acquired: bool = await asyncio.wait_for(
383
+ self._command_processing_lock.acquire(), timeout=_COMMAND_LOCK_TIMEOUT
384
+ )
385
+ except TimeoutError:
386
+ acquired = False
387
+ _LOGGER.warning( # i18n-log: ignore
388
+ "%s: command lock acquisition timed out; proceeding without lock", self
389
+ )
390
+ try:
391
+ await self._stop(collector=collector)
392
+ finally:
393
+ if acquired:
394
+ self._command_processing_lock.release()
395
+
396
+ @bind_collector(enabled=False)
397
+ async def stop_tilt(self, *, collector: CallParameterCollector | None = None) -> None:
398
+ """Stop the device if in motion. Use only when command_processing_lock is held."""
399
+ await self.stop(collector=collector)
400
+
401
+ def _get_combined_value(self, *, level: float | None = None, tilt_level: float | None = None) -> str | None:
402
+ """Return the combined parameter."""
403
+ if level is None and tilt_level is None:
404
+ return None
405
+ levels: list[str] = []
406
+ # the resulting hex value is based on the doubled position
407
+ if level is not None:
408
+ levels.append(convert_hm_level_to_cpv(value=level))
409
+ if tilt_level is not None:
410
+ levels.append(convert_hm_level_to_cpv(value=tilt_level))
411
+
412
+ if levels:
413
+ return ",".join(levels)
414
+ return None
415
+
416
+ @bind_collector
417
+ async def _send_level(
418
+ self,
419
+ *,
420
+ level: float,
421
+ tilt_level: float,
422
+ collector: CallParameterCollector | None = None,
423
+ ) -> None:
424
+ """Transmit a new target level to the device."""
425
+ if self._dp_combined.is_hmtype and (
426
+ combined_parameter := self._get_combined_value(level=level, tilt_level=tilt_level)
427
+ ):
428
+ # don't use collector for blind combined parameter
429
+ await self._dp_combined.send_value(value=combined_parameter, collector=None)
430
+ return
431
+
432
+ await self._dp_level_2.send_value(value=tilt_level, collector=collector)
433
+ await super()._set_level(level=level, collector=collector)
434
+
435
+ async def _set_level(
436
+ self,
437
+ *,
438
+ level: float | None = None,
439
+ tilt_level: float | None = None,
440
+ collector: CallParameterCollector | None = None,
441
+ ) -> None:
442
+ """
443
+ Move the cover to a specific tilt level. Value range is 0.0 to 1.00.
444
+
445
+ level or tilt_level may be set to None for no change.
446
+ """
447
+ currently_moving = False
448
+
449
+ try:
450
+ acquired: bool = await asyncio.wait_for(
451
+ self._command_processing_lock.acquire(), timeout=_COMMAND_LOCK_TIMEOUT
452
+ )
453
+ except TimeoutError:
454
+ acquired = False
455
+ _LOGGER.warning( # i18n-log: ignore
456
+ "%s: command lock acquisition timed out; proceeding without lock", self
457
+ )
458
+
459
+ try:
460
+ if level is not None:
461
+ _level = level
462
+ elif self._target_level is not None:
463
+ # The blind moves and the target blind height is known
464
+ currently_moving = True
465
+ _level = self._target_level
466
+ else: # The blind is at a standstill and no level is explicitly requested => we remain at the current level
467
+ _level = self._group_level
468
+
469
+ if tilt_level is not None:
470
+ _tilt_level = tilt_level
471
+ elif self._target_tilt_level is not None:
472
+ # The blind moves and the target slat position is known
473
+ currently_moving = True
474
+ _tilt_level = self._target_tilt_level
475
+ else: # The blind is at a standstill and no tilt is explicitly desired => we remain at the current angle
476
+ _tilt_level = self._group_tilt_level
477
+
478
+ if currently_moving:
479
+ # Blind actors are buggy when sending new coordinates while they are moving. So we stop them first.
480
+ await self._stop()
481
+
482
+ await self._send_level(level=_level, tilt_level=_tilt_level, collector=collector)
483
+ finally:
484
+ if acquired:
485
+ self._command_processing_lock.release()
486
+
487
+ @bind_collector(enabled=False)
488
+ async def _stop(self, *, collector: CallParameterCollector | None = None) -> None:
489
+ """Stop the device if in motion. Do only call with _command_processing_lock held."""
490
+ await super().stop(collector=collector)
491
+
492
+
493
+ class CustomDpIpBlind(CustomDpBlind):
494
+ """Class for HomematicIP blind data point."""
495
+
496
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
497
+
498
+ # Declarative data point field definitions (override parent)
499
+ _dp_combined = DataPointField(field=Field.COMBINED_PARAMETER, dpt=DpAction)
500
+ _dp_operation_mode: Final = DataPointField(field=Field.OPERATION_MODE, dpt=DpSelect)
501
+
502
+ @property
503
+ def operation_mode(self) -> str | None:
504
+ """Return operation mode of cover."""
505
+ val = self._dp_operation_mode.value
506
+ return val if isinstance(val, str) else None
507
+
508
+ def _get_combined_value(self, *, level: float | None = None, tilt_level: float | None = None) -> str | None:
509
+ """Return the combined parameter."""
510
+ if level is None and tilt_level is None:
511
+ return None
512
+ levels: list[str] = []
513
+ if (tilt_pos := self.level_to_position(tilt_level)) is not None:
514
+ levels.append(f"L2={tilt_pos}")
515
+ if (level_pos := self.level_to_position(level)) is not None:
516
+ levels.append(f"L={level_pos}")
517
+
518
+ if levels:
519
+ return ",".join(levels)
520
+ return None
521
+
522
+
523
+ class CustomDpGarage(PositionMixin, CustomDataPoint):
524
+ """Class for Homematic garage data point."""
525
+
526
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
527
+
528
+ _category = DataPointCategory.COVER
529
+
530
+ # Declarative data point field definitions
531
+ _dp_door_command: Final = DataPointField(field=Field.DOOR_COMMAND, dpt=DpActionSelect)
532
+ _dp_door_state: Final = DataPointField(field=Field.DOOR_STATE, dpt=DpSensor[str | None])
533
+ _dp_section: Final = DataPointField(field=Field.SECTION, dpt=DpSensor[int | None])
534
+
535
+ @state_property
536
+ def current_position(self) -> int | None:
537
+ """Return current position of the garage door ."""
538
+ if self._dp_door_state.value == _GarageDoorState.OPEN:
539
+ return _CoverPosition.OPEN
540
+ if self._dp_door_state.value == _GarageDoorState.VENTILATION_POSITION:
541
+ return _CoverPosition.VENT
542
+ if self._dp_door_state.value == _GarageDoorState.CLOSED:
543
+ return _CoverPosition.CLOSED
544
+ return None
545
+
546
+ @state_property
547
+ def is_closed(self) -> bool | None:
548
+ """Return if the garage door is closed."""
549
+ if self._dp_door_state.value is not None:
550
+ return str(self._dp_door_state.value) == _GarageDoorState.CLOSED
551
+ return None
552
+
553
+ @state_property
554
+ def is_closing(self) -> bool | None:
555
+ """Return if the garage door is closing."""
556
+ if self._dp_section.value is not None:
557
+ return int(self._dp_section.value) == _GarageDoorActivity.CLOSING
558
+ return None
559
+
560
+ @state_property
561
+ def is_opening(self) -> bool | None:
562
+ """Return if the garage door is opening."""
563
+ if self._dp_section.value is not None:
564
+ return int(self._dp_section.value) == _GarageDoorActivity.OPENING
565
+ return None
566
+
567
+ @bind_collector
568
+ async def close(self, *, collector: CallParameterCollector | None = None) -> None:
569
+ """Close the garage door."""
570
+ if not self.is_state_change(close=True):
571
+ return
572
+ await self._dp_door_command.send_value(value=_GarageDoorCommand.CLOSE, collector=collector)
573
+
574
+ def is_state_change(self, **kwargs: Unpack[StateChangeArgs]) -> bool:
575
+ """Check if the state changes due to kwargs."""
576
+ if kwargs.get(_StateChangeArg.OPEN) is not None and self.current_position != _CoverPosition.OPEN:
577
+ return True
578
+ if kwargs.get(_StateChangeArg.VENT) is not None and self.current_position != _CoverPosition.VENT:
579
+ return True
580
+ if kwargs.get(_StateChangeArg.CLOSE) is not None and self.current_position != _CoverPosition.CLOSED:
581
+ return True
582
+ return super().is_state_change(**kwargs)
583
+
584
+ @bind_collector
585
+ async def open(self, *, collector: CallParameterCollector | None = None) -> None:
586
+ """Open the garage door."""
587
+ if not self.is_state_change(open=True):
588
+ return
589
+ await self._dp_door_command.send_value(value=_GarageDoorCommand.OPEN, collector=collector)
590
+
591
+ @bind_collector
592
+ async def set_position(
593
+ self,
594
+ *,
595
+ position: int | None = None,
596
+ tilt_position: int | None = None,
597
+ collector: CallParameterCollector | None = None,
598
+ ) -> None:
599
+ """Move the garage door to a specific position."""
600
+ if position is None:
601
+ return
602
+ if _COVER_VENT_MAX_POSITION < position <= _CoverPosition.OPEN:
603
+ await self.open(collector=collector)
604
+ if _CoverPosition.VENT < position <= _COVER_VENT_MAX_POSITION:
605
+ await self.vent(collector=collector)
606
+ if _CoverPosition.CLOSED <= position <= _CoverPosition.VENT:
607
+ await self.close(collector=collector)
608
+
609
+ @bind_collector(enabled=False)
610
+ async def stop(self, *, collector: CallParameterCollector | None = None) -> None:
611
+ """Stop the device if in motion."""
612
+ await self._dp_door_command.send_value(value=_GarageDoorCommand.STOP, collector=collector)
613
+
614
+ @bind_collector
615
+ async def vent(self, *, collector: CallParameterCollector | None = None) -> None:
616
+ """Move the garage door to vent position."""
617
+ if not self.is_state_change(vent=True):
618
+ return
619
+ await self._dp_door_command.send_value(value=_GarageDoorCommand.PARTIAL_OPEN, collector=collector)
620
+
621
+
622
+ # =============================================================================
623
+ # DeviceProfileRegistry Registration
624
+ # =============================================================================
625
+
626
+ # RF Cover
627
+ DeviceProfileRegistry.register(
628
+ category=DataPointCategory.COVER,
629
+ models=(
630
+ "263 146",
631
+ "263 147",
632
+ "HM-LC-Bl1-Velux",
633
+ "HM-LC-Bl1-FM",
634
+ "HM-LC-Bl1-FM-2",
635
+ "HM-LC-Bl1-PB-FM",
636
+ "HM-LC-Bl1-SM",
637
+ "HM-LC-Bl1-SM-2",
638
+ "HM-LC-Bl1PBU-FM",
639
+ "HM-LC-BlX",
640
+ "ZEL STG RM FEP 230V",
641
+ ),
642
+ data_point_class=CustomDpCover,
643
+ profile_type=DeviceProfile.RF_COVER,
644
+ )
645
+ DeviceProfileRegistry.register(
646
+ category=DataPointCategory.COVER,
647
+ models="HMW-LC-Bl1",
648
+ data_point_class=CustomDpCover,
649
+ profile_type=DeviceProfile.RF_COVER,
650
+ channels=(3,),
651
+ )
652
+
653
+ # RF Blind
654
+ DeviceProfileRegistry.register(
655
+ category=DataPointCategory.COVER,
656
+ models=("HM-LC-Ja1PBU-FM", "HM-LC-JaX"),
657
+ data_point_class=CustomDpBlind,
658
+ profile_type=DeviceProfile.RF_COVER,
659
+ )
660
+
661
+ # RF Window Drive
662
+ DeviceProfileRegistry.register(
663
+ category=DataPointCategory.COVER,
664
+ models="HM-Sec-Win",
665
+ data_point_class=CustomDpWindowDrive,
666
+ profile_type=DeviceProfile.RF_COVER,
667
+ channels=(1,),
668
+ extended=ExtendedDeviceConfig(
669
+ additional_data_points={
670
+ 1: (Parameter.DIRECTION, Parameter.WORKING, Parameter.ERROR),
671
+ 2: (Parameter.LEVEL, Parameter.STATUS),
672
+ }
673
+ ),
674
+ )
675
+
676
+ # IP Cover
677
+ DeviceProfileRegistry.register(
678
+ category=DataPointCategory.COVER,
679
+ models=("HmIP-BROLL", "HmIP-FROLL"),
680
+ data_point_class=CustomDpCover,
681
+ profile_type=DeviceProfile.IP_COVER,
682
+ channels=(4,),
683
+ )
684
+
685
+ # IP Blind
686
+ DeviceProfileRegistry.register(
687
+ category=DataPointCategory.COVER,
688
+ models=("HmIP-BBL", "HmIP-FBL"),
689
+ data_point_class=CustomDpIpBlind,
690
+ profile_type=DeviceProfile.IP_COVER,
691
+ channels=(4,),
692
+ )
693
+ DeviceProfileRegistry.register(
694
+ category=DataPointCategory.COVER,
695
+ models="HmIP-DRBLI4",
696
+ data_point_class=CustomDpIpBlind,
697
+ profile_type=DeviceProfile.IP_COVER,
698
+ channels=(10, 14, 18, 22),
699
+ )
700
+ DeviceProfileRegistry.register(
701
+ category=DataPointCategory.COVER,
702
+ models="HmIPW-DRBL4",
703
+ data_point_class=CustomDpIpBlind,
704
+ profile_type=DeviceProfile.IP_COVER,
705
+ channels=(2, 6, 10, 14),
706
+ )
707
+
708
+ # IP HDM
709
+ DeviceProfileRegistry.register(
710
+ category=DataPointCategory.COVER,
711
+ models="HmIP-HDM",
712
+ data_point_class=CustomDpIpBlind,
713
+ profile_type=DeviceProfile.IP_HDM,
714
+ )
715
+
716
+ # IP Garage
717
+ DeviceProfileRegistry.register(
718
+ category=DataPointCategory.COVER,
719
+ models=("HmIP-MOD-HO", "HmIP-MOD-TM"),
720
+ data_point_class=CustomDpGarage,
721
+ profile_type=DeviceProfile.IP_GARAGE,
722
+ )