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,1174 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Custom light data points for dimmers and colored lighting.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from enum import StrEnum
13
+ import math
14
+ from typing import Final, TypedDict, Unpack
15
+
16
+ from aiohomematic.const import DataPointCategory, DataPointUsage, DeviceProfile, Field, Parameter
17
+ from aiohomematic.model.custom.capabilities.light import LightCapabilities
18
+ from aiohomematic.model.custom.data_point import CustomDataPoint
19
+ from aiohomematic.model.custom.field import DataPointField
20
+ from aiohomematic.model.custom.mixins import BrightnessMixin, StateChangeArgs, StateChangeTimerMixin, TimerUnitMixin
21
+ from aiohomematic.model.custom.registry import DeviceConfig, DeviceProfileRegistry, ExtendedDeviceConfig
22
+ from aiohomematic.model.data_point import CallParameterCollector, bind_collector
23
+ from aiohomematic.model.generic import (
24
+ DpAction,
25
+ DpActionSelect,
26
+ DpFloat,
27
+ DpInteger,
28
+ DpSelect,
29
+ DpSensor,
30
+ GenericDataPointAny,
31
+ )
32
+ from aiohomematic.property_decorators import DelegatedProperty, Kind, state_property
33
+
34
+ # Activity states indicating LED is active
35
+ _ACTIVITY_STATES_ACTIVE: Final[frozenset[str]] = frozenset({"UP", "DOWN"})
36
+ _DIMMER_OFF: Final = 0.0
37
+ _EFFECT_OFF: Final = "Off"
38
+ _LEVEL_TO_BRIGHTNESS_MULTIPLIER: Final = 100
39
+ _MAX_BRIGHTNESS: Final = 255.0
40
+ _MAX_KELVIN: Final = 1000000
41
+ _MAX_MIREDS: Final = 500
42
+ _MAX_SATURATION: Final = 100.0
43
+ _MIN_BRIGHTNESS: Final = 0.0
44
+ _MIN_HUE: Final = 0.0
45
+ _MIN_MIREDS: Final = 153
46
+ _MIN_SATURATION: Final = 0.0
47
+ _NOT_USED: Final = 111600
48
+ _SATURATION_MULTIPLIER: Final = 100
49
+
50
+
51
+ class _DeviceOperationMode(StrEnum):
52
+ """Enum with device operation modes."""
53
+
54
+ PWM = "4_PWM"
55
+ RGB = "RGB"
56
+ RGBW = "RGBW"
57
+ TUNABLE_WHITE = "2_TUNABLE_WHITE"
58
+
59
+
60
+ class _ColorBehaviour(StrEnum):
61
+ """Enum with color behaviours."""
62
+
63
+ DO_NOT_CARE = "DO_NOT_CARE"
64
+ OFF = "OFF"
65
+ OLD_VALUE = "OLD_VALUE"
66
+ ON = "ON"
67
+
68
+
69
+ class FixedColor(StrEnum):
70
+ """Enum with colors."""
71
+
72
+ BLACK = "BLACK"
73
+ BLUE = "BLUE"
74
+ DO_NOT_CARE = "DO_NOT_CARE"
75
+ GREEN = "GREEN"
76
+ OLD_VALUE = "OLD_VALUE"
77
+ PURPLE = "PURPLE"
78
+ RED = "RED"
79
+ TURQUOISE = "TURQUOISE"
80
+ WHITE = "WHITE"
81
+ YELLOW = "YELLOW"
82
+
83
+
84
+ class _StateChangeArg(StrEnum):
85
+ """Enum with light state change arguments."""
86
+
87
+ BRIGHTNESS = "brightness"
88
+ COLOR_TEMP_KELVIN = "color_temp_kelvin"
89
+ EFFECT = "effect"
90
+ HS_COLOR = "hs_color"
91
+ OFF = "off"
92
+ ON = "on"
93
+ ON_TIME = "on_time"
94
+ RAMP_TIME = "ramp_time"
95
+
96
+
97
+ _NO_COLOR: Final = (
98
+ FixedColor.BLACK,
99
+ FixedColor.DO_NOT_CARE,
100
+ FixedColor.OLD_VALUE,
101
+ )
102
+
103
+ _EXCLUDE_FROM_COLOR_BEHAVIOUR: Final = (
104
+ _ColorBehaviour.DO_NOT_CARE,
105
+ _ColorBehaviour.OFF,
106
+ _ColorBehaviour.OLD_VALUE,
107
+ )
108
+
109
+ _OFF_COLOR_BEHAVIOUR: Final = (
110
+ _ColorBehaviour.DO_NOT_CARE,
111
+ _ColorBehaviour.OFF,
112
+ _ColorBehaviour.OLD_VALUE,
113
+ )
114
+
115
+ FIXED_COLOR_TO_HS_CONVERTER: Mapping[str, tuple[float, float]] = {
116
+ FixedColor.WHITE: (_MIN_HUE, _MIN_SATURATION),
117
+ FixedColor.RED: (_MIN_HUE, _MAX_SATURATION),
118
+ FixedColor.YELLOW: (60.0, _MAX_SATURATION),
119
+ FixedColor.GREEN: (120.0, _MAX_SATURATION),
120
+ FixedColor.TURQUOISE: (180.0, _MAX_SATURATION),
121
+ FixedColor.BLUE: (240.0, _MAX_SATURATION),
122
+ FixedColor.PURPLE: (300.0, _MAX_SATURATION),
123
+ }
124
+
125
+ # ON_TIME_LIST values mapping: (ms_value, enum_string)
126
+ # Used for flash timing in LED sequences
127
+ _ON_TIME_LIST_VALUES: Final[tuple[tuple[int, str], ...]] = (
128
+ (100, "100MS"),
129
+ (200, "200MS"),
130
+ (300, "300MS"),
131
+ (400, "400MS"),
132
+ (500, "500MS"),
133
+ (600, "600MS"),
134
+ (700, "700MS"),
135
+ (800, "800MS"),
136
+ (900, "900MS"),
137
+ (1000, "1S"),
138
+ (2000, "2S"),
139
+ (3000, "3S"),
140
+ (4000, "4S"),
141
+ (5000, "5S"),
142
+ )
143
+ _PERMANENTLY_ON: Final = "PERMANENTLY_ON"
144
+
145
+ # Repetitions constants
146
+ _NO_REPETITION: Final = "NO_REPETITION"
147
+ _INFINITE_REPETITIONS: Final = "INFINITE_REPETITIONS"
148
+ _MAX_REPETITIONS: Final = 18
149
+
150
+
151
+ def _convert_repetitions(*, repetitions: int | None) -> str:
152
+ """
153
+ Convert repetitions count to REPETITIONS VALUE_LIST value.
154
+
155
+ Args:
156
+ repetitions: Number of repetitions (0=none, 1-18=count, -1=infinite, None=none).
157
+
158
+ Returns:
159
+ VALUE_LIST string (NO_REPETITION, REPETITIONS_001-018, or INFINITE_REPETITIONS).
160
+
161
+ Raises:
162
+ ValueError: If repetitions is outside valid range (-1 to 18).
163
+
164
+ """
165
+ if repetitions is None or repetitions == 0:
166
+ return _NO_REPETITION
167
+
168
+ if repetitions == -1:
169
+ return _INFINITE_REPETITIONS
170
+
171
+ if repetitions < -1 or repetitions > _MAX_REPETITIONS:
172
+ msg = f"Repetitions must be -1 (infinite), 0 (none), or 1-{_MAX_REPETITIONS}, got {repetitions}"
173
+ raise ValueError(msg)
174
+
175
+ return f"REPETITIONS_{repetitions:03d}"
176
+
177
+
178
+ def _convert_flash_time_to_on_time_list(*, flash_time_ms: int | None) -> str:
179
+ """Convert flash time in milliseconds to nearest ON_TIME_LIST value."""
180
+ if flash_time_ms is None or flash_time_ms <= 0:
181
+ return _PERMANENTLY_ON
182
+
183
+ # If flash_time is larger than 5000ms, use PERMANENTLY_ON
184
+ if flash_time_ms > 5000:
185
+ return _PERMANENTLY_ON
186
+
187
+ # Find the closest match
188
+ best_match = _PERMANENTLY_ON
189
+ best_diff = math.inf
190
+
191
+ for ms_value, enum_str in _ON_TIME_LIST_VALUES:
192
+ if (diff := abs(ms_value - flash_time_ms)) < best_diff:
193
+ best_diff = diff
194
+ best_match = enum_str
195
+
196
+ return best_match
197
+
198
+
199
+ class LightOnArgs(TypedDict, total=False):
200
+ """Matcher for the light turn on arguments."""
201
+
202
+ brightness: int
203
+ color_temp_kelvin: int
204
+ effect: str
205
+ hs_color: tuple[float, float]
206
+ on_time: float
207
+ ramp_time: float
208
+
209
+
210
+ class LightOffArgs(TypedDict, total=False):
211
+ """Matcher for the light turn off arguments."""
212
+
213
+ on_time: float
214
+ ramp_time: float
215
+
216
+
217
+ class SoundPlayerLedOnArgs(LightOnArgs, total=False):
218
+ """Arguments for CustomDpSoundPlayerLed turn_on method (extends LightOnArgs)."""
219
+
220
+ repetitions: int # 0=none, 1-18=count, -1=infinite (converted to VALUE_LIST entry)
221
+ flash_time: int # Flash duration in milliseconds (converted to nearest VALUE_LIST entry)
222
+
223
+
224
+ class CustomDpDimmer(StateChangeTimerMixin, BrightnessMixin, CustomDataPoint):
225
+ """Base class for Homematic light data point."""
226
+
227
+ __slots__ = ("_capabilities",)
228
+
229
+ _category = DataPointCategory.LIGHT
230
+
231
+ # Declarative data point field definitions
232
+ _dp_group_level: Final = DataPointField(field=Field.GROUP_LEVEL, dpt=DpSensor[float | None])
233
+ _dp_level: Final = DataPointField(field=Field.LEVEL, dpt=DpFloat)
234
+ _dp_on_time_value = DataPointField(field=Field.ON_TIME_VALUE, dpt=DpAction)
235
+ _dp_ramp_time_value = DataPointField(field=Field.RAMP_TIME_VALUE, dpt=DpAction)
236
+
237
+ @property
238
+ def brightness_pct(self) -> int | None:
239
+ """Return the brightness in percent of this light."""
240
+ return self.level_to_brightness_pct(self._dp_level.value or _MIN_BRIGHTNESS)
241
+
242
+ @property
243
+ def capabilities(self) -> LightCapabilities:
244
+ """Return the light capabilities."""
245
+ if (caps := getattr(self, "_capabilities", None)) is None:
246
+ caps = self._compute_capabilities()
247
+ object.__setattr__(self, "_capabilities", caps)
248
+ return caps
249
+
250
+ @property
251
+ def group_brightness(self) -> int | None:
252
+ """Return the group brightness of this light between min/max brightness."""
253
+ if self._dp_group_level.value is not None:
254
+ return self.level_to_brightness(self._dp_group_level.value)
255
+ return None
256
+
257
+ @property
258
+ def group_brightness_pct(self) -> int | None:
259
+ """Return the group brightness in percent of this light."""
260
+ if self._dp_group_level.value is not None:
261
+ return self.level_to_brightness_pct(self._dp_group_level.value)
262
+ return None
263
+
264
+ @property
265
+ def has_color_temperature(self) -> bool:
266
+ """Return True if light currently has color temperature."""
267
+ return self.color_temp_kelvin is not None
268
+
269
+ @property
270
+ def has_effects(self) -> bool:
271
+ """Return True if light currently has effects."""
272
+ return self.effects is not None and len(self.effects) > 0
273
+
274
+ @property
275
+ def has_hs_color(self) -> bool:
276
+ """Return True if light currently has hs color."""
277
+ return self.hs_color is not None
278
+
279
+ @state_property
280
+ def brightness(self) -> int | None:
281
+ """Return the brightness of this light between min/max brightness."""
282
+ return self.level_to_brightness(self._dp_level.value or _MIN_BRIGHTNESS)
283
+
284
+ @state_property
285
+ def color_temp_kelvin(self) -> int | None:
286
+ """Return the color temperature in kelvin."""
287
+ return None
288
+
289
+ @state_property
290
+ def effect(self) -> str | None:
291
+ """Return the current effect."""
292
+ return None
293
+
294
+ @state_property
295
+ def effects(self) -> tuple[str, ...] | None:
296
+ """Return the supported effects."""
297
+ return None
298
+
299
+ @state_property
300
+ def hs_color(self) -> tuple[float, float] | None:
301
+ """Return the hue and saturation color value [float, float]."""
302
+ return None
303
+
304
+ @state_property
305
+ def is_on(self) -> bool | None:
306
+ """Return true if dimmer is on."""
307
+ return self._dp_level.value is not None and self._dp_level.value > _DIMMER_OFF
308
+
309
+ def is_state_change(self, **kwargs: Unpack[StateChangeArgs]) -> bool:
310
+ """Check if the state changes due to kwargs."""
311
+ if self.is_timer_state_change():
312
+ return True
313
+ if kwargs.get(_StateChangeArg.ON_TIME) is not None:
314
+ return True
315
+ if kwargs.get(_StateChangeArg.RAMP_TIME) is not None:
316
+ return True
317
+ if kwargs.get(_StateChangeArg.ON) is not None and self.is_on is not True and len(kwargs) == 1:
318
+ return True
319
+ if kwargs.get(_StateChangeArg.OFF) is not None and self.is_on is not False and len(kwargs) == 1:
320
+ return True
321
+ if (brightness := kwargs.get(_StateChangeArg.BRIGHTNESS)) is not None and brightness != self.brightness:
322
+ return True
323
+ if (hs_color := kwargs.get(_StateChangeArg.HS_COLOR)) is not None and hs_color != self.hs_color:
324
+ return True
325
+ if (
326
+ color_temp_kelvin := kwargs.get(_StateChangeArg.COLOR_TEMP_KELVIN)
327
+ ) is not None and color_temp_kelvin != self.color_temp_kelvin:
328
+ return True
329
+ if (effect := kwargs.get(_StateChangeArg.EFFECT)) is not None and effect != self.effect:
330
+ return True
331
+ return super().is_state_change(**kwargs)
332
+
333
+ @bind_collector
334
+ async def turn_off(
335
+ self, *, collector: CallParameterCollector | None = None, **kwargs: Unpack[LightOffArgs]
336
+ ) -> None:
337
+ """Turn the light off."""
338
+ self.reset_timer_on_time()
339
+ if not self.is_state_change(off=True, **kwargs):
340
+ return
341
+ if ramp_time := kwargs.get("ramp_time"):
342
+ await self._set_ramp_time_off_value(ramp_time=ramp_time, collector=collector)
343
+ await self._dp_level.send_value(value=_DIMMER_OFF, collector=collector)
344
+
345
+ @bind_collector
346
+ async def turn_on(self, *, collector: CallParameterCollector | None = None, **kwargs: Unpack[LightOnArgs]) -> None:
347
+ """Turn the light on."""
348
+ if (on_time := kwargs.get("on_time")) is not None:
349
+ self.set_timer_on_time(on_time=on_time)
350
+ if not self.is_state_change(on=True, **kwargs):
351
+ return
352
+
353
+ if (timer := self.get_and_start_timer()) is not None:
354
+ await self._set_on_time_value(on_time=timer, collector=collector)
355
+ if ramp_time := kwargs.get("ramp_time"):
356
+ await self._set_ramp_time_on_value(ramp_time=ramp_time, collector=collector)
357
+ if not (brightness := kwargs.get("brightness", self.brightness)):
358
+ brightness = int(_MAX_BRIGHTNESS)
359
+ level = self.brightness_to_level(brightness)
360
+ await self._dp_level.send_value(value=level, collector=collector)
361
+
362
+ def _compute_capabilities(self) -> LightCapabilities:
363
+ """Compute static capabilities based on DataPoint types."""
364
+ return LightCapabilities(
365
+ brightness=isinstance(self._dp_level, DpFloat),
366
+ transition=isinstance(getattr(self, "_dp_ramp_time_value", None), DpAction),
367
+ )
368
+
369
+ @bind_collector
370
+ async def _set_on_time_value(self, *, on_time: float, collector: CallParameterCollector | None = None) -> None:
371
+ """Set the on time value in seconds."""
372
+ await self._dp_on_time_value.send_value(value=on_time, collector=collector, do_validate=False)
373
+
374
+ async def _set_ramp_time_off_value(
375
+ self, *, ramp_time: float, collector: CallParameterCollector | None = None
376
+ ) -> None:
377
+ """Set the ramp time value in seconds."""
378
+ await self._set_ramp_time_on_value(ramp_time=ramp_time, collector=collector)
379
+
380
+ async def _set_ramp_time_on_value(
381
+ self, *, ramp_time: float, collector: CallParameterCollector | None = None
382
+ ) -> None:
383
+ """Set the ramp time value in seconds."""
384
+ await self._dp_ramp_time_value.send_value(value=ramp_time, collector=collector)
385
+
386
+
387
+ class CustomDpColorDimmer(CustomDpDimmer):
388
+ """Class for Homematic dimmer with color data point."""
389
+
390
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
391
+
392
+ # Declarative data point field definitions
393
+ _dp_color: Final = DataPointField(field=Field.COLOR, dpt=DpInteger)
394
+
395
+ @state_property
396
+ def hs_color(self) -> tuple[float, float] | None:
397
+ """Return the hue and saturation color value [float, float]."""
398
+ if (color := self._dp_color.value) is not None:
399
+ if color >= 200:
400
+ # 200 is a special case (white), so we have a saturation of 0.
401
+ # Larger values are undefined.
402
+ # For the sake of robustness we return "white" anyway.
403
+ return _MIN_HUE, _MIN_SATURATION
404
+
405
+ # For all other colors we assume saturation of 1
406
+ return color / 200 * 360, _MAX_SATURATION
407
+ return _MIN_HUE, _MIN_SATURATION
408
+
409
+ @bind_collector
410
+ async def turn_on(self, *, collector: CallParameterCollector | None = None, **kwargs: Unpack[LightOnArgs]) -> None:
411
+ """Turn the light on."""
412
+ if not self.is_state_change(on=True, **kwargs):
413
+ return
414
+ if (hs_color := kwargs.get("hs_color")) is not None:
415
+ khue, ksaturation = hs_color
416
+ hue = khue / 360
417
+ saturation = ksaturation / _SATURATION_MULTIPLIER
418
+ color = 200 if saturation < 0.1 else int(round(max(min(hue, 1), 0) * 199))
419
+ await self._dp_color.send_value(value=color, collector=collector)
420
+ await super().turn_on(collector=collector, **kwargs)
421
+
422
+
423
+ class CustomDpColorDimmerEffect(CustomDpColorDimmer):
424
+ """Class for Homematic dimmer with color data point."""
425
+
426
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
427
+
428
+ _effects: tuple[str, ...] = (
429
+ _EFFECT_OFF,
430
+ "Slow color change",
431
+ "Medium color change",
432
+ "Fast color change",
433
+ "Campemit",
434
+ "Waterfall",
435
+ "TV simulation",
436
+ )
437
+
438
+ # Declarative data point field definitions
439
+ _dp_effect: Final = DataPointField(field=Field.PROGRAM, dpt=DpInteger)
440
+
441
+ effects: Final = DelegatedProperty[tuple[str, ...] | None](path="_effects", kind=Kind.STATE)
442
+
443
+ @state_property
444
+ def effect(self) -> str | None:
445
+ """Return the current effect."""
446
+ if self._dp_effect.value is not None:
447
+ return self._effects[int(self._dp_effect.value)]
448
+ return None
449
+
450
+ @bind_collector
451
+ async def turn_on(self, *, collector: CallParameterCollector | None = None, **kwargs: Unpack[LightOnArgs]) -> None:
452
+ """Turn the light on."""
453
+ if not self.is_state_change(on=True, **kwargs):
454
+ return
455
+
456
+ if "effect" not in kwargs and self.has_effects and self.effect != _EFFECT_OFF:
457
+ await self._dp_effect.send_value(value=0, collector=collector, collector_order=5)
458
+
459
+ if (
460
+ self.has_effects
461
+ and (effect := kwargs.get("effect")) is not None
462
+ and (effect_idx := self._effects.index(effect)) is not None
463
+ ):
464
+ await self._dp_effect.send_value(value=effect_idx, collector=collector, collector_order=95)
465
+
466
+ await super().turn_on(collector=collector, **kwargs)
467
+
468
+
469
+ class CustomDpColorTempDimmer(CustomDpDimmer):
470
+ """Class for Homematic dimmer with color temperature."""
471
+
472
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
473
+
474
+ # Declarative data point field definitions
475
+ _dp_color_level: Final = DataPointField(field=Field.COLOR_LEVEL, dpt=DpFloat)
476
+
477
+ @state_property
478
+ def color_temp_kelvin(self) -> int | None:
479
+ """Return the color temperature in kelvin."""
480
+ return math.floor(
481
+ _MAX_KELVIN / int(_MAX_MIREDS - (_MAX_MIREDS - _MIN_MIREDS) * (self._dp_color_level.value or _DIMMER_OFF))
482
+ )
483
+
484
+ @bind_collector
485
+ async def turn_on(self, *, collector: CallParameterCollector | None = None, **kwargs: Unpack[LightOnArgs]) -> None:
486
+ """Turn the light on."""
487
+ if not self.is_state_change(on=True, **kwargs):
488
+ return
489
+ if (color_temp_kelvin := kwargs.get("color_temp_kelvin")) is not None:
490
+ color_level = (_MAX_MIREDS - math.floor(_MAX_KELVIN / color_temp_kelvin)) / (_MAX_MIREDS - _MIN_MIREDS)
491
+ await self._dp_color_level.send_value(value=color_level, collector=collector)
492
+
493
+ await super().turn_on(collector=collector, **kwargs)
494
+
495
+
496
+ class CustomDpIpRGBWLight(TimerUnitMixin, CustomDpDimmer):
497
+ """Class for HomematicIP HmIP-RGBW light data point."""
498
+
499
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
500
+
501
+ # Declarative data point field definitions
502
+ _dp_direction: Final = DataPointField(field=Field.DIRECTION, dpt=DpSensor[str | None])
503
+ _dp_color_temperature_kelvin: Final = DataPointField(field=Field.COLOR_TEMPERATURE, dpt=DpInteger)
504
+ _dp_device_operation_mode: Final = DataPointField(field=Field.DEVICE_OPERATION_MODE, dpt=DpSelect)
505
+ _dp_effect: Final = DataPointField(field=Field.EFFECT, dpt=DpActionSelect)
506
+
507
+ _dp_hue: Final = DataPointField(field=Field.HUE, dpt=DpInteger)
508
+ _dp_on_time_unit = DataPointField(field=Field.ON_TIME_UNIT, dpt=DpActionSelect)
509
+ _dp_ramp_time_to_off_unit: Final = DataPointField(field=Field.RAMP_TIME_TO_OFF_UNIT, dpt=DpActionSelect)
510
+ _dp_ramp_time_to_off_value: Final = DataPointField(field=Field.RAMP_TIME_TO_OFF_VALUE, dpt=DpAction)
511
+ _dp_ramp_time_unit = DataPointField(field=Field.RAMP_TIME_UNIT, dpt=DpActionSelect)
512
+ _dp_saturation: Final = DataPointField(field=Field.SATURATION, dpt=DpFloat)
513
+
514
+ @property
515
+ def _device_operation_mode(self) -> _DeviceOperationMode:
516
+ """Return the device operation mode."""
517
+ try:
518
+ return _DeviceOperationMode(str(self._dp_device_operation_mode.value))
519
+ except Exception:
520
+ # Fallback to a sensible default if the value is not set or unexpected
521
+ return _DeviceOperationMode.RGBW
522
+
523
+ @property
524
+ def _relevant_data_points(self) -> tuple[GenericDataPointAny, ...]:
525
+ """Returns the list of relevant data points. To be overridden by subclasses."""
526
+ if self._device_operation_mode == _DeviceOperationMode.RGBW:
527
+ return (
528
+ self._dp_hue,
529
+ self._dp_level,
530
+ self._dp_saturation,
531
+ self._dp_color_temperature_kelvin,
532
+ )
533
+ if self._device_operation_mode == _DeviceOperationMode.RGB:
534
+ return self._dp_hue, self._dp_level, self._dp_saturation
535
+ if self._device_operation_mode == _DeviceOperationMode.TUNABLE_WHITE:
536
+ return self._dp_level, self._dp_color_temperature_kelvin
537
+ return (self._dp_level,)
538
+
539
+ @property
540
+ def has_color_temperature(self) -> bool:
541
+ """Return True if light currently has color temperature (mode-dependent)."""
542
+ return self._device_operation_mode == _DeviceOperationMode.TUNABLE_WHITE
543
+
544
+ @property
545
+ def has_effects(self) -> bool:
546
+ """Return True if light currently has effects (mode-dependent)."""
547
+ return (
548
+ self._device_operation_mode != _DeviceOperationMode.PWM
549
+ and self.effects is not None
550
+ and len(self.effects) > 0
551
+ )
552
+
553
+ @property
554
+ def has_hs_color(self) -> bool:
555
+ """Return True if light currently has hs color (mode-dependent)."""
556
+ return self._device_operation_mode in (
557
+ _DeviceOperationMode.RGBW,
558
+ _DeviceOperationMode.RGB,
559
+ )
560
+
561
+ @property
562
+ def usage(self) -> DataPointUsage:
563
+ """
564
+ Return the data_point usage.
565
+
566
+ Avoid creating data points that are not usable in selected device operation mode.
567
+ """
568
+ if (
569
+ self._device_operation_mode in (_DeviceOperationMode.RGB, _DeviceOperationMode.RGBW)
570
+ and self._channel.no in (2, 3, 4)
571
+ ) or (self._device_operation_mode == _DeviceOperationMode.TUNABLE_WHITE and self._channel.no in (3, 4)):
572
+ return DataPointUsage.NO_CREATE
573
+ return self._get_data_point_usage()
574
+
575
+ @state_property
576
+ def color_temp_kelvin(self) -> int | None:
577
+ """Return the color temperature in kelvin."""
578
+ if not self._dp_color_temperature_kelvin.value:
579
+ return None
580
+ return self._dp_color_temperature_kelvin.value
581
+
582
+ @state_property
583
+ def effects(self) -> tuple[str, ...] | None:
584
+ """Return the supported effects."""
585
+ return self._dp_effect.values or ()
586
+
587
+ @state_property
588
+ def hs_color(self) -> tuple[float, float] | None:
589
+ """Return the hue and saturation color value [float, float]."""
590
+ if self._dp_hue.value is not None and self._dp_saturation.value is not None:
591
+ return self._dp_hue.value, self._dp_saturation.value * _SATURATION_MULTIPLIER
592
+ return None
593
+
594
+ @bind_collector
595
+ async def turn_off(
596
+ self, *, collector: CallParameterCollector | None = None, **kwargs: Unpack[LightOffArgs]
597
+ ) -> None:
598
+ """Turn the light off."""
599
+ if kwargs.get("on_time") is None and kwargs.get("ramp_time"):
600
+ await self._set_on_time_value(on_time=_NOT_USED, collector=collector)
601
+ await super().turn_off(collector=collector, **kwargs)
602
+
603
+ @bind_collector
604
+ async def turn_on(self, *, collector: CallParameterCollector | None = None, **kwargs: Unpack[LightOnArgs]) -> None:
605
+ """Turn the light on."""
606
+ if on_time := (kwargs.get("on_time") or self.get_and_start_timer()):
607
+ kwargs["on_time"] = on_time
608
+ if not self.is_state_change(on=True, **kwargs):
609
+ return
610
+ if (hs_color := kwargs.get("hs_color")) is not None:
611
+ hue, ksaturation = hs_color
612
+ saturation = ksaturation / _SATURATION_MULTIPLIER
613
+ await self._dp_hue.send_value(value=int(hue), collector=collector)
614
+ await self._dp_saturation.send_value(value=saturation, collector=collector)
615
+ if color_temp_kelvin := kwargs.get("color_temp_kelvin"):
616
+ await self._dp_color_temperature_kelvin.send_value(value=color_temp_kelvin, collector=collector)
617
+ if on_time is None and kwargs.get("ramp_time"):
618
+ await self._set_on_time_value(on_time=_NOT_USED, collector=collector)
619
+ if self.has_effects and (effect := kwargs.get("effect")) is not None:
620
+ await self._dp_effect.send_value(value=effect, collector=collector)
621
+
622
+ await super().turn_on(collector=collector, **kwargs)
623
+
624
+ @bind_collector
625
+ async def _set_on_time_value(self, *, on_time: float, collector: CallParameterCollector | None = None) -> None:
626
+ """Set the on time value with automatic unit conversion."""
627
+ on_time, on_time_unit = self._recalc_unit_timer(time=on_time)
628
+ await self._dp_on_time_unit.send_value(value=on_time_unit, collector=collector)
629
+ await self._dp_on_time_value.send_value(value=float(on_time), collector=collector)
630
+
631
+ async def _set_ramp_time_off_value(
632
+ self, *, ramp_time: float, collector: CallParameterCollector | None = None
633
+ ) -> None:
634
+ """Set the ramp time off value with automatic unit conversion."""
635
+ ramp_time, ramp_time_unit = self._recalc_unit_timer(time=ramp_time)
636
+ await self._dp_ramp_time_unit.send_value(value=ramp_time_unit, collector=collector)
637
+ await self._dp_ramp_time_value.send_value(value=float(ramp_time), collector=collector)
638
+
639
+ async def _set_ramp_time_on_value(
640
+ self, *, ramp_time: float, collector: CallParameterCollector | None = None
641
+ ) -> None:
642
+ """Set the ramp time on value with automatic unit conversion."""
643
+ ramp_time, ramp_time_unit = self._recalc_unit_timer(time=ramp_time)
644
+ await self._dp_ramp_time_unit.send_value(value=ramp_time_unit, collector=collector)
645
+ await self._dp_ramp_time_value.send_value(value=float(ramp_time), collector=collector)
646
+
647
+
648
+ class CustomDpIpDrgDaliLight(TimerUnitMixin, CustomDpDimmer):
649
+ """Class for HomematicIP HmIP-DRG-DALI light data point."""
650
+
651
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
652
+
653
+ # Declarative data point field definitions
654
+ _dp_color_temperature_kelvin: Final = DataPointField(field=Field.COLOR_TEMPERATURE, dpt=DpInteger)
655
+ _dp_effect: Final = DataPointField(field=Field.EFFECT, dpt=DpActionSelect)
656
+ _dp_hue: Final = DataPointField(field=Field.HUE, dpt=DpInteger)
657
+ _dp_on_time_unit = DataPointField(field=Field.ON_TIME_UNIT, dpt=DpActionSelect)
658
+ _dp_ramp_time_to_off_unit: Final = DataPointField(field=Field.RAMP_TIME_TO_OFF_UNIT, dpt=DpActionSelect)
659
+ _dp_ramp_time_to_off_value: Final = DataPointField(field=Field.RAMP_TIME_TO_OFF_VALUE, dpt=DpAction)
660
+ _dp_ramp_time_unit = DataPointField(field=Field.RAMP_TIME_UNIT, dpt=DpActionSelect)
661
+ _dp_saturation: Final = DataPointField(field=Field.SATURATION, dpt=DpFloat)
662
+
663
+ @property
664
+ def _relevant_data_points(self) -> tuple[GenericDataPointAny, ...]:
665
+ """Returns the list of relevant data points. To be overridden by subclasses."""
666
+ return (self._dp_level,)
667
+
668
+ @state_property
669
+ def color_temp_kelvin(self) -> int | None:
670
+ """Return the color temperature in kelvin."""
671
+ if not self._dp_color_temperature_kelvin.value:
672
+ return None
673
+ return self._dp_color_temperature_kelvin.value
674
+
675
+ @state_property
676
+ def effects(self) -> tuple[str, ...] | None:
677
+ """Return the supported effects."""
678
+ return self._dp_effect.values or ()
679
+
680
+ @state_property
681
+ def hs_color(self) -> tuple[float, float] | None:
682
+ """Return the hue and saturation color value [float, float]."""
683
+ if self._dp_hue.value is not None and self._dp_saturation.value is not None:
684
+ return self._dp_hue.value, self._dp_saturation.value * _SATURATION_MULTIPLIER
685
+ return None
686
+
687
+ @bind_collector
688
+ async def turn_on(self, *, collector: CallParameterCollector | None = None, **kwargs: Unpack[LightOnArgs]) -> None:
689
+ """Turn the light on."""
690
+ if not self.is_state_change(on=True, **kwargs):
691
+ return
692
+ if (hs_color := kwargs.get("hs_color")) is not None:
693
+ hue, ksaturation = hs_color
694
+ saturation = ksaturation / _SATURATION_MULTIPLIER
695
+ await self._dp_hue.send_value(value=int(hue), collector=collector)
696
+ await self._dp_saturation.send_value(value=saturation, collector=collector)
697
+ if color_temp_kelvin := kwargs.get("color_temp_kelvin"):
698
+ await self._dp_color_temperature_kelvin.send_value(value=color_temp_kelvin, collector=collector)
699
+ if kwargs.get("on_time") is None and kwargs.get("ramp_time"):
700
+ await self._set_on_time_value(on_time=_NOT_USED, collector=collector)
701
+ if self.has_effects and (effect := kwargs.get("effect")) is not None:
702
+ await self._dp_effect.send_value(value=effect, collector=collector)
703
+
704
+ await super().turn_on(collector=collector, **kwargs)
705
+
706
+
707
+ class CustomDpIpFixedColorLight(TimerUnitMixin, CustomDpDimmer):
708
+ """Class for HomematicIP HmIP-BSL light data point."""
709
+
710
+ __slots__ = ("_effect_list",) # Keep instance variable, descriptors are class-level
711
+
712
+ # Declarative data point field definitions
713
+ _dp_channel_color: Final = DataPointField(field=Field.CHANNEL_COLOR, dpt=DpSensor[str | None])
714
+ _dp_color: Final = DataPointField(field=Field.COLOR, dpt=DpSelect)
715
+ _dp_effect: Final = DataPointField(field=Field.COLOR_BEHAVIOUR, dpt=DpSelect)
716
+ _dp_on_time_unit = DataPointField(field=Field.ON_TIME_UNIT, dpt=DpActionSelect)
717
+ _dp_ramp_time_unit = DataPointField(field=Field.RAMP_TIME_UNIT, dpt=DpActionSelect)
718
+
719
+ _effect_list: tuple[str, ...]
720
+
721
+ channel_color_name: Final = DelegatedProperty[str | None](path="_dp_channel_color.value")
722
+ effects: Final = DelegatedProperty[tuple[str, ...] | None](path="_effect_list", kind=Kind.STATE)
723
+
724
+ @property
725
+ def channel_hs_color(self) -> tuple[float, float] | None:
726
+ """Return the channel hue and saturation color value [float, float]."""
727
+ if self._dp_channel_color.value is not None:
728
+ return FIXED_COLOR_TO_HS_CONVERTER.get(self._dp_channel_color.value, (_MIN_HUE, _MIN_SATURATION))
729
+ return None
730
+
731
+ @state_property
732
+ def color_name(self) -> str | None:
733
+ """Return the name of the color."""
734
+ val = self._dp_color.value
735
+ return val if isinstance(val, str) else None
736
+
737
+ @state_property
738
+ def effect(self) -> str | None:
739
+ """Return the current effect."""
740
+ if (effect := self._dp_effect.value) is not None and effect in self._effect_list:
741
+ return effect if isinstance(effect, str) else None
742
+ return None
743
+
744
+ @state_property
745
+ def hs_color(self) -> tuple[float, float] | None:
746
+ """Return the hue and saturation color value [float, float]."""
747
+ if (
748
+ self._dp_color.value is not None
749
+ and isinstance(self._dp_color.value, str)
750
+ and (hs_color := FIXED_COLOR_TO_HS_CONVERTER.get(self._dp_color.value)) is not None
751
+ ):
752
+ return hs_color
753
+ return _MIN_HUE, _MIN_SATURATION
754
+
755
+ @bind_collector
756
+ async def turn_on(self, *, collector: CallParameterCollector | None = None, **kwargs: Unpack[LightOnArgs]) -> None:
757
+ """Turn the light on."""
758
+ if not self.is_state_change(on=True, **kwargs):
759
+ return
760
+ if (hs_color := kwargs.get("hs_color")) is not None:
761
+ simple_rgb_color = hs_color_to_fixed_converter(color=hs_color)
762
+ await self._dp_color.send_value(value=simple_rgb_color, collector=collector)
763
+ elif self.color_name in _NO_COLOR:
764
+ await self._dp_color.send_value(value=FixedColor.WHITE, collector=collector)
765
+ if (effect := kwargs.get("effect")) is not None and effect in self._effect_list:
766
+ await self._dp_effect.send_value(value=effect, collector=collector)
767
+ elif self._dp_effect.value not in self._effect_list:
768
+ await self._dp_effect.send_value(value=_ColorBehaviour.ON, collector=collector)
769
+ elif (color_behaviour := self._dp_effect.value) is not None:
770
+ await self._dp_effect.send_value(value=color_behaviour, collector=collector)
771
+
772
+ await super().turn_on(collector=collector, **kwargs)
773
+
774
+ def _post_init(self) -> None:
775
+ """Post action after initialisation of the data point fields."""
776
+ super()._post_init()
777
+
778
+ self._effect_list = (
779
+ tuple(str(item) for item in self._dp_effect.values if item not in _EXCLUDE_FROM_COLOR_BEHAVIOUR)
780
+ if (self._dp_effect and self._dp_effect.values)
781
+ else ()
782
+ )
783
+
784
+
785
+ def hs_color_to_fixed_converter(*, color: tuple[float, float]) -> str:
786
+ """
787
+ Convert the given color to the reduced color of the device.
788
+
789
+ Device contains only 8 colors including white and black,
790
+ so a conversion is required.
791
+ """
792
+ hue: int = int(color[0])
793
+ if int(color[1]) < 5:
794
+ return FixedColor.WHITE
795
+ if 30 < hue <= 90:
796
+ return FixedColor.YELLOW
797
+ if 90 < hue <= 150:
798
+ return FixedColor.GREEN
799
+ if 150 < hue <= 210:
800
+ return FixedColor.TURQUOISE
801
+ if 210 < hue <= 270:
802
+ return FixedColor.BLUE
803
+ if 270 < hue <= 330:
804
+ return FixedColor.PURPLE
805
+ return FixedColor.RED
806
+
807
+
808
+ class CustomDpSoundPlayerLed(TimerUnitMixin, CustomDpDimmer):
809
+ """Class for HomematicIP sound player LED data point (HmIP-MP3P channel 6)."""
810
+
811
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
812
+
813
+ # Additional declarative data point field definitions for LED channel
814
+ # Note: _dp_level and _dp_ramp_time_value are inherited from CustomDpDimmer
815
+ # Map on_time to DURATION_VALUE/UNIT for TimerUnitMixin compatibility (override parent)
816
+ _dp_on_time_value = DataPointField(field=Field.DURATION_VALUE, dpt=DpAction)
817
+ _dp_on_time_unit = DataPointField(field=Field.DURATION_UNIT, dpt=DpActionSelect)
818
+ _dp_ramp_time_unit = DataPointField(field=Field.RAMP_TIME_UNIT, dpt=DpActionSelect)
819
+ _dp_color: Final = DataPointField(field=Field.COLOR, dpt=DpSelect)
820
+ _dp_on_time_list: Final = DataPointField(field=Field.ON_TIME_LIST, dpt=DpActionSelect)
821
+ _dp_repetitions: Final = DataPointField(field=Field.REPETITIONS, dpt=DpActionSelect)
822
+ _dp_direction: Final = DataPointField(field=Field.DIRECTION, dpt=DpSensor[str | None])
823
+
824
+ # Expose available options via DelegatedProperty (from VALUE_LISTs)
825
+ available_colors: Final = DelegatedProperty[tuple[str, ...] | None](path="_dp_color.values", kind=Kind.STATE)
826
+
827
+ @state_property
828
+ def color_name(self) -> str | None:
829
+ """Return the name of the color."""
830
+ val = self._dp_color.value
831
+ return val if isinstance(val, str) else None
832
+
833
+ @state_property
834
+ def hs_color(self) -> tuple[float, float] | None:
835
+ """Return the hue and saturation color value [float, float]."""
836
+ if (
837
+ self._dp_color.value is not None
838
+ and isinstance(self._dp_color.value, str)
839
+ and (hs_color := FIXED_COLOR_TO_HS_CONVERTER.get(self._dp_color.value)) is not None
840
+ ):
841
+ return hs_color
842
+ return _MIN_HUE, _MIN_SATURATION
843
+
844
+ @bind_collector
845
+ async def turn_off(
846
+ self,
847
+ *,
848
+ collector: CallParameterCollector | None = None,
849
+ **kwargs: Unpack[LightOffArgs],
850
+ ) -> None:
851
+ """Turn off the LED."""
852
+ self.reset_timer_on_time()
853
+ await self._dp_level.send_value(value=0.0, collector=collector)
854
+ await self._dp_color.send_value(value=FixedColor.BLACK, collector=collector)
855
+ await self._dp_on_time_value.send_value(value=0, collector=collector)
856
+
857
+ @bind_collector
858
+ async def turn_on(
859
+ self,
860
+ *,
861
+ collector: CallParameterCollector | None = None,
862
+ **kwargs: Unpack[SoundPlayerLedOnArgs],
863
+ ) -> None:
864
+ """
865
+ Turn on the LED with optional color and brightness settings.
866
+
867
+ API is comparable to CustomDpIpFixedColorLight.
868
+
869
+ Args:
870
+ collector: Optional call parameter collector.
871
+ **kwargs: LED parameters from SoundPlayerLedOnArgs (extends LightOnArgs):
872
+ brightness: Brightness 0-255 (converted to 0.0-1.0 for device).
873
+ hs_color: Hue/saturation tuple for color selection.
874
+ on_time: Duration in seconds (auto-converted to value+unit via TimerUnitMixin).
875
+ ramp_time: Ramp time in seconds (auto-converted to value+unit via TimerUnitMixin).
876
+ repetitions: 0=none, 1-18=count, -1=infinite (converted to VALUE_LIST).
877
+ flash_time: Flash duration in ms (converted to nearest ON_TIME_LIST value).
878
+
879
+ """
880
+ # Handle timer like CustomDpDimmer: store if passed, then retrieve via get_and_start_timer
881
+ if (on_time_arg := kwargs.get("on_time")) is not None:
882
+ self.set_timer_on_time(on_time=on_time_arg)
883
+
884
+ # Convert brightness from 0-255 to 0.0-1.0
885
+ brightness_int = kwargs.get("brightness")
886
+ brightness = self.brightness_to_level(brightness_int) if brightness_int is not None else 1.0
887
+
888
+ # Use pre-set timer (from set_timer_on_time) or fall back to kwargs/default
889
+ on_time = self.get_and_start_timer() or kwargs.get("on_time", 0.0)
890
+ ramp_time = kwargs.get("ramp_time", 0.0)
891
+ repetitions_value = _convert_repetitions(repetitions=kwargs.get("repetitions"))
892
+ flash_time_value = _convert_flash_time_to_on_time_list(flash_time_ms=kwargs.get("flash_time"))
893
+
894
+ # Handle color: convert hs_color or default to WHITE (like CustomDpIpFixedColorLight)
895
+ if (hs_color := kwargs.get("hs_color")) is not None:
896
+ color = hs_color_to_fixed_converter(color=hs_color)
897
+ elif self.color_name in _NO_COLOR:
898
+ color = FixedColor.WHITE
899
+ else:
900
+ color = self.color_name or FixedColor.WHITE
901
+
902
+ # Send parameters - order matters for batching
903
+ await self._dp_level.send_value(value=brightness, collector=collector)
904
+ await self._dp_color.send_value(value=color, collector=collector)
905
+ await self._dp_on_time_list.send_value(value=flash_time_value, collector=collector)
906
+ await self._dp_repetitions.send_value(value=repetitions_value, collector=collector)
907
+ # Use mixin methods for automatic unit conversion
908
+ await self._set_ramp_time_on_value(ramp_time=ramp_time, collector=collector)
909
+ await self._set_on_time_value(on_time=on_time, collector=collector)
910
+
911
+
912
+ # =============================================================================
913
+ # DeviceProfileRegistry Registration
914
+ # =============================================================================
915
+
916
+ # RF Dimmer (simple)
917
+ DeviceProfileRegistry.register(
918
+ category=DataPointCategory.LIGHT,
919
+ models=("263 132", "263 134", "HSS-DX"),
920
+ data_point_class=CustomDpDimmer,
921
+ profile_type=DeviceProfile.RF_DIMMER,
922
+ )
923
+
924
+ # RF Dimmer with virtual channels
925
+ DeviceProfileRegistry.register(
926
+ category=DataPointCategory.LIGHT,
927
+ models=(
928
+ "263 133",
929
+ "HM-LC-AO-SM",
930
+ "HM-LC-Dim1L-CV",
931
+ "HM-LC-Dim1L-CV-2",
932
+ "HM-LC-Dim1L-Pl",
933
+ "HM-LC-Dim1L-Pl-3",
934
+ "HM-LC-Dim1PWM-CV",
935
+ "HM-LC-Dim1PWM-CV-2",
936
+ "HM-LC-Dim1T-CV",
937
+ "HM-LC-Dim1T-CV-2",
938
+ "HM-LC-Dim1T-FM",
939
+ "HM-LC-Dim1T-FM-2",
940
+ "HM-LC-Dim1T-Pl",
941
+ "HM-LC-Dim1T-Pl-3",
942
+ "HM-LC-Dim1TPBU-FM",
943
+ "HM-LC-Dim1TPBU-FM-2",
944
+ ),
945
+ data_point_class=CustomDpDimmer,
946
+ profile_type=DeviceProfile.RF_DIMMER_WITH_VIRT_CHANNEL,
947
+ )
948
+
949
+ # RF Dimmer (specific channels)
950
+ DeviceProfileRegistry.register(
951
+ category=DataPointCategory.LIGHT,
952
+ models="HM-DW-WM",
953
+ data_point_class=CustomDpDimmer,
954
+ profile_type=DeviceProfile.RF_DIMMER,
955
+ channels=(1, 2, 3, 4),
956
+ )
957
+ DeviceProfileRegistry.register(
958
+ category=DataPointCategory.LIGHT,
959
+ models="HM-LC-Dim1T-DR",
960
+ data_point_class=CustomDpDimmer,
961
+ profile_type=DeviceProfile.RF_DIMMER,
962
+ channels=(1, 2, 3),
963
+ )
964
+ DeviceProfileRegistry.register(
965
+ category=DataPointCategory.LIGHT,
966
+ models="HM-LC-Dim1T-FM-LF",
967
+ data_point_class=CustomDpDimmer,
968
+ profile_type=DeviceProfile.RF_DIMMER,
969
+ )
970
+ DeviceProfileRegistry.register(
971
+ category=DataPointCategory.LIGHT,
972
+ models=("HM-LC-Dim1L-Pl-2", "HM-LC-Dim1T-Pl-2"),
973
+ data_point_class=CustomDpDimmer,
974
+ profile_type=DeviceProfile.RF_DIMMER,
975
+ )
976
+ DeviceProfileRegistry.register(
977
+ category=DataPointCategory.LIGHT,
978
+ models=("HM-LC-Dim2L-CV", "HM-LC-Dim2L-SM", "HM-LC-Dim2T-SM"),
979
+ data_point_class=CustomDpDimmer,
980
+ profile_type=DeviceProfile.RF_DIMMER,
981
+ channels=(1, 2),
982
+ )
983
+ DeviceProfileRegistry.register(
984
+ category=DataPointCategory.LIGHT,
985
+ models=("HM-LC-Dim2L-SM-2", "HM-LC-Dim2T-SM-2"),
986
+ data_point_class=CustomDpDimmer,
987
+ profile_type=DeviceProfile.RF_DIMMER,
988
+ channels=(1, 2, 3, 4, 5, 6),
989
+ )
990
+ DeviceProfileRegistry.register(
991
+ category=DataPointCategory.LIGHT,
992
+ models="HMW-LC-Dim1L-DR",
993
+ data_point_class=CustomDpDimmer,
994
+ profile_type=DeviceProfile.RF_DIMMER,
995
+ channels=(3,),
996
+ )
997
+ DeviceProfileRegistry.register(
998
+ category=DataPointCategory.LIGHT,
999
+ models="OLIGO.smart.iq.HM",
1000
+ data_point_class=CustomDpDimmer,
1001
+ profile_type=DeviceProfile.RF_DIMMER,
1002
+ channels=(1, 2, 3, 4, 5, 6),
1003
+ )
1004
+
1005
+ # RF Dimmer with color temperature
1006
+ DeviceProfileRegistry.register(
1007
+ category=DataPointCategory.LIGHT,
1008
+ models="HM-LC-DW-WM",
1009
+ data_point_class=CustomDpColorTempDimmer,
1010
+ profile_type=DeviceProfile.RF_DIMMER_COLOR_TEMP,
1011
+ channels=(1, 3, 5),
1012
+ )
1013
+
1014
+ # RF Dimmer with color and effect
1015
+ DeviceProfileRegistry.register(
1016
+ category=DataPointCategory.LIGHT,
1017
+ models="HM-LC-RGBW-WM",
1018
+ data_point_class=CustomDpColorDimmerEffect,
1019
+ profile_type=DeviceProfile.RF_DIMMER_COLOR,
1020
+ )
1021
+
1022
+ # IP Dimmer
1023
+ DeviceProfileRegistry.register(
1024
+ category=DataPointCategory.LIGHT,
1025
+ models="HmIP-BDT",
1026
+ data_point_class=CustomDpDimmer,
1027
+ profile_type=DeviceProfile.IP_DIMMER,
1028
+ channels=(4,),
1029
+ )
1030
+ DeviceProfileRegistry.register(
1031
+ category=DataPointCategory.LIGHT,
1032
+ models="HmIP-DRDI3",
1033
+ data_point_class=CustomDpDimmer,
1034
+ profile_type=DeviceProfile.IP_DIMMER,
1035
+ channels=(5, 9, 13),
1036
+ )
1037
+ DeviceProfileRegistry.register(
1038
+ category=DataPointCategory.LIGHT,
1039
+ models="HmIP-FDT",
1040
+ data_point_class=CustomDpDimmer,
1041
+ profile_type=DeviceProfile.IP_DIMMER,
1042
+ channels=(2,),
1043
+ )
1044
+ DeviceProfileRegistry.register(
1045
+ category=DataPointCategory.LIGHT,
1046
+ models="HmIP-PDT",
1047
+ data_point_class=CustomDpDimmer,
1048
+ profile_type=DeviceProfile.IP_DIMMER,
1049
+ channels=(3,),
1050
+ )
1051
+ DeviceProfileRegistry.register(
1052
+ category=DataPointCategory.LIGHT,
1053
+ models="HmIP-WGT",
1054
+ data_point_class=CustomDpDimmer,
1055
+ profile_type=DeviceProfile.IP_DIMMER,
1056
+ channels=(2,),
1057
+ )
1058
+ DeviceProfileRegistry.register(
1059
+ category=DataPointCategory.LIGHT,
1060
+ models="HmIPW-DRD3",
1061
+ data_point_class=CustomDpDimmer,
1062
+ profile_type=DeviceProfile.IP_DIMMER,
1063
+ channels=(2, 6, 10),
1064
+ )
1065
+
1066
+ # IP Fixed Color Light
1067
+ DeviceProfileRegistry.register(
1068
+ category=DataPointCategory.LIGHT,
1069
+ models="HmIP-BSL",
1070
+ data_point_class=CustomDpIpFixedColorLight,
1071
+ profile_type=DeviceProfile.IP_FIXED_COLOR_LIGHT,
1072
+ channels=(8, 12),
1073
+ )
1074
+
1075
+ # IP Simple Fixed Color Light (Wired)
1076
+ DeviceProfileRegistry.register(
1077
+ category=DataPointCategory.LIGHT,
1078
+ models="HmIPW-WRC6",
1079
+ data_point_class=CustomDpIpFixedColorLight,
1080
+ profile_type=DeviceProfile.IP_SIMPLE_FIXED_COLOR_LIGHT_WIRED,
1081
+ channels=(7, 8, 9, 10, 11, 12, 13),
1082
+ )
1083
+
1084
+ # IP RGBW Light
1085
+ DeviceProfileRegistry.register(
1086
+ category=DataPointCategory.LIGHT,
1087
+ models=("HmIP-RGBW", "HmIP-LSC"),
1088
+ data_point_class=CustomDpIpRGBWLight,
1089
+ profile_type=DeviceProfile.IP_RGBW_LIGHT,
1090
+ )
1091
+
1092
+ # IP DRG DALI Light
1093
+ DeviceProfileRegistry.register(
1094
+ category=DataPointCategory.LIGHT,
1095
+ models="HmIP-DRG-DALI",
1096
+ data_point_class=CustomDpIpDrgDaliLight,
1097
+ profile_type=DeviceProfile.IP_DRG_DALI,
1098
+ channels=tuple(range(1, 49)),
1099
+ )
1100
+
1101
+ # HmIP-SCTH230 (Dimmer with additional sensors)
1102
+ DeviceProfileRegistry.register(
1103
+ category=DataPointCategory.LIGHT,
1104
+ models="HmIP-SCTH230",
1105
+ data_point_class=CustomDpDimmer,
1106
+ profile_type=DeviceProfile.IP_DIMMER,
1107
+ channels=(12,),
1108
+ extended=ExtendedDeviceConfig(
1109
+ additional_data_points={
1110
+ 1: (Parameter.CONCENTRATION,),
1111
+ 4: (Parameter.HUMIDITY, Parameter.ACTUAL_TEMPERATURE),
1112
+ }
1113
+ ),
1114
+ )
1115
+
1116
+ # HBW-LC4-IN4-DR (Dimmer with additional inputs)
1117
+ DeviceProfileRegistry.register(
1118
+ category=DataPointCategory.LIGHT,
1119
+ models="HBW-LC4-IN4-DR",
1120
+ data_point_class=CustomDpDimmer,
1121
+ profile_type=DeviceProfile.RF_DIMMER,
1122
+ channels=(5, 6, 7, 8),
1123
+ extended=ExtendedDeviceConfig(
1124
+ additional_data_points={
1125
+ 1: (Parameter.PRESS_LONG, Parameter.PRESS_SHORT, Parameter.SENSOR),
1126
+ 2: (Parameter.PRESS_LONG, Parameter.PRESS_SHORT, Parameter.SENSOR),
1127
+ 3: (Parameter.PRESS_LONG, Parameter.PRESS_SHORT, Parameter.SENSOR),
1128
+ 4: (Parameter.PRESS_LONG, Parameter.PRESS_SHORT, Parameter.SENSOR),
1129
+ }
1130
+ ),
1131
+ )
1132
+
1133
+ # HBW-LC-RGBWW-IN6-DR (Complex device with multiple configs)
1134
+ DeviceProfileRegistry.register_multiple(
1135
+ category=DataPointCategory.LIGHT,
1136
+ models="HBW-LC-RGBWW-IN6-DR",
1137
+ configs=(
1138
+ DeviceConfig(
1139
+ data_point_class=CustomDpDimmer,
1140
+ profile_type=DeviceProfile.RF_DIMMER,
1141
+ channels=(7, 8, 9, 10, 11, 12),
1142
+ extended=ExtendedDeviceConfig(
1143
+ additional_data_points={
1144
+ (1, 2, 3, 4, 5, 6): (
1145
+ Parameter.PRESS_LONG,
1146
+ Parameter.PRESS_SHORT,
1147
+ Parameter.SENSOR,
1148
+ )
1149
+ },
1150
+ ),
1151
+ ),
1152
+ DeviceConfig(
1153
+ data_point_class=CustomDpColorDimmer,
1154
+ profile_type=DeviceProfile.RF_DIMMER_COLOR_FIXED,
1155
+ channels=(13,),
1156
+ extended=ExtendedDeviceConfig(fixed_channel_fields={15: {Field.COLOR: Parameter.COLOR}}),
1157
+ ),
1158
+ DeviceConfig(
1159
+ data_point_class=CustomDpColorDimmer,
1160
+ profile_type=DeviceProfile.RF_DIMMER_COLOR_FIXED,
1161
+ channels=(14,),
1162
+ extended=ExtendedDeviceConfig(fixed_channel_fields={16: {Field.COLOR: Parameter.COLOR}}),
1163
+ ),
1164
+ ),
1165
+ )
1166
+
1167
+ # HmIP-MP3P LED Control (channel 6)
1168
+ DeviceProfileRegistry.register(
1169
+ category=DataPointCategory.LIGHT,
1170
+ models="HmIP-MP3P",
1171
+ data_point_class=CustomDpSoundPlayerLed,
1172
+ profile_type=DeviceProfile.IP_SOUND_PLAYER_LED,
1173
+ channels=(6,),
1174
+ )