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,1130 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Custom climate data points for thermostats and HVAC controls.
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
+ from collections.abc import Mapping
13
+ from datetime import datetime, timedelta
14
+ from enum import IntEnum, StrEnum
15
+ import logging
16
+ from typing import Final, Unpack, cast
17
+
18
+ from aiohomematic import i18n
19
+ from aiohomematic.const import (
20
+ BIDCOS_DEVICE_CHANNEL_DUMMY,
21
+ DEFAULT_CLIMATE_FILL_TEMPERATURE,
22
+ ClimateProfileSchedule,
23
+ ClimateWeekdaySchedule,
24
+ DataPointCategory,
25
+ DeviceProfile,
26
+ Field,
27
+ InternalCustomID,
28
+ OptionalSettings,
29
+ Parameter,
30
+ ParamsetKey,
31
+ ScheduleProfile,
32
+ SimpleProfileSchedule,
33
+ SimpleScheduleDict,
34
+ SimpleWeekdaySchedule,
35
+ WeekdayStr,
36
+ )
37
+ from aiohomematic.decorators import inspector
38
+ from aiohomematic.exceptions import ValidationException
39
+ from aiohomematic.interfaces import ChannelProtocol, GenericDataPointProtocolAny
40
+ from aiohomematic.model import week_profile as wp
41
+ from aiohomematic.model.custom.capabilities.climate import (
42
+ BASIC_CLIMATE_CAPABILITIES,
43
+ IP_THERMOSTAT_CAPABILITIES,
44
+ ClimateCapabilities,
45
+ )
46
+ from aiohomematic.model.custom.data_point import CustomDataPoint
47
+ from aiohomematic.model.custom.field import DataPointField
48
+ from aiohomematic.model.custom.mixins import StateChangeArgs
49
+ from aiohomematic.model.custom.profile import RebasedChannelGroupConfig
50
+ from aiohomematic.model.custom.registry import DeviceConfig, DeviceProfileRegistry
51
+ from aiohomematic.model.data_point import CallParameterCollector, bind_collector
52
+ from aiohomematic.model.generic import DpAction, DpBinarySensor, DpFloat, DpInteger, DpSelect, DpSensor, DpSwitch
53
+ from aiohomematic.property_decorators import DelegatedProperty, Kind, config_property, state_property
54
+ from aiohomematic.type_aliases import UnsubscribeCallback
55
+
56
+ _LOGGER: Final = logging.getLogger(__name__)
57
+
58
+ _CLOSED_LEVEL: Final = 0.0
59
+ _DEFAULT_TEMPERATURE_STEP: Final = 0.5
60
+ _OFF_TEMPERATURE: Final = 4.5
61
+ _PARTY_DATE_FORMAT: Final = "%Y_%m_%d %H:%M"
62
+ _PARTY_INIT_DATE: Final = "2000_01_01 00:00"
63
+ _TEMP_CELSIUS: Final = "°C"
64
+ PROFILE_PREFIX: Final = "week_program_"
65
+
66
+
67
+ class _ModeHm(StrEnum):
68
+ """Enum with the HM modes."""
69
+
70
+ AUTO = "AUTO-MODE" # 0
71
+ AWAY = "PARTY-MODE" # 2
72
+ BOOST = "BOOST-MODE" # 3
73
+ MANU = "MANU-MODE" # 1
74
+
75
+
76
+ class _ModeHmIP(IntEnum):
77
+ """Enum with the HmIP modes."""
78
+
79
+ AUTO = 0
80
+ AWAY = 2
81
+ MANU = 1
82
+
83
+
84
+ class _StateChangeArg(StrEnum):
85
+ """Enum with climate state change arguments."""
86
+
87
+ MODE = "mode"
88
+ PROFILE = "profile"
89
+ TEMPERATURE = "temperature"
90
+
91
+
92
+ class ClimateActivity(StrEnum):
93
+ """Enum with the climate activities."""
94
+
95
+ COOL = "cooling"
96
+ HEAT = "heating"
97
+ IDLE = "idle"
98
+ OFF = "off"
99
+
100
+
101
+ class ClimateHeatingValveType(StrEnum):
102
+ """Enum with the climate heating valve types."""
103
+
104
+ NORMALLY_CLOSE = "NORMALLY_CLOSE"
105
+ NORMALLY_OPEN = "NORMALLY_OPEN"
106
+
107
+
108
+ class ClimateMode(StrEnum):
109
+ """Enum with the thermostat modes."""
110
+
111
+ AUTO = "auto"
112
+ COOL = "cool"
113
+ HEAT = "heat"
114
+ OFF = "off"
115
+
116
+
117
+ class ClimateProfile(StrEnum):
118
+ """Enum with profiles."""
119
+
120
+ AWAY = "away"
121
+ BOOST = "boost"
122
+ COMFORT = "comfort"
123
+ ECO = "eco"
124
+ NONE = "none"
125
+ WEEK_PROGRAM_1 = "week_program_1"
126
+ WEEK_PROGRAM_2 = "week_program_2"
127
+ WEEK_PROGRAM_3 = "week_program_3"
128
+ WEEK_PROGRAM_4 = "week_program_4"
129
+ WEEK_PROGRAM_5 = "week_program_5"
130
+ WEEK_PROGRAM_6 = "week_program_6"
131
+
132
+
133
+ _HM_WEEK_PROFILE_POINTERS_TO_NAMES: Final = {
134
+ 0: "WEEK PROGRAM 1",
135
+ 1: "WEEK PROGRAM 2",
136
+ 2: "WEEK PROGRAM 3",
137
+ 3: "WEEK PROGRAM 4",
138
+ 4: "WEEK PROGRAM 5",
139
+ 5: "WEEK PROGRAM 6",
140
+ }
141
+ _HM_WEEK_PROFILE_POINTERS_TO_IDX: Final = {v: k for k, v in _HM_WEEK_PROFILE_POINTERS_TO_NAMES.items()}
142
+
143
+
144
+ class BaseCustomDpClimate(CustomDataPoint):
145
+ """Base Homematic climate data_point."""
146
+
147
+ __slots__ = (
148
+ "_capabilities",
149
+ "_old_manu_setpoint",
150
+ "_peer_level_dp",
151
+ "_peer_state_dp",
152
+ "_peer_unsubscribe_callbacks",
153
+ )
154
+
155
+ _category = DataPointCategory.CLIMATE
156
+
157
+ @property
158
+ def capabilities(self) -> ClimateCapabilities:
159
+ """Return the climate capabilities."""
160
+ if (caps := getattr(self, "_capabilities", None)) is None:
161
+ caps = self._compute_capabilities()
162
+ object.__setattr__(self, "_capabilities", caps)
163
+ return caps
164
+
165
+ def _compute_capabilities(self) -> ClimateCapabilities:
166
+ """Compute static capabilities. Base implementation returns no profiles."""
167
+ return BASIC_CLIMATE_CAPABILITIES
168
+
169
+ # Declarative data point field definitions
170
+ _dp_humidity: Final = DataPointField(field=Field.HUMIDITY, dpt=DpSensor[int | None])
171
+ _dp_min_max_value_not_relevant_for_manu_mode: Final = DataPointField(
172
+ field=Field.MIN_MAX_VALUE_NOT_RELEVANT_FOR_MANU_MODE, dpt=DpSwitch
173
+ )
174
+ _dp_setpoint: Final = DataPointField(field=Field.SETPOINT, dpt=DpFloat)
175
+ _dp_temperature: Final = DataPointField(field=Field.TEMPERATURE, dpt=DpSensor[float | None])
176
+ _dp_temperature_maximum: Final = DataPointField(field=Field.TEMPERATURE_MAXIMUM, dpt=DpFloat)
177
+ _dp_temperature_minimum: Final = DataPointField(field=Field.TEMPERATURE_MINIMUM, dpt=DpFloat)
178
+
179
+ def __init__(
180
+ self,
181
+ *,
182
+ channel: ChannelProtocol,
183
+ unique_id: str,
184
+ device_profile: DeviceProfile,
185
+ channel_group: RebasedChannelGroupConfig,
186
+ custom_data_point_def: Mapping[int | tuple[int, ...], tuple[Parameter, ...]],
187
+ group_no: int | None,
188
+ device_config: DeviceConfig,
189
+ ) -> None:
190
+ """Initialize base climate data_point."""
191
+ self._peer_level_dp: DpFloat | None = None
192
+ self._peer_state_dp: DpBinarySensor | None = None
193
+ self._peer_unsubscribe_callbacks: list[UnsubscribeCallback] = []
194
+ super().__init__(
195
+ channel=channel,
196
+ unique_id=unique_id,
197
+ device_profile=device_profile,
198
+ channel_group=channel_group,
199
+ custom_data_point_def=custom_data_point_def,
200
+ group_no=group_no,
201
+ device_config=device_config,
202
+ )
203
+ self._old_manu_setpoint: float | None = None
204
+
205
+ current_humidity: Final = DelegatedProperty[int | None](path="_dp_humidity.value", kind=Kind.STATE)
206
+ current_temperature: Final = DelegatedProperty[float | None](path="_dp_temperature.value", kind=Kind.STATE)
207
+ target_temperature: Final = DelegatedProperty[float | None](path="_dp_setpoint.value", kind=Kind.STATE)
208
+
209
+ @property
210
+ def _temperature_for_heat_mode(self) -> float:
211
+ """
212
+ Return a safe temperature to use when setting mode to HEAT.
213
+
214
+ If the current target temperature is None or represents the special OFF value,
215
+ fall back to the device's minimum valid temperature. Otherwise, return the
216
+ current target temperature clipped to the valid [min, max] range.
217
+ """
218
+ temp = self._old_manu_setpoint or self.target_temperature
219
+ # Treat None or OFF sentinel as invalid/unsafe to restore.
220
+ if temp is None or temp <= _OFF_TEMPERATURE or temp < self.min_temp:
221
+ return self.min_temp if self.min_temp > _OFF_TEMPERATURE else _OFF_TEMPERATURE + 0.5
222
+ if temp > self.max_temp:
223
+ return self.max_temp
224
+ return temp
225
+
226
+ @property
227
+ def available_schedule_profiles(self) -> tuple[ScheduleProfile, ...]:
228
+ """Return available schedule profiles."""
229
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
230
+ return self._device.week_profile.available_schedule_profiles
231
+ return ()
232
+
233
+ @property
234
+ def schedule_profile_nos(self) -> int:
235
+ """Return the number of supported profiles."""
236
+ return 0
237
+
238
+ @property
239
+ def simple_schedule(self) -> SimpleScheduleDict:
240
+ """
241
+ Return cached simple schedule in TypedDict format.
242
+
243
+ This format uses string keys and is optimized for JSON serialization.
244
+ Ideal for custom card integration.
245
+
246
+ Returns:
247
+ SimpleScheduleDict with base_temperature and periods per weekday
248
+
249
+ """
250
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
251
+ return self._device.week_profile.simple_schedule
252
+ return {}
253
+
254
+ @config_property
255
+ def target_temperature_step(self) -> float:
256
+ """Return the supported step of target temperature."""
257
+ return _DEFAULT_TEMPERATURE_STEP
258
+
259
+ @config_property
260
+ def temperature_unit(self) -> str:
261
+ """Return temperature unit."""
262
+ return _TEMP_CELSIUS
263
+
264
+ @state_property
265
+ def activity(self) -> ClimateActivity | None:
266
+ """Return the current activity."""
267
+ return None
268
+
269
+ @state_property
270
+ def max_temp(self) -> float:
271
+ """Return the maximum temperature."""
272
+ if self._dp_temperature_maximum.value is not None:
273
+ return float(self._dp_temperature_maximum.value)
274
+ return cast(float, self._dp_setpoint.max)
275
+
276
+ @state_property
277
+ def min_max_value_not_relevant_for_manu_mode(self) -> bool:
278
+ """Return the maximum temperature."""
279
+ if self._dp_min_max_value_not_relevant_for_manu_mode.value is not None:
280
+ return self._dp_min_max_value_not_relevant_for_manu_mode.value
281
+ return False
282
+
283
+ @state_property
284
+ def min_temp(self) -> float:
285
+ """Return the minimum temperature."""
286
+ if self._dp_temperature_minimum.value is not None:
287
+ min_temp = float(self._dp_temperature_minimum.value)
288
+ else:
289
+ min_temp = float(self._dp_setpoint.min) if self._dp_setpoint.min is not None else 0.0
290
+
291
+ if min_temp == _OFF_TEMPERATURE:
292
+ return min_temp + _DEFAULT_TEMPERATURE_STEP
293
+ return min_temp
294
+
295
+ @state_property
296
+ def mode(self) -> ClimateMode:
297
+ """Return current operation mode."""
298
+ return ClimateMode.HEAT
299
+
300
+ @state_property
301
+ def modes(self) -> tuple[ClimateMode, ...]:
302
+ """Return the available operation modes."""
303
+ return (ClimateMode.HEAT,)
304
+
305
+ @state_property
306
+ def profile(self) -> ClimateProfile:
307
+ """Return the current profile."""
308
+ return ClimateProfile.NONE
309
+
310
+ @state_property
311
+ def profiles(self) -> tuple[ClimateProfile, ...]:
312
+ """Return available profiles."""
313
+ return (ClimateProfile.NONE,)
314
+
315
+ @inspector
316
+ async def copy_schedule(self, *, target_climate_data_point: BaseCustomDpClimate) -> None:
317
+ """Copy schedule to target device (delegates to week profile)."""
318
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
319
+ await self._device.week_profile.copy_schedule(target_climate_data_point=target_climate_data_point)
320
+
321
+ @inspector
322
+ async def copy_schedule_profile(
323
+ self,
324
+ *,
325
+ source_profile: ScheduleProfile,
326
+ target_profile: ScheduleProfile,
327
+ target_climate_data_point: BaseCustomDpClimate | None = None,
328
+ ) -> None:
329
+ """Copy schedule profile to target device (delegates to week profile)."""
330
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
331
+ await self._device.week_profile.copy_profile(
332
+ source_profile=source_profile,
333
+ target_profile=target_profile,
334
+ target_climate_data_point=target_climate_data_point,
335
+ )
336
+
337
+ @inspector
338
+ async def disable_away_mode(self) -> None:
339
+ """Disable the away mode on thermostat."""
340
+
341
+ @inspector
342
+ async def enable_away_mode_by_calendar(self, *, start: datetime, end: datetime, away_temperature: float) -> None:
343
+ """Enable the away mode by calendar on thermostat."""
344
+
345
+ @inspector
346
+ async def enable_away_mode_by_duration(self, *, hours: int, away_temperature: float) -> None:
347
+ """Enable the away mode by duration on thermostat."""
348
+
349
+ @inspector
350
+ async def get_schedule_profile(
351
+ self, *, profile: ScheduleProfile, force_load: bool = False
352
+ ) -> ClimateProfileSchedule:
353
+ """Return a schedule by climate profile (delegates to week profile)."""
354
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
355
+ return await self._device.week_profile.get_profile(profile=profile, force_load=force_load)
356
+ return {}
357
+
358
+ @inspector
359
+ async def get_schedule_simple_profile(
360
+ self, *, profile: ScheduleProfile, force_load: bool = False
361
+ ) -> SimpleProfileSchedule:
362
+ """Return a simple schedule by climate profile (delegates to week profile)."""
363
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
364
+ return await self._device.week_profile.get_simple_profile(profile=profile, force_load=force_load)
365
+ return {}
366
+
367
+ @inspector
368
+ async def get_schedule_simple_schedule(self, *, force_load: bool = False) -> SimpleScheduleDict:
369
+ """Return the complete simple schedule dictionary (delegates to week profile)."""
370
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
371
+ return await self._device.week_profile.get_simple_schedule(force_load=force_load)
372
+ return {}
373
+
374
+ @inspector
375
+ async def get_schedule_simple_weekday(
376
+ self, *, profile: ScheduleProfile, weekday: WeekdayStr, force_load: bool = False
377
+ ) -> SimpleWeekdaySchedule:
378
+ """Return a simple schedule by climate profile and weekday (delegates to week profile)."""
379
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
380
+ return await self._device.week_profile.get_simple_weekday(
381
+ profile=profile, weekday=weekday, force_load=force_load
382
+ )
383
+ return SimpleWeekdaySchedule(base_temperature=DEFAULT_CLIMATE_FILL_TEMPERATURE, periods=[])
384
+
385
+ @inspector
386
+ async def get_schedule_weekday(
387
+ self, *, profile: ScheduleProfile, weekday: WeekdayStr, force_load: bool = False
388
+ ) -> ClimateWeekdaySchedule:
389
+ """Return a schedule by climate profile and weekday (delegates to week profile)."""
390
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
391
+ return await self._device.week_profile.get_weekday(profile=profile, weekday=weekday, force_load=force_load)
392
+ return {}
393
+
394
+ def is_state_change(self, **kwargs: Unpack[StateChangeArgs]) -> bool:
395
+ """Check if the state changes due to kwargs."""
396
+ if (
397
+ temperature := kwargs.get(_StateChangeArg.TEMPERATURE)
398
+ ) is not None and temperature != self.target_temperature:
399
+ return True
400
+ if (mode := kwargs.get(_StateChangeArg.MODE)) is not None and mode != self.mode:
401
+ return True
402
+ if (profile := kwargs.get(_StateChangeArg.PROFILE)) is not None and profile != self.profile:
403
+ return True
404
+ return super().is_state_change(**kwargs)
405
+
406
+ @bind_collector
407
+ async def set_mode(self, *, mode: ClimateMode, collector: CallParameterCollector | None = None) -> None:
408
+ """Set new target mode."""
409
+
410
+ @bind_collector
411
+ async def set_profile(self, *, profile: ClimateProfile, collector: CallParameterCollector | None = None) -> None:
412
+ """Set new profile."""
413
+
414
+ @inspector
415
+ async def set_schedule_profile(
416
+ self, *, profile: ScheduleProfile, profile_data: ClimateProfileSchedule, do_validate: bool = True
417
+ ) -> None:
418
+ """Set a profile to device (delegates to week profile)."""
419
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
420
+ await self._device.week_profile.set_profile(
421
+ profile=profile, profile_data=profile_data, do_validate=do_validate
422
+ )
423
+
424
+ @inspector
425
+ async def set_schedule_weekday(
426
+ self,
427
+ *,
428
+ profile: ScheduleProfile,
429
+ weekday: WeekdayStr,
430
+ weekday_data: ClimateWeekdaySchedule,
431
+ do_validate: bool = True,
432
+ ) -> None:
433
+ """Store a profile weekday to device (delegates to week profile)."""
434
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
435
+ await self._device.week_profile.set_weekday(
436
+ profile=profile, weekday=weekday, weekday_data=weekday_data, do_validate=do_validate
437
+ )
438
+
439
+ @inspector
440
+ async def set_simple_schedule(self, *, simple_schedule_data: SimpleScheduleDict) -> None:
441
+ """Set the complete simple schedule dictionary to device (delegates to week profile)."""
442
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
443
+ await self._device.week_profile.set_simple_schedule(simple_schedule_data=simple_schedule_data)
444
+
445
+ @inspector
446
+ async def set_simple_schedule_profile(
447
+ self,
448
+ *,
449
+ profile: ScheduleProfile,
450
+ simple_profile_data: SimpleProfileSchedule,
451
+ ) -> None:
452
+ """Set a profile to device using simple format (delegates to week profile)."""
453
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
454
+ await self._device.week_profile.set_simple_profile(profile=profile, simple_profile_data=simple_profile_data)
455
+
456
+ @inspector
457
+ async def set_simple_schedule_weekday(
458
+ self,
459
+ *,
460
+ profile: ScheduleProfile,
461
+ weekday: WeekdayStr,
462
+ simple_weekday_data: SimpleWeekdaySchedule,
463
+ ) -> None:
464
+ """Store a simple weekday profile to device (delegates to week profile)."""
465
+ if self._device.week_profile and isinstance(self._device.week_profile, wp.ClimateWeekProfile):
466
+ await self._device.week_profile.set_simple_weekday(
467
+ profile=profile,
468
+ weekday=weekday,
469
+ simple_weekday_data=simple_weekday_data,
470
+ )
471
+
472
+ @bind_collector
473
+ async def set_temperature(
474
+ self,
475
+ *,
476
+ temperature: float,
477
+ collector: CallParameterCollector | None = None,
478
+ do_validate: bool = True,
479
+ ) -> None:
480
+ """Set new target temperature. The temperature must be set in all cases, even if the values are identical."""
481
+ if do_validate and self.mode == ClimateMode.HEAT and self.min_max_value_not_relevant_for_manu_mode:
482
+ do_validate = False
483
+
484
+ if do_validate and not (self.min_temp <= temperature <= self.max_temp):
485
+ raise ValidationException(
486
+ i18n.tr(
487
+ key="exception.model.custom.climate.set_temperature.invalid",
488
+ temperature=temperature,
489
+ min=self.min_temp,
490
+ max=self.max_temp,
491
+ )
492
+ )
493
+
494
+ await self._dp_setpoint.send_value(value=temperature, collector=collector, do_validate=do_validate)
495
+
496
+ @abstractmethod
497
+ def _manu_temp_changed(
498
+ self, *, data_point: GenericDataPointProtocolAny | None = None, custom_id: str | None = None
499
+ ) -> None:
500
+ """Handle device state changes."""
501
+
502
+ def _on_link_peer_changed(self) -> None:
503
+ """
504
+ Handle a change of the link peer channel.
505
+
506
+ Refresh references to `STATE`/`LEVEL` on the peer and publish an update so
507
+ consumers can re-evaluate `activity`.
508
+ """
509
+ self._refresh_link_peer_activity_sources()
510
+ # Inform listeners that relevant inputs may have changed
511
+ self.publish_data_point_updated_event()
512
+
513
+ def _post_init(self) -> None:
514
+ """Post action after initialisation of the data point fields."""
515
+ super()._post_init()
516
+
517
+ self._unsubscribe_callbacks.append(
518
+ self._dp_setpoint.subscribe_to_data_point_updated(
519
+ handler=self._manu_temp_changed, custom_id=InternalCustomID.MANU_TEMP
520
+ )
521
+ )
522
+
523
+ if (
524
+ OptionalSettings.ENABLE_LINKED_ENTITY_CLIMATE_ACTIVITY
525
+ not in self._device.config_provider.config.optional_settings
526
+ ):
527
+ return
528
+
529
+ for ch in self._device.channels.values():
530
+ # subscribe to link-peer change events; store unsubscribe handle
531
+ if (unreg := ch.subscribe_to_link_peer_changed(handler=self._on_link_peer_changed)) is not None:
532
+ self._unsubscribe_callbacks.append(unreg)
533
+ # pre-populate peer references (if any) once
534
+ self._refresh_link_peer_activity_sources()
535
+
536
+ def _refresh_link_peer_activity_sources(self) -> None:
537
+ """
538
+ Refresh peer data point references used for `activity` fallback.
539
+
540
+ - Unsubscribe from any previously subscribed peer updates.
541
+ - Grab its `STATE` and `LEVEL` generic data points from any available linked channel (if available).
542
+ - Subscribe to their updates to keep `activity` current.
543
+ """
544
+ # Unsubscribe from previous peer DPs
545
+ # Make a copy to avoid modifying list during iteration
546
+ for unreg in list(self._peer_unsubscribe_callbacks):
547
+ if unreg is not None:
548
+ try:
549
+ unreg()
550
+ finally:
551
+ # Remove from both lists to prevent double-cleanup
552
+ if unreg in self._unsubscribe_callbacks:
553
+ self._unsubscribe_callbacks.remove(unreg)
554
+
555
+ self._peer_unsubscribe_callbacks.clear()
556
+ self._peer_level_dp = None
557
+ self._peer_state_dp = None
558
+
559
+ try:
560
+ # Go thru all link peer channels of the device
561
+ for link_channels in self._device.link_peer_channels.values():
562
+ # Some channels have multiple link peers
563
+ for link_channel in link_channels:
564
+ # Continue if LEVEL or STATE dp found and ignore the others
565
+ if not link_channel.has_link_target_category(category=DataPointCategory.CLIMATE):
566
+ continue
567
+ if level_dp := link_channel.get_generic_data_point(parameter=Parameter.LEVEL):
568
+ self._peer_level_dp = cast(DpFloat, level_dp)
569
+ break
570
+ if state_dp := link_channel.get_generic_data_point(parameter=Parameter.STATE):
571
+ self._peer_state_dp = cast(DpBinarySensor, state_dp)
572
+ break
573
+ except Exception: # pragma: no cover - defensive
574
+ self._peer_level_dp = None
575
+ self._peer_state_dp = None
576
+ return
577
+
578
+ # Subscribe to updates of peer DPs to forward update events
579
+ for dp in (self._peer_level_dp, self._peer_state_dp):
580
+ if dp is None:
581
+ continue
582
+ unreg = dp.subscribe_to_data_point_updated(
583
+ handler=self.publish_data_point_updated_event, custom_id=InternalCustomID.LINK_PEER
584
+ )
585
+ if unreg is not None:
586
+ # Track for both refresh-time cleanup and object removal cleanup
587
+ self._peer_unsubscribe_callbacks.append(unreg)
588
+ self._unsubscribe_callbacks.append(unreg)
589
+
590
+
591
+ class CustomDpSimpleRfThermostat(BaseCustomDpClimate):
592
+ """Simple classic Homematic thermostat HM-CC-TC."""
593
+
594
+ __slots__ = ()
595
+
596
+ def _manu_temp_changed(
597
+ self, *, data_point: GenericDataPointProtocolAny | None = None, custom_id: str | None = None
598
+ ) -> None:
599
+ """Handle device state changes."""
600
+
601
+
602
+ class CustomDpRfThermostat(BaseCustomDpClimate):
603
+ """Classic Homematic thermostat like HM-CC-RT-DN."""
604
+
605
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
606
+
607
+ # Declarative data point field definitions
608
+ _dp_auto_mode: Final = DataPointField(field=Field.AUTO_MODE, dpt=DpAction)
609
+ _dp_boost_mode: Final = DataPointField(field=Field.BOOST_MODE, dpt=DpAction)
610
+ _dp_comfort_mode: Final = DataPointField(field=Field.COMFORT_MODE, dpt=DpAction)
611
+ _dp_control_mode: Final = DataPointField(field=Field.CONTROL_MODE, dpt=DpSensor[str | None])
612
+ _dp_lowering_mode: Final = DataPointField(field=Field.LOWERING_MODE, dpt=DpAction)
613
+ _dp_manu_mode: Final = DataPointField(field=Field.MANU_MODE, dpt=DpAction)
614
+ _dp_temperature_offset: Final = DataPointField(field=Field.TEMPERATURE_OFFSET, dpt=DpSelect)
615
+ _dp_valve_state: Final = DataPointField(field=Field.VALVE_STATE, dpt=DpSensor[int | None])
616
+ _dp_week_program_pointer: Final = DataPointField(field=Field.WEEK_PROGRAM_POINTER, dpt=DpSelect)
617
+
618
+ @property
619
+ def _current_profile_name(self) -> ClimateProfile | None:
620
+ """Return a profile index by name."""
621
+ inv_profiles = {v: k for k, v in self._profiles.items()}
622
+ sp = str(self._dp_week_program_pointer.value)
623
+ idx = int(sp) if sp.isnumeric() else _HM_WEEK_PROFILE_POINTERS_TO_IDX.get(sp)
624
+ return inv_profiles.get(idx) if idx is not None else None
625
+
626
+ @property
627
+ def _profile_names(self) -> tuple[ClimateProfile, ...]:
628
+ """Return a collection of profile names."""
629
+ return tuple(self._profiles.keys())
630
+
631
+ @property
632
+ def _profiles(self) -> Mapping[ClimateProfile, int]:
633
+ """Return the profile groups."""
634
+ profiles: dict[ClimateProfile, int] = {}
635
+ if self._dp_week_program_pointer.min is not None and self._dp_week_program_pointer.max is not None:
636
+ for i in range(int(self._dp_week_program_pointer.min) + 1, int(self._dp_week_program_pointer.max) + 2):
637
+ profiles[ClimateProfile(f"{PROFILE_PREFIX}{i}")] = i - 1
638
+
639
+ return profiles
640
+
641
+ @state_property
642
+ def activity(self) -> ClimateActivity | None:
643
+ """Return the current activity."""
644
+ if self._dp_valve_state.value is None:
645
+ return None
646
+ if self.mode == ClimateMode.OFF:
647
+ return ClimateActivity.OFF
648
+ if self._dp_valve_state.value and self._dp_valve_state.value > 0:
649
+ return ClimateActivity.HEAT
650
+ return ClimateActivity.IDLE
651
+
652
+ @state_property
653
+ def mode(self) -> ClimateMode:
654
+ """Return current operation mode."""
655
+ if self.target_temperature and self.target_temperature <= _OFF_TEMPERATURE:
656
+ return ClimateMode.OFF
657
+ if self._dp_control_mode.value == _ModeHm.MANU:
658
+ return ClimateMode.HEAT
659
+ return ClimateMode.AUTO
660
+
661
+ @state_property
662
+ def modes(self) -> tuple[ClimateMode, ...]:
663
+ """Return the available operation modes."""
664
+ return (ClimateMode.AUTO, ClimateMode.HEAT, ClimateMode.OFF)
665
+
666
+ @state_property
667
+ def profile(self) -> ClimateProfile:
668
+ """Return the current profile."""
669
+ if self._dp_control_mode.value is None:
670
+ return ClimateProfile.NONE
671
+ if self._dp_control_mode.value == _ModeHm.BOOST:
672
+ return ClimateProfile.BOOST
673
+ if self._dp_control_mode.value == _ModeHm.AWAY:
674
+ return ClimateProfile.AWAY
675
+ if self.mode == ClimateMode.AUTO:
676
+ return self._current_profile_name if self._current_profile_name else ClimateProfile.NONE
677
+ return ClimateProfile.NONE
678
+
679
+ @state_property
680
+ def profiles(self) -> tuple[ClimateProfile, ...]:
681
+ """Return available profile."""
682
+ control_modes = [ClimateProfile.BOOST, ClimateProfile.COMFORT, ClimateProfile.ECO, ClimateProfile.NONE]
683
+ if self.mode == ClimateMode.AUTO:
684
+ control_modes.extend(self._profile_names)
685
+ return tuple(control_modes)
686
+
687
+ @state_property
688
+ def temperature_offset(self) -> str | None:
689
+ """Return the maximum temperature."""
690
+ val = self._dp_temperature_offset.value
691
+ return val if isinstance(val, str) else None
692
+
693
+ @inspector
694
+ async def disable_away_mode(self) -> None:
695
+ """Disable the away mode on thermostat."""
696
+ start = datetime.now() - timedelta(hours=11)
697
+ end = datetime.now() - timedelta(hours=10)
698
+
699
+ await self._client.set_value(
700
+ channel_address=self._channel.address,
701
+ paramset_key=ParamsetKey.VALUES,
702
+ parameter=Parameter.PARTY_MODE_SUBMIT,
703
+ value=_party_mode_code(start=start, end=end, away_temperature=12.0),
704
+ )
705
+
706
+ @inspector
707
+ async def enable_away_mode_by_calendar(self, *, start: datetime, end: datetime, away_temperature: float) -> None:
708
+ """Enable the away mode by calendar on thermostat."""
709
+ await self._client.set_value(
710
+ channel_address=self._channel.address,
711
+ paramset_key=ParamsetKey.VALUES,
712
+ parameter=Parameter.PARTY_MODE_SUBMIT,
713
+ value=_party_mode_code(start=start, end=end, away_temperature=away_temperature),
714
+ )
715
+
716
+ @inspector
717
+ async def enable_away_mode_by_duration(self, *, hours: int, away_temperature: float) -> None:
718
+ """Enable the away mode by duration on thermostat."""
719
+ start = datetime.now() - timedelta(minutes=10)
720
+ end = datetime.now() + timedelta(hours=hours)
721
+ await self.enable_away_mode_by_calendar(start=start, end=end, away_temperature=away_temperature)
722
+
723
+ @bind_collector
724
+ async def set_mode(self, *, mode: ClimateMode, collector: CallParameterCollector | None = None) -> None:
725
+ """Set new mode."""
726
+ if not self.is_state_change(mode=mode):
727
+ return
728
+ if mode == ClimateMode.AUTO:
729
+ await self._dp_auto_mode.send_value(value=True, collector=collector)
730
+ elif mode == ClimateMode.HEAT:
731
+ await self._dp_manu_mode.send_value(value=self._temperature_for_heat_mode, collector=collector)
732
+ elif mode == ClimateMode.OFF:
733
+ await self._dp_manu_mode.send_value(value=self.target_temperature, collector=collector)
734
+ # Disable validation here to allow setting a value,
735
+ # that is out of the validation range.
736
+ await self.set_temperature(temperature=_OFF_TEMPERATURE, collector=collector, do_validate=False)
737
+
738
+ @bind_collector
739
+ async def set_profile(self, *, profile: ClimateProfile, collector: CallParameterCollector | None = None) -> None:
740
+ """Set new profile."""
741
+ if not self.is_state_change(profile=profile):
742
+ return
743
+ if profile == ClimateProfile.BOOST:
744
+ await self._dp_boost_mode.send_value(value=True, collector=collector)
745
+ elif profile == ClimateProfile.COMFORT:
746
+ await self._dp_comfort_mode.send_value(value=True, collector=collector)
747
+ elif profile == ClimateProfile.ECO:
748
+ await self._dp_lowering_mode.send_value(value=True, collector=collector)
749
+ elif profile in self._profile_names:
750
+ if self.mode != ClimateMode.AUTO:
751
+ await self.set_mode(mode=ClimateMode.AUTO, collector=collector)
752
+ await self._dp_boost_mode.send_value(value=False, collector=collector)
753
+ if (profile_idx := self._profiles.get(profile)) is not None:
754
+ await self._dp_week_program_pointer.send_value(
755
+ value=_HM_WEEK_PROFILE_POINTERS_TO_NAMES[profile_idx], collector=collector
756
+ )
757
+
758
+ def _compute_capabilities(self) -> ClimateCapabilities:
759
+ """Compute static capabilities. RF thermostats support profiles."""
760
+ return IP_THERMOSTAT_CAPABILITIES
761
+
762
+ def _manu_temp_changed(
763
+ self, *, data_point: GenericDataPointProtocolAny | None = None, custom_id: str | None = None
764
+ ) -> None:
765
+ """Handle device state changes."""
766
+ if (
767
+ data_point == self._dp_control_mode
768
+ and self.mode == ClimateMode.HEAT
769
+ and self._dp_setpoint.refreshed_recently
770
+ ):
771
+ self._old_manu_setpoint = self.target_temperature
772
+
773
+ if (
774
+ data_point == self._dp_setpoint
775
+ and self.mode == ClimateMode.HEAT
776
+ and self._dp_control_mode.refreshed_recently
777
+ ):
778
+ self._old_manu_setpoint = self.target_temperature
779
+
780
+ def _post_init(self) -> None:
781
+ """Post action after initialisation of the data point fields."""
782
+ super()._post_init()
783
+
784
+ # subscribe to control_mode updates to track manual target temp
785
+ self._unsubscribe_callbacks.append(
786
+ self._dp_control_mode.subscribe_to_data_point_updated(
787
+ handler=self._manu_temp_changed, custom_id=InternalCustomID.MANU_TEMP
788
+ )
789
+ )
790
+
791
+
792
+ def _party_mode_code(*, start: datetime, end: datetime, away_temperature: float) -> str:
793
+ """
794
+ Create the party mode code.
795
+
796
+ e.g. 21.5,1200,20,10,16,1380,20,10,16
797
+ away_temperature,start_minutes_of_day, day(2), month(2), year(2), end_minutes_of_day, day(2), month(2), year(2)
798
+ """
799
+ return f"{away_temperature:.1f},{start.hour * 60 + start.minute},{start.strftime('%d,%m,%y')},{end.hour * 60 + end.minute},{end.strftime('%d,%m,%y')}"
800
+
801
+
802
+ class CustomDpIpThermostat(BaseCustomDpClimate):
803
+ """HomematicIP thermostat like HmIP-BWTH, HmIP-eTRV-X."""
804
+
805
+ __slots__ = () # Required to prevent __dict__ creation (descriptors are class-level)
806
+
807
+ # Declarative data point field definitions
808
+ _dp_active_profile: Final = DataPointField(field=Field.ACTIVE_PROFILE, dpt=DpInteger)
809
+ _dp_boost_mode: Final = DataPointField(field=Field.BOOST_MODE, dpt=DpSwitch)
810
+ _dp_control_mode: Final = DataPointField(field=Field.CONTROL_MODE, dpt=DpAction)
811
+ _dp_heating_mode: Final = DataPointField(field=Field.HEATING_COOLING, dpt=DpSelect)
812
+ _dp_heating_valve_type: Final = DataPointField(field=Field.HEATING_VALVE_TYPE, dpt=DpSelect)
813
+ _dp_level: Final = DataPointField(field=Field.LEVEL, dpt=DpFloat)
814
+ _dp_optimum_start_stop: Final = DataPointField(field=Field.OPTIMUM_START_STOP, dpt=DpSwitch)
815
+ _dp_party_mode: Final = DataPointField(field=Field.PARTY_MODE, dpt=DpBinarySensor)
816
+ _dp_set_point_mode: Final = DataPointField(field=Field.SET_POINT_MODE, dpt=DpInteger)
817
+ _dp_state: Final = DataPointField(field=Field.STATE, dpt=DpBinarySensor)
818
+ _dp_temperature_offset: Final = DataPointField(field=Field.TEMPERATURE_OFFSET, dpt=DpFloat)
819
+
820
+ optimum_start_stop: Final = DelegatedProperty[bool | None](path="_dp_optimum_start_stop.value")
821
+ temperature_offset: Final = DelegatedProperty[float | None](path="_dp_temperature_offset.value", kind=Kind.STATE)
822
+
823
+ @property
824
+ def _current_profile_name(self) -> ClimateProfile | None:
825
+ """Return a profile index by name."""
826
+ inv_profiles = {v: k for k, v in self._profiles.items()}
827
+ if self._dp_active_profile.value is not None:
828
+ return inv_profiles.get(int(self._dp_active_profile.value))
829
+ return None
830
+
831
+ @property
832
+ def _is_heating_mode(self) -> bool:
833
+ """Return the heating_mode of the device."""
834
+ val = self._dp_heating_mode.value
835
+ return True if val is None else str(val) == "HEATING"
836
+
837
+ @property
838
+ def _profile_names(self) -> tuple[ClimateProfile, ...]:
839
+ """Return a collection of profile names."""
840
+ return tuple(self._profiles.keys())
841
+
842
+ @property
843
+ def _profiles(self) -> Mapping[ClimateProfile, int]:
844
+ """Return the profile groups."""
845
+ profiles: dict[ClimateProfile, int] = {}
846
+ if self._dp_active_profile.min and self._dp_active_profile.max:
847
+ for i in range(self._dp_active_profile.min, self._dp_active_profile.max + 1):
848
+ profiles[ClimateProfile(f"{PROFILE_PREFIX}{i}")] = i
849
+
850
+ return profiles
851
+
852
+ @property
853
+ def schedule_profile_nos(self) -> int:
854
+ """Return the number of supported profiles."""
855
+ return len(self._profiles)
856
+
857
+ @state_property
858
+ def activity(self) -> ClimateActivity | None:
859
+ """
860
+ Return the current activity.
861
+
862
+ The preferred sources for determining the activity are this channel's `LEVEL` and `STATE` data points.
863
+ Some devices don't expose one or both; in that case we try to use the same data points from the linked peer channels instead.
864
+ """
865
+ # Determine effective data point values for LEVEL and STATE.
866
+ level_dp = self._dp_level if self._dp_level.is_hmtype else None
867
+ state_dp = self._dp_state if self._dp_state.is_hmtype else None
868
+
869
+ eff_level = None
870
+ eff_state = None
871
+
872
+ # Use own DP values as-is when available to preserve legacy behavior.
873
+ if level_dp is not None and level_dp.value is not None:
874
+ eff_level = level_dp.value
875
+ elif self._peer_level_dp is not None and self._peer_level_dp.value is not None:
876
+ eff_level = self._peer_level_dp.value
877
+
878
+ if state_dp is not None and state_dp.value is not None:
879
+ eff_state = state_dp.value
880
+ elif self._peer_state_dp is not None and self._peer_state_dp.value is not None:
881
+ eff_state = self._peer_state_dp.value
882
+
883
+ if eff_state is None and eff_level is None:
884
+ return None
885
+ if self.mode == ClimateMode.OFF:
886
+ return ClimateActivity.OFF
887
+ if eff_level is not None and eff_level > _CLOSED_LEVEL:
888
+ return ClimateActivity.HEAT
889
+ valve = self._dp_heating_valve_type.value
890
+ # Determine heating/cooling based on valve type and state
891
+ is_active = False
892
+ if eff_state is True:
893
+ # Valve open means active when NC or valve type unknown
894
+ is_active = valve is None or valve == ClimateHeatingValveType.NORMALLY_CLOSE
895
+ elif eff_state is False:
896
+ # Valve closed means active for NO type
897
+ is_active = valve == ClimateHeatingValveType.NORMALLY_OPEN
898
+ if is_active:
899
+ return ClimateActivity.HEAT if self._is_heating_mode else ClimateActivity.COOL
900
+ return ClimateActivity.IDLE
901
+
902
+ @state_property
903
+ def mode(self) -> ClimateMode:
904
+ """Return current operation mode."""
905
+ if self.target_temperature and self.target_temperature <= _OFF_TEMPERATURE:
906
+ return ClimateMode.OFF
907
+ if self._dp_set_point_mode.value == _ModeHmIP.MANU:
908
+ return ClimateMode.HEAT if self._is_heating_mode else ClimateMode.COOL
909
+ if self._dp_set_point_mode.value == _ModeHmIP.AUTO:
910
+ return ClimateMode.AUTO
911
+ return ClimateMode.AUTO
912
+
913
+ @state_property
914
+ def modes(self) -> tuple[ClimateMode, ...]:
915
+ """Return the available operation modes."""
916
+ return (
917
+ ClimateMode.AUTO,
918
+ ClimateMode.HEAT if self._is_heating_mode else ClimateMode.COOL,
919
+ ClimateMode.OFF,
920
+ )
921
+
922
+ @state_property
923
+ def profile(self) -> ClimateProfile:
924
+ """Return the current control mode."""
925
+ if self._dp_boost_mode.value:
926
+ return ClimateProfile.BOOST
927
+ if self._dp_set_point_mode.value == _ModeHmIP.AWAY:
928
+ return ClimateProfile.AWAY
929
+ if self.mode == ClimateMode.AUTO:
930
+ return self._current_profile_name if self._current_profile_name else ClimateProfile.NONE
931
+ return ClimateProfile.NONE
932
+
933
+ @state_property
934
+ def profiles(self) -> tuple[ClimateProfile, ...]:
935
+ """Return available control modes."""
936
+ control_modes = [ClimateProfile.BOOST, ClimateProfile.NONE]
937
+ if self.mode == ClimateMode.AUTO:
938
+ control_modes.extend(self._profile_names)
939
+ return tuple(control_modes)
940
+
941
+ @inspector
942
+ async def disable_away_mode(self) -> None:
943
+ """Disable the away mode on thermostat."""
944
+ await self._client.put_paramset(
945
+ channel_address=self._channel.address,
946
+ paramset_key_or_link_address=ParamsetKey.VALUES,
947
+ values={
948
+ Parameter.SET_POINT_MODE: _ModeHmIP.AWAY,
949
+ Parameter.PARTY_TIME_START: _PARTY_INIT_DATE,
950
+ Parameter.PARTY_TIME_END: _PARTY_INIT_DATE,
951
+ },
952
+ )
953
+
954
+ @inspector
955
+ async def enable_away_mode_by_calendar(self, *, start: datetime, end: datetime, away_temperature: float) -> None:
956
+ """Enable the away mode by calendar on thermostat."""
957
+ await self._client.put_paramset(
958
+ channel_address=self._channel.address,
959
+ paramset_key_or_link_address=ParamsetKey.VALUES,
960
+ values={
961
+ Parameter.SET_POINT_MODE: _ModeHmIP.AWAY,
962
+ Parameter.SET_POINT_TEMPERATURE: away_temperature,
963
+ Parameter.PARTY_TIME_START: start.strftime(_PARTY_DATE_FORMAT),
964
+ Parameter.PARTY_TIME_END: end.strftime(_PARTY_DATE_FORMAT),
965
+ },
966
+ )
967
+
968
+ @inspector
969
+ async def enable_away_mode_by_duration(self, *, hours: int, away_temperature: float) -> None:
970
+ """Enable the away mode by duration on thermostat."""
971
+ start = datetime.now() - timedelta(minutes=10)
972
+ end = datetime.now() + timedelta(hours=hours)
973
+ await self.enable_away_mode_by_calendar(start=start, end=end, away_temperature=away_temperature)
974
+
975
+ @bind_collector
976
+ async def set_mode(self, *, mode: ClimateMode, collector: CallParameterCollector | None = None) -> None:
977
+ """Set new target mode."""
978
+ if not self.is_state_change(mode=mode):
979
+ return
980
+ # if switching mode then disable boost_mode
981
+ if self._dp_boost_mode.value:
982
+ await self.set_profile(profile=ClimateProfile.NONE, collector=collector)
983
+
984
+ if mode == ClimateMode.AUTO:
985
+ await self._dp_control_mode.send_value(value=_ModeHmIP.AUTO, collector=collector)
986
+ elif mode in (ClimateMode.HEAT, ClimateMode.COOL):
987
+ await self._dp_control_mode.send_value(value=_ModeHmIP.MANU, collector=collector)
988
+ await self.set_temperature(temperature=self._temperature_for_heat_mode, collector=collector)
989
+ elif mode == ClimateMode.OFF:
990
+ await self._dp_control_mode.send_value(value=_ModeHmIP.MANU, collector=collector)
991
+ await self.set_temperature(temperature=_OFF_TEMPERATURE, collector=collector, do_validate=False)
992
+
993
+ @bind_collector
994
+ async def set_profile(self, *, profile: ClimateProfile, collector: CallParameterCollector | None = None) -> None:
995
+ """Set new control mode."""
996
+ if not self.is_state_change(profile=profile):
997
+ return
998
+ if profile == ClimateProfile.BOOST:
999
+ await self._dp_boost_mode.send_value(value=True, collector=collector)
1000
+ elif profile == ClimateProfile.NONE:
1001
+ await self._dp_boost_mode.send_value(value=False, collector=collector)
1002
+ elif profile in self._profile_names:
1003
+ if self.mode != ClimateMode.AUTO:
1004
+ await self.set_mode(mode=ClimateMode.AUTO, collector=collector)
1005
+ await self._dp_boost_mode.send_value(value=False, collector=collector)
1006
+ if profile_idx := self._profiles.get(profile):
1007
+ await self._dp_active_profile.send_value(value=profile_idx, collector=collector)
1008
+
1009
+ def _compute_capabilities(self) -> ClimateCapabilities:
1010
+ """Compute static capabilities. IP thermostats support profiles."""
1011
+ return IP_THERMOSTAT_CAPABILITIES
1012
+
1013
+ def _manu_temp_changed(
1014
+ self, *, data_point: GenericDataPointProtocolAny | None = None, custom_id: str | None = None
1015
+ ) -> None:
1016
+ """Handle device state changes."""
1017
+ if (
1018
+ data_point == self._dp_set_point_mode
1019
+ and self.mode == ClimateMode.HEAT
1020
+ and self._dp_setpoint.refreshed_recently
1021
+ ):
1022
+ self._old_manu_setpoint = self.target_temperature
1023
+
1024
+ if (
1025
+ data_point == self._dp_setpoint
1026
+ and self.mode == ClimateMode.HEAT
1027
+ and self._dp_set_point_mode.refreshed_recently
1028
+ ):
1029
+ self._old_manu_setpoint = self.target_temperature
1030
+
1031
+ def _post_init(self) -> None:
1032
+ """Post action after initialisation of the data point fields."""
1033
+ super()._post_init()
1034
+
1035
+ # subscribe to set_point_mode updates to track manual target temp
1036
+ self._unsubscribe_callbacks.append(
1037
+ self._dp_set_point_mode.subscribe_to_data_point_updated(
1038
+ handler=self._manu_temp_changed, custom_id=InternalCustomID.MANU_TEMP
1039
+ )
1040
+ )
1041
+
1042
+
1043
+ # =============================================================================
1044
+ # DeviceProfileRegistry Registration
1045
+ # =============================================================================
1046
+
1047
+ # Simple RF Thermostat
1048
+ DeviceProfileRegistry.register(
1049
+ category=DataPointCategory.CLIMATE,
1050
+ models=("HM-CC-TC", "ZEL STG RM FWT"),
1051
+ data_point_class=CustomDpSimpleRfThermostat,
1052
+ profile_type=DeviceProfile.SIMPLE_RF_THERMOSTAT,
1053
+ )
1054
+
1055
+ # RF Thermostat
1056
+ DeviceProfileRegistry.register(
1057
+ category=DataPointCategory.CLIMATE,
1058
+ models=("BC-RT-TRX-CyG", "BC-RT-TRX-CyN", "BC-TC-C-WM"),
1059
+ data_point_class=CustomDpRfThermostat,
1060
+ profile_type=DeviceProfile.RF_THERMOSTAT,
1061
+ )
1062
+ DeviceProfileRegistry.register(
1063
+ category=DataPointCategory.CLIMATE,
1064
+ models="HM-CC-RT-DN",
1065
+ data_point_class=CustomDpRfThermostat,
1066
+ profile_type=DeviceProfile.RF_THERMOSTAT,
1067
+ channels=(4,),
1068
+ )
1069
+ DeviceProfileRegistry.register(
1070
+ category=DataPointCategory.CLIMATE,
1071
+ models="HM-TC-IT-WM-W-EU",
1072
+ data_point_class=CustomDpRfThermostat,
1073
+ profile_type=DeviceProfile.RF_THERMOSTAT,
1074
+ channels=(2,),
1075
+ schedule_channel_no=BIDCOS_DEVICE_CHANNEL_DUMMY,
1076
+ )
1077
+
1078
+ # RF Thermostat Group
1079
+ DeviceProfileRegistry.register(
1080
+ category=DataPointCategory.CLIMATE,
1081
+ models="HM-CC-VG-1",
1082
+ data_point_class=CustomDpRfThermostat,
1083
+ profile_type=DeviceProfile.RF_THERMOSTAT_GROUP,
1084
+ )
1085
+
1086
+ # IP Thermostat
1087
+ DeviceProfileRegistry.register(
1088
+ category=DataPointCategory.CLIMATE,
1089
+ models=(
1090
+ "ALPHA-IP-RBG",
1091
+ "Thermostat AA",
1092
+ ),
1093
+ data_point_class=CustomDpIpThermostat,
1094
+ profile_type=DeviceProfile.IP_THERMOSTAT,
1095
+ )
1096
+ DeviceProfileRegistry.register(
1097
+ category=DataPointCategory.CLIMATE,
1098
+ models=(
1099
+ "HmIP-BWTH",
1100
+ "HmIP-STH",
1101
+ "HmIP-WTH",
1102
+ "HmIP-eTRV",
1103
+ "HmIPW-SCTHD",
1104
+ "HmIPW-STH",
1105
+ "HmIPW-WTH",
1106
+ ),
1107
+ data_point_class=CustomDpIpThermostat,
1108
+ profile_type=DeviceProfile.IP_THERMOSTAT,
1109
+ schedule_channel_no=1,
1110
+ )
1111
+ DeviceProfileRegistry.register(
1112
+ category=DataPointCategory.CLIMATE,
1113
+ models="HmIP-WGT",
1114
+ data_point_class=CustomDpIpThermostat,
1115
+ profile_type=DeviceProfile.IP_THERMOSTAT,
1116
+ channels=(8,),
1117
+ schedule_channel_no=1,
1118
+ )
1119
+
1120
+ # IP Thermostat Group
1121
+ DeviceProfileRegistry.register(
1122
+ category=DataPointCategory.CLIMATE,
1123
+ models="HmIP-HEATING",
1124
+ data_point_class=CustomDpIpThermostat,
1125
+ profile_type=DeviceProfile.IP_THERMOSTAT_GROUP,
1126
+ schedule_channel_no=1,
1127
+ )
1128
+
1129
+ # Blacklist
1130
+ DeviceProfileRegistry.blacklist("HmIP-STHO")