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,462 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Custom siren data points for alarm and notification devices.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import abstractmethod
12
+ import contextlib
13
+ from enum import StrEnum
14
+ from typing import Final, TypedDict, Unpack
15
+
16
+ from aiohomematic import i18n
17
+ from aiohomematic.const import DataPointCategory, DeviceProfile, Field
18
+ from aiohomematic.exceptions import ValidationException
19
+ from aiohomematic.model.custom.capabilities.siren import SMOKE_SENSOR_SIREN_CAPABILITIES, SirenCapabilities
20
+ from aiohomematic.model.custom.data_point import CustomDataPoint
21
+ from aiohomematic.model.custom.field import DataPointField
22
+ from aiohomematic.model.custom.mixins import TimerUnitMixin
23
+ from aiohomematic.model.custom.registry import DeviceProfileRegistry
24
+ from aiohomematic.model.data_point import CallParameterCollector, bind_collector
25
+ from aiohomematic.model.generic import DpAction, DpActionSelect, DpBinarySensor, DpSelect, DpSensor
26
+ from aiohomematic.property_decorators import DelegatedProperty, Kind, state_property
27
+
28
+ _SMOKE_DETECTOR_ALARM_STATUS_IDLE_OFF: Final = "IDLE_OFF"
29
+
30
+ # Activity states indicating playback is active
31
+ _ACTIVITY_STATES_ACTIVE: Final[frozenset[str]] = frozenset({"UP", "DOWN"})
32
+
33
+ # Repetitions constants
34
+ _NO_REPETITION: Final = "NO_REPETITION"
35
+ _INFINITE_REPETITIONS: Final = "INFINITE_REPETITIONS"
36
+ _MAX_REPETITIONS: Final = 18
37
+
38
+
39
+ def _convert_repetitions(*, repetitions: int | None) -> str:
40
+ """
41
+ Convert repetitions count to REPETITIONS VALUE_LIST value.
42
+
43
+ Args:
44
+ repetitions: Number of repetitions (0=none, 1-18=count, -1=infinite, None=none).
45
+
46
+ Returns:
47
+ VALUE_LIST string (NO_REPETITION, REPETITIONS_001-018, or INFINITE_REPETITIONS).
48
+
49
+ Raises:
50
+ ValueError: If repetitions is outside valid range (-1 to 18).
51
+
52
+ """
53
+ if repetitions is None or repetitions == 0:
54
+ return _NO_REPETITION
55
+
56
+ if repetitions == -1:
57
+ return _INFINITE_REPETITIONS
58
+
59
+ if repetitions < -1 or repetitions > _MAX_REPETITIONS:
60
+ msg = f"Repetitions must be -1 (infinite), 0 (none), or 1-{_MAX_REPETITIONS}, got {repetitions}"
61
+ raise ValueError(msg)
62
+
63
+ return f"REPETITIONS_{repetitions:03d}"
64
+
65
+
66
+ class _SirenCommand(StrEnum):
67
+ """Enum with siren commands."""
68
+
69
+ OFF = "INTRUSION_ALARM_OFF"
70
+ ON = "INTRUSION_ALARM"
71
+
72
+
73
+ class SirenOnArgs(TypedDict, total=False):
74
+ """Matcher for the siren arguments."""
75
+
76
+ acoustic_alarm: str
77
+ optical_alarm: str
78
+ duration: str
79
+
80
+
81
+ class PlaySoundArgs(TypedDict, total=False):
82
+ """Arguments for play_sound method (comparable to SirenOnArgs)."""
83
+
84
+ soundfile: str | int # Soundfile from available_soundfiles or index (1-189)
85
+ volume: float # Volume level 0.0-1.0 (default: 0.5)
86
+ on_time: float # Duration in seconds (auto unit conversion via TimerUnitMixin)
87
+ ramp_time: float # Ramp time in seconds (auto unit conversion via TimerUnitMixin)
88
+ repetitions: int # 0=none, 1-18=count, -1=infinite (converted to VALUE_LIST entry)
89
+
90
+
91
+ class BaseCustomDpSiren(CustomDataPoint):
92
+ """Class for Homematic siren data point."""
93
+
94
+ __slots__ = ("_capabilities",)
95
+
96
+ _category = DataPointCategory.SIREN
97
+
98
+ @property
99
+ def capabilities(self) -> SirenCapabilities:
100
+ """Return the siren capabilities."""
101
+ if (caps := getattr(self, "_capabilities", None)) is None:
102
+ caps = self._compute_capabilities()
103
+ object.__setattr__(self, "_capabilities", caps)
104
+ return caps
105
+
106
+ @state_property
107
+ @abstractmethod
108
+ def available_lights(self) -> tuple[str, ...] | None:
109
+ """Return available lights."""
110
+
111
+ @state_property
112
+ @abstractmethod
113
+ def available_tones(self) -> tuple[str, ...] | None:
114
+ """Return available tones."""
115
+
116
+ @state_property
117
+ @abstractmethod
118
+ def is_on(self) -> bool:
119
+ """Return true if siren is on."""
120
+
121
+ @abstractmethod
122
+ @bind_collector
123
+ async def turn_off(self, *, collector: CallParameterCollector | None = None) -> None:
124
+ """Turn the device off."""
125
+
126
+ @abstractmethod
127
+ @bind_collector
128
+ async def turn_on(
129
+ self,
130
+ *,
131
+ collector: CallParameterCollector | None = None,
132
+ **kwargs: Unpack[SirenOnArgs],
133
+ ) -> None:
134
+ """Turn the device on."""
135
+
136
+ @abstractmethod
137
+ def _compute_capabilities(self) -> SirenCapabilities:
138
+ """Compute static capabilities. Implemented by subclasses."""
139
+
140
+
141
+ class CustomDpIpSiren(BaseCustomDpSiren):
142
+ """Class for HomematicIP siren data point."""
143
+
144
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
145
+
146
+ # Declarative data point field definitions
147
+ _dp_acoustic_alarm_active: Final = DataPointField(field=Field.ACOUSTIC_ALARM_ACTIVE, dpt=DpBinarySensor)
148
+ _dp_acoustic_alarm_selection: Final = DataPointField(field=Field.ACOUSTIC_ALARM_SELECTION, dpt=DpActionSelect)
149
+ _dp_duration: Final = DataPointField(field=Field.DURATION, dpt=DpAction)
150
+ _dp_duration_unit: Final = DataPointField(field=Field.DURATION_UNIT, dpt=DpActionSelect)
151
+ _dp_optical_alarm_active: Final = DataPointField(field=Field.OPTICAL_ALARM_ACTIVE, dpt=DpBinarySensor)
152
+ _dp_optical_alarm_selection: Final = DataPointField(field=Field.OPTICAL_ALARM_SELECTION, dpt=DpActionSelect)
153
+
154
+ available_lights: Final = DelegatedProperty[tuple[str, ...] | None](
155
+ path="_dp_optical_alarm_selection.values", kind=Kind.STATE
156
+ )
157
+ available_tones: Final = DelegatedProperty[tuple[str, ...] | None](
158
+ path="_dp_acoustic_alarm_selection.values", kind=Kind.STATE
159
+ )
160
+
161
+ @state_property
162
+ def is_on(self) -> bool:
163
+ """Return true if siren is on."""
164
+ return self._dp_acoustic_alarm_active.value is True or self._dp_optical_alarm_active.value is True
165
+
166
+ @bind_collector
167
+ async def turn_off(self, *, collector: CallParameterCollector | None = None) -> None:
168
+ """Turn the device off."""
169
+ if (acoustic_default := self._dp_acoustic_alarm_selection.default) is not None:
170
+ await self._dp_acoustic_alarm_selection.send_value(value=acoustic_default, collector=collector)
171
+ if (optical_default := self._dp_optical_alarm_selection.default) is not None:
172
+ await self._dp_optical_alarm_selection.send_value(value=optical_default, collector=collector)
173
+ if (duration_unit_default := self._dp_duration_unit.default) is not None:
174
+ await self._dp_duration_unit.send_value(value=duration_unit_default, collector=collector)
175
+ await self._dp_duration.send_value(value=self._dp_duration.default, collector=collector)
176
+
177
+ @bind_collector
178
+ async def turn_on(
179
+ self,
180
+ *,
181
+ collector: CallParameterCollector | None = None,
182
+ **kwargs: Unpack[SirenOnArgs],
183
+ ) -> None:
184
+ """Turn the device on."""
185
+ acoustic_alarm = (
186
+ kwargs.get("acoustic_alarm")
187
+ or self._dp_acoustic_alarm_selection.value
188
+ or self._dp_acoustic_alarm_selection.default
189
+ )
190
+ if self.available_tones and acoustic_alarm and acoustic_alarm not in self.available_tones:
191
+ raise ValidationException(
192
+ i18n.tr(
193
+ key="exception.model.custom.siren.invalid_tone",
194
+ full_name=self.full_name,
195
+ value=acoustic_alarm,
196
+ )
197
+ )
198
+
199
+ optical_alarm = (
200
+ kwargs.get("optical_alarm")
201
+ or self._dp_optical_alarm_selection.value
202
+ or self._dp_optical_alarm_selection.default
203
+ )
204
+ if self.available_lights and optical_alarm and optical_alarm not in self.available_lights:
205
+ raise ValidationException(
206
+ i18n.tr(
207
+ key="exception.model.custom.siren.invalid_light",
208
+ full_name=self.full_name,
209
+ value=optical_alarm,
210
+ )
211
+ )
212
+
213
+ if acoustic_alarm is not None:
214
+ await self._dp_acoustic_alarm_selection.send_value(value=acoustic_alarm, collector=collector)
215
+ if optical_alarm is not None:
216
+ await self._dp_optical_alarm_selection.send_value(value=optical_alarm, collector=collector)
217
+ if (duration_unit_default := self._dp_duration_unit.default) is not None:
218
+ await self._dp_duration_unit.send_value(value=duration_unit_default, collector=collector)
219
+ duration = kwargs.get("duration") or self._dp_duration.default
220
+ await self._dp_duration.send_value(value=duration, collector=collector)
221
+
222
+ def _compute_capabilities(self) -> SirenCapabilities:
223
+ """Compute static capabilities based on available DataPoints."""
224
+ return SirenCapabilities(
225
+ duration=True,
226
+ lights=self.available_lights is not None,
227
+ tones=self.available_tones is not None,
228
+ )
229
+
230
+
231
+ class CustomDpIpSirenSmoke(BaseCustomDpSiren):
232
+ """Class for HomematicIP siren smoke data point."""
233
+
234
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
235
+
236
+ # Declarative data point field definitions
237
+ _dp_smoke_detector_alarm_status: Final = DataPointField(
238
+ field=Field.SMOKE_DETECTOR_ALARM_STATUS, dpt=DpSensor[str | None]
239
+ )
240
+ _dp_smoke_detector_command: Final = DataPointField(field=Field.SMOKE_DETECTOR_COMMAND, dpt=DpActionSelect)
241
+
242
+ @state_property
243
+ def available_lights(self) -> tuple[str, ...] | None:
244
+ """Return available lights."""
245
+ return None
246
+
247
+ @state_property
248
+ def available_tones(self) -> tuple[str, ...] | None:
249
+ """Return available tones."""
250
+ return None
251
+
252
+ @state_property
253
+ def is_on(self) -> bool:
254
+ """Return true if siren is on."""
255
+ if not self._dp_smoke_detector_alarm_status.value:
256
+ return False
257
+ return bool(self._dp_smoke_detector_alarm_status.value != _SMOKE_DETECTOR_ALARM_STATUS_IDLE_OFF)
258
+
259
+ @bind_collector
260
+ async def turn_off(self, *, collector: CallParameterCollector | None = None) -> None:
261
+ """Turn the device off."""
262
+ await self._dp_smoke_detector_command.send_value(value=_SirenCommand.OFF, collector=collector)
263
+
264
+ @bind_collector
265
+ async def turn_on(
266
+ self,
267
+ *,
268
+ collector: CallParameterCollector | None = None,
269
+ **kwargs: Unpack[SirenOnArgs],
270
+ ) -> None:
271
+ """Turn the device on."""
272
+ await self._dp_smoke_detector_command.send_value(value=_SirenCommand.ON, collector=collector)
273
+
274
+ def _compute_capabilities(self) -> SirenCapabilities:
275
+ """Compute static capabilities. Smoke sensor siren has no configurable options."""
276
+ return SMOKE_SENSOR_SIREN_CAPABILITIES
277
+
278
+
279
+ class CustomDpSoundPlayer(TimerUnitMixin, BaseCustomDpSiren):
280
+ """Class for HomematicIP sound player data point (HmIP-MP3P channel 2)."""
281
+
282
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
283
+
284
+ # Declarative data point field definitions for sound channel
285
+ # Map on_time to DURATION_VALUE/UNIT for TimerUnitMixin compatibility (no Final for overrides)
286
+ _dp_level: Final = DataPointField(field=Field.LEVEL, dpt=DpAction)
287
+ _dp_on_time_value = DataPointField(field=Field.DURATION_VALUE, dpt=DpAction)
288
+ _dp_on_time_unit = DataPointField(field=Field.DURATION_UNIT, dpt=DpActionSelect)
289
+ _dp_ramp_time_value = DataPointField(field=Field.RAMP_TIME_VALUE, dpt=DpAction)
290
+ _dp_ramp_time_unit = DataPointField(field=Field.RAMP_TIME_UNIT, dpt=DpActionSelect)
291
+ _dp_soundfile: Final = DataPointField(field=Field.SOUNDFILE, dpt=DpSelect)
292
+ _dp_repetitions: Final = DataPointField(field=Field.REPETITIONS, dpt=DpActionSelect)
293
+ _dp_direction: Final = DataPointField(field=Field.DIRECTION, dpt=DpSensor[str | None])
294
+
295
+ # Expose available options via DelegatedProperty (from ActionSelect VALUE_LISTs)
296
+ @staticmethod
297
+ def _convert_soundfile_index(index: int) -> str:
298
+ """Convert integer index to soundfile name."""
299
+ if index < 1 or index > 189:
300
+ raise ValueError(i18n.tr(key="exception.model.custom.siren.invalid_soundfile_index", index=index))
301
+ return f"SOUNDFILE_{index:03d}"
302
+
303
+ available_soundfiles: Final = DelegatedProperty[tuple[str, ...] | None](
304
+ path="_dp_soundfile.values", kind=Kind.STATE
305
+ )
306
+
307
+ @state_property
308
+ def available_lights(self) -> tuple[str, ...] | None:
309
+ """Return available lights (not supported for sound player)."""
310
+ return None
311
+
312
+ @state_property
313
+ def available_tones(self) -> tuple[str, ...] | None:
314
+ """Return available tones (soundfiles for sound player)."""
315
+ return self.available_soundfiles
316
+
317
+ @state_property
318
+ def current_soundfile(self) -> str | None:
319
+ """Return currently selected soundfile."""
320
+ if (value := self._dp_soundfile.value) is None:
321
+ return None
322
+ return str(value)
323
+
324
+ @state_property
325
+ def is_on(self) -> bool:
326
+ """Return true if sound is currently playing."""
327
+ activity = self._dp_direction.value
328
+ return activity is not None and activity in _ACTIVITY_STATES_ACTIVE
329
+
330
+ @bind_collector
331
+ async def play_sound(
332
+ self,
333
+ *,
334
+ collector: CallParameterCollector | None = None,
335
+ **kwargs: Unpack[PlaySoundArgs],
336
+ ) -> None:
337
+ """
338
+ Play a sound file on the device.
339
+
340
+ API is comparable to other siren classes, using on_time/ramp_time in seconds
341
+ with automatic unit conversion via TimerUnitMixin.
342
+
343
+ Args:
344
+ collector: Optional call parameter collector.
345
+ **kwargs: Sound parameters from PlaySoundArgs:
346
+ soundfile: Soundfile from available_soundfiles or index (1-189).
347
+ volume: Volume level 0.0-1.0 (default: 0.5).
348
+ on_time: Duration in seconds (auto unit conversion, default: 10).
349
+ ramp_time: Ramp time in seconds (auto unit conversion, default: 0).
350
+ repetitions: 0=none, 1-18=count, -1=infinite (converted to VALUE_LIST).
351
+
352
+ """
353
+ soundfile = kwargs.get("soundfile", "INTERNAL_SOUNDFILE")
354
+ volume = kwargs.get("volume", 0.5)
355
+ on_time = kwargs.get("on_time", 10.0)
356
+ ramp_time = kwargs.get("ramp_time", 0.0)
357
+ repetitions_value = _convert_repetitions(repetitions=kwargs.get("repetitions"))
358
+
359
+ # Convert integer to soundfile name if needed
360
+ if isinstance(soundfile, int):
361
+ soundfile = self._convert_soundfile_index(soundfile)
362
+
363
+ # Validate volume
364
+ if not 0.0 <= volume <= 1.0:
365
+ raise ValidationException(
366
+ i18n.tr(
367
+ key="exception.model.custom.siren.invalid_volume",
368
+ full_name=self.full_name,
369
+ value=volume,
370
+ )
371
+ )
372
+
373
+ # Validate soundfile against available options
374
+ if self.available_soundfiles and soundfile not in self.available_soundfiles:
375
+ raise ValidationException(
376
+ i18n.tr(
377
+ key="exception.model.custom.siren.invalid_soundfile",
378
+ full_name=self.full_name,
379
+ value=soundfile,
380
+ )
381
+ )
382
+
383
+ # Send parameters - order matters for batching
384
+ await self._dp_level.send_value(value=volume, collector=collector)
385
+ await self._dp_soundfile.send_value(value=soundfile, collector=collector)
386
+ await self._dp_repetitions.send_value(value=repetitions_value, collector=collector)
387
+ # Use mixin methods for automatic unit conversion
388
+ await self._set_ramp_time_on_value(ramp_time=ramp_time, collector=collector)
389
+ await self._set_on_time_value(on_time=on_time, collector=collector)
390
+
391
+ @bind_collector
392
+ async def stop_sound(
393
+ self,
394
+ *,
395
+ collector: CallParameterCollector | None = None,
396
+ ) -> None:
397
+ """Stop current sound playback."""
398
+ await self._dp_level.send_value(value=0.0, collector=collector)
399
+ await self._dp_on_time_value.send_value(value=0, collector=collector)
400
+
401
+ @bind_collector
402
+ async def turn_off(self, *, collector: CallParameterCollector | None = None) -> None:
403
+ """Turn the device off."""
404
+ await self.stop_sound(collector=collector)
405
+
406
+ @bind_collector
407
+ async def turn_on(
408
+ self,
409
+ *,
410
+ collector: CallParameterCollector | None = None,
411
+ **kwargs: Unpack[SirenOnArgs],
412
+ ) -> None:
413
+ """Turn the device on."""
414
+ # Map SirenOnArgs to PlaySoundArgs
415
+ play_kwargs: PlaySoundArgs = {}
416
+ if "acoustic_alarm" in kwargs:
417
+ play_kwargs["soundfile"] = kwargs["acoustic_alarm"]
418
+ if "duration" in kwargs:
419
+ # Duration in SirenOnArgs is a string, try to parse as float (seconds)
420
+ with contextlib.suppress(ValueError, TypeError):
421
+ play_kwargs["on_time"] = float(kwargs["duration"])
422
+ await self.play_sound(collector=collector, **play_kwargs)
423
+
424
+ def _compute_capabilities(self) -> SirenCapabilities:
425
+ """Compute static capabilities based on available DataPoints."""
426
+ return SirenCapabilities(
427
+ duration=True,
428
+ lights=False,
429
+ tones=False,
430
+ soundfiles=self.available_soundfiles is not None,
431
+ )
432
+
433
+
434
+ # =============================================================================
435
+ # DeviceProfileRegistry Registration
436
+ # =============================================================================
437
+
438
+ # IP Siren
439
+ DeviceProfileRegistry.register(
440
+ category=DataPointCategory.SIREN,
441
+ models="HmIP-ASIR",
442
+ data_point_class=CustomDpIpSiren,
443
+ profile_type=DeviceProfile.IP_SIREN,
444
+ channels=(3,),
445
+ )
446
+
447
+ # IP Siren Smoke
448
+ DeviceProfileRegistry.register(
449
+ category=DataPointCategory.SIREN,
450
+ models="HmIP-SWSD",
451
+ data_point_class=CustomDpIpSirenSmoke,
452
+ profile_type=DeviceProfile.IP_SIREN_SMOKE,
453
+ )
454
+
455
+ # HmIP-MP3P Sound Player (channel 2)
456
+ DeviceProfileRegistry.register(
457
+ category=DataPointCategory.SIREN,
458
+ models="HmIP-MP3P",
459
+ data_point_class=CustomDpSoundPlayer,
460
+ profile_type=DeviceProfile.IP_SOUND_PLAYER,
461
+ channels=(2,),
462
+ )
@@ -0,0 +1,195 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Custom switch data points for advanced switching devices.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Final, Unpack
13
+
14
+ from aiohomematic.const import DataPointCategory, DeviceProfile, Field, Parameter
15
+ from aiohomematic.model.custom.data_point import CustomDataPoint
16
+ from aiohomematic.model.custom.field import DataPointField
17
+ from aiohomematic.model.custom.mixins import GroupStateMixin, StateChangeArgs, StateChangeTimerMixin
18
+ from aiohomematic.model.custom.registry import DeviceProfileRegistry, ExtendedDeviceConfig
19
+ from aiohomematic.model.data_point import CallParameterCollector, bind_collector
20
+ from aiohomematic.model.generic import DpAction, DpBinarySensor, DpSwitch
21
+ from aiohomematic.property_decorators import DelegatedProperty, Kind
22
+
23
+ _LOGGER: Final = logging.getLogger(__name__)
24
+
25
+
26
+ class CustomDpSwitch(StateChangeTimerMixin, GroupStateMixin, CustomDataPoint):
27
+ """Class for Homematic switch data point."""
28
+
29
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
30
+
31
+ _category = DataPointCategory.SWITCH
32
+
33
+ # Declarative data point field definitions
34
+ _dp_group_state = DataPointField(field=Field.GROUP_STATE, dpt=DpBinarySensor)
35
+ _dp_on_time_value = DataPointField(field=Field.ON_TIME_VALUE, dpt=DpAction)
36
+ _dp_state: Final = DataPointField(field=Field.STATE, dpt=DpSwitch)
37
+
38
+ value: Final = DelegatedProperty[bool | None](path="_dp_state.value", kind=Kind.STATE)
39
+
40
+ def is_state_change(self, **kwargs: Unpack[StateChangeArgs]) -> bool:
41
+ """Check if the state changes due to kwargs."""
42
+ if self.is_state_change_for_on_off(**kwargs):
43
+ return True
44
+ return super().is_state_change(**kwargs)
45
+
46
+ @bind_collector
47
+ async def turn_off(self, *, collector: CallParameterCollector | None = None) -> None:
48
+ """Turn the switch off."""
49
+ self.reset_timer_on_time()
50
+ if not self.is_state_change(off=True):
51
+ return
52
+ await self._dp_state.turn_off(collector=collector)
53
+
54
+ @bind_collector
55
+ async def turn_on(self, *, on_time: float | None = None, collector: CallParameterCollector | None = None) -> None:
56
+ """Turn the switch on."""
57
+ if on_time is not None:
58
+ self.set_timer_on_time(on_time=on_time)
59
+ if not self.is_state_change(on=True):
60
+ return
61
+
62
+ if (timer := self.get_and_start_timer()) is not None:
63
+ await self._dp_on_time_value.send_value(value=timer, collector=collector, do_validate=False)
64
+ await self._dp_state.turn_on(collector=collector)
65
+
66
+
67
+ # =============================================================================
68
+ # DeviceProfileRegistry Registration
69
+ # =============================================================================
70
+
71
+ # IP Switch (various channel configurations)
72
+ DeviceProfileRegistry.register(
73
+ category=DataPointCategory.SWITCH,
74
+ models=("ELV-SH-BS2", "HmIP-BS2", "HmIP-PCBS2"),
75
+ data_point_class=CustomDpSwitch,
76
+ profile_type=DeviceProfile.IP_SWITCH,
77
+ channels=(4, 8),
78
+ )
79
+ DeviceProfileRegistry.register(
80
+ category=DataPointCategory.SWITCH,
81
+ models=(
82
+ "ELV-SH-PSMCI",
83
+ "ELV-SH-SW1-BAT",
84
+ "HmIP-DRSI1",
85
+ "HmIP-FSI",
86
+ "HmIP-PCBS",
87
+ "HmIP-PCBS-BAT",
88
+ "HmIP-PS",
89
+ "HmIP-USBSM",
90
+ "HmIP-WGC",
91
+ ),
92
+ data_point_class=CustomDpSwitch,
93
+ profile_type=DeviceProfile.IP_SWITCH,
94
+ channels=(3,),
95
+ )
96
+ DeviceProfileRegistry.register(
97
+ category=DataPointCategory.SWITCH,
98
+ models=("HmIP-BSL", "HmIP-BSM"),
99
+ data_point_class=CustomDpSwitch,
100
+ profile_type=DeviceProfile.IP_SWITCH,
101
+ channels=(4,),
102
+ )
103
+ DeviceProfileRegistry.register(
104
+ category=DataPointCategory.SWITCH,
105
+ models="HmIP-DRSI4",
106
+ data_point_class=CustomDpSwitch,
107
+ profile_type=DeviceProfile.IP_SWITCH,
108
+ channels=(6, 10, 14, 18),
109
+ )
110
+ DeviceProfileRegistry.register(
111
+ category=DataPointCategory.SWITCH,
112
+ models="HmIP-FSM",
113
+ data_point_class=CustomDpSwitch,
114
+ profile_type=DeviceProfile.IP_SWITCH,
115
+ channels=(2,),
116
+ )
117
+ DeviceProfileRegistry.register(
118
+ category=DataPointCategory.SWITCH,
119
+ models="HmIP-MOD-OC8",
120
+ data_point_class=CustomDpSwitch,
121
+ profile_type=DeviceProfile.IP_SWITCH,
122
+ channels=(10, 14, 18, 22, 26, 30, 34, 38),
123
+ )
124
+ DeviceProfileRegistry.register(
125
+ category=DataPointCategory.SWITCH,
126
+ models="HmIP-SCTH230",
127
+ data_point_class=CustomDpSwitch,
128
+ profile_type=DeviceProfile.IP_SWITCH,
129
+ channels=(8,),
130
+ )
131
+ DeviceProfileRegistry.register(
132
+ category=DataPointCategory.SWITCH,
133
+ models="HmIP-WGT",
134
+ data_point_class=CustomDpSwitch,
135
+ profile_type=DeviceProfile.IP_SWITCH,
136
+ channels=(4,),
137
+ )
138
+ DeviceProfileRegistry.register(
139
+ category=DataPointCategory.SWITCH,
140
+ models="HmIP-WHS2",
141
+ data_point_class=CustomDpSwitch,
142
+ profile_type=DeviceProfile.IP_SWITCH,
143
+ channels=(2, 6),
144
+ )
145
+ DeviceProfileRegistry.register(
146
+ category=DataPointCategory.SWITCH,
147
+ models="HmIPW-DRS",
148
+ data_point_class=CustomDpSwitch,
149
+ profile_type=DeviceProfile.IP_SWITCH,
150
+ channels=(2, 6, 10, 14, 18, 22, 26, 30),
151
+ )
152
+ DeviceProfileRegistry.register(
153
+ category=DataPointCategory.SWITCH,
154
+ models="HmIPW-FIO6",
155
+ data_point_class=CustomDpSwitch,
156
+ profile_type=DeviceProfile.IP_SWITCH,
157
+ channels=(8, 12, 16, 20, 24, 28),
158
+ )
159
+
160
+ # HmIP-SMO230 (Switch with motion sensor)
161
+ DeviceProfileRegistry.register(
162
+ category=DataPointCategory.SWITCH,
163
+ models="HmIP-SMO230",
164
+ data_point_class=CustomDpSwitch,
165
+ profile_type=DeviceProfile.IP_SWITCH,
166
+ channels=(10,),
167
+ extended=ExtendedDeviceConfig(
168
+ additional_data_points={
169
+ 1: (
170
+ Parameter.ILLUMINATION,
171
+ Parameter.MOTION,
172
+ Parameter.MOTION_DETECTION_ACTIVE,
173
+ Parameter.RESET_MOTION,
174
+ ),
175
+ 2: (
176
+ Parameter.ILLUMINATION,
177
+ Parameter.MOTION,
178
+ Parameter.MOTION_DETECTION_ACTIVE,
179
+ Parameter.RESET_MOTION,
180
+ ),
181
+ 3: (
182
+ Parameter.ILLUMINATION,
183
+ Parameter.MOTION,
184
+ Parameter.MOTION_DETECTION_ACTIVE,
185
+ Parameter.RESET_MOTION,
186
+ ),
187
+ 4: (
188
+ Parameter.ILLUMINATION,
189
+ Parameter.MOTION,
190
+ Parameter.MOTION_DETECTION_ACTIVE,
191
+ Parameter.RESET_MOTION,
192
+ ),
193
+ }
194
+ ),
195
+ )