aiohomematic 2025.11.3__py3-none-any.whl

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

Potentially problematic release.


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

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