aiohomematic 2025.11.3__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (77) hide show
  1. aiohomematic/__init__.py +61 -0
  2. aiohomematic/async_support.py +212 -0
  3. aiohomematic/central/__init__.py +2309 -0
  4. aiohomematic/central/decorators.py +155 -0
  5. aiohomematic/central/rpc_server.py +295 -0
  6. aiohomematic/client/__init__.py +1848 -0
  7. aiohomematic/client/_rpc_errors.py +81 -0
  8. aiohomematic/client/json_rpc.py +1326 -0
  9. aiohomematic/client/rpc_proxy.py +311 -0
  10. aiohomematic/const.py +1127 -0
  11. aiohomematic/context.py +18 -0
  12. aiohomematic/converter.py +108 -0
  13. aiohomematic/decorators.py +302 -0
  14. aiohomematic/exceptions.py +164 -0
  15. aiohomematic/hmcli.py +186 -0
  16. aiohomematic/model/__init__.py +140 -0
  17. aiohomematic/model/calculated/__init__.py +84 -0
  18. aiohomematic/model/calculated/climate.py +290 -0
  19. aiohomematic/model/calculated/data_point.py +327 -0
  20. aiohomematic/model/calculated/operating_voltage_level.py +299 -0
  21. aiohomematic/model/calculated/support.py +234 -0
  22. aiohomematic/model/custom/__init__.py +177 -0
  23. aiohomematic/model/custom/climate.py +1532 -0
  24. aiohomematic/model/custom/cover.py +792 -0
  25. aiohomematic/model/custom/data_point.py +334 -0
  26. aiohomematic/model/custom/definition.py +871 -0
  27. aiohomematic/model/custom/light.py +1128 -0
  28. aiohomematic/model/custom/lock.py +394 -0
  29. aiohomematic/model/custom/siren.py +275 -0
  30. aiohomematic/model/custom/support.py +41 -0
  31. aiohomematic/model/custom/switch.py +175 -0
  32. aiohomematic/model/custom/valve.py +114 -0
  33. aiohomematic/model/data_point.py +1123 -0
  34. aiohomematic/model/device.py +1445 -0
  35. aiohomematic/model/event.py +208 -0
  36. aiohomematic/model/generic/__init__.py +217 -0
  37. aiohomematic/model/generic/action.py +34 -0
  38. aiohomematic/model/generic/binary_sensor.py +30 -0
  39. aiohomematic/model/generic/button.py +27 -0
  40. aiohomematic/model/generic/data_point.py +171 -0
  41. aiohomematic/model/generic/dummy.py +147 -0
  42. aiohomematic/model/generic/number.py +76 -0
  43. aiohomematic/model/generic/select.py +39 -0
  44. aiohomematic/model/generic/sensor.py +74 -0
  45. aiohomematic/model/generic/switch.py +54 -0
  46. aiohomematic/model/generic/text.py +29 -0
  47. aiohomematic/model/hub/__init__.py +333 -0
  48. aiohomematic/model/hub/binary_sensor.py +24 -0
  49. aiohomematic/model/hub/button.py +28 -0
  50. aiohomematic/model/hub/data_point.py +340 -0
  51. aiohomematic/model/hub/number.py +39 -0
  52. aiohomematic/model/hub/select.py +49 -0
  53. aiohomematic/model/hub/sensor.py +37 -0
  54. aiohomematic/model/hub/switch.py +44 -0
  55. aiohomematic/model/hub/text.py +30 -0
  56. aiohomematic/model/support.py +586 -0
  57. aiohomematic/model/update.py +143 -0
  58. aiohomematic/property_decorators.py +496 -0
  59. aiohomematic/py.typed +0 -0
  60. aiohomematic/rega_scripts/fetch_all_device_data.fn +92 -0
  61. aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
  62. aiohomematic/rega_scripts/get_serial.fn +44 -0
  63. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
  64. aiohomematic/rega_scripts/set_program_state.fn +12 -0
  65. aiohomematic/rega_scripts/set_system_variable.fn +15 -0
  66. aiohomematic/store/__init__.py +34 -0
  67. aiohomematic/store/dynamic.py +551 -0
  68. aiohomematic/store/persistent.py +988 -0
  69. aiohomematic/store/visibility.py +812 -0
  70. aiohomematic/support.py +664 -0
  71. aiohomematic/validator.py +112 -0
  72. aiohomematic-2025.11.3.dist-info/METADATA +144 -0
  73. aiohomematic-2025.11.3.dist-info/RECORD +77 -0
  74. aiohomematic-2025.11.3.dist-info/WHEEL +5 -0
  75. aiohomematic-2025.11.3.dist-info/entry_points.txt +2 -0
  76. aiohomematic-2025.11.3.dist-info/licenses/LICENSE +21 -0
  77. aiohomematic-2025.11.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1532 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025
3
+ """Module for data points implemented using the climate category."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import abstractmethod
8
+ from collections.abc import Mapping
9
+ import contextlib
10
+ from datetime import datetime, timedelta
11
+ from enum import IntEnum, StrEnum
12
+ import logging
13
+ from typing import Any, Final, cast
14
+
15
+ from aiohomematic.const import (
16
+ CALLBACK_TYPE,
17
+ SCHEDULER_PROFILE_PATTERN,
18
+ SCHEDULER_TIME_PATTERN,
19
+ DataPointCategory,
20
+ DeviceProfile,
21
+ Field,
22
+ InternalCustomID,
23
+ OptionalSettings,
24
+ Parameter,
25
+ ParamsetKey,
26
+ ProductGroup,
27
+ )
28
+ from aiohomematic.decorators import inspector
29
+ from aiohomematic.exceptions import ClientException, ValidationException
30
+ from aiohomematic.model import device as hmd
31
+ from aiohomematic.model.custom import definition as hmed
32
+ from aiohomematic.model.custom.data_point import CustomDataPoint
33
+ from aiohomematic.model.custom.support import CustomConfig
34
+ from aiohomematic.model.data_point import CallParameterCollector, bind_collector
35
+ from aiohomematic.model.generic import (
36
+ DpAction,
37
+ DpBinarySensor,
38
+ DpFloat,
39
+ DpInteger,
40
+ DpSelect,
41
+ DpSensor,
42
+ DpSwitch,
43
+ GenericDataPoint,
44
+ )
45
+ from aiohomematic.property_decorators import config_property, state_property
46
+
47
+ _LOGGER: Final = logging.getLogger(__name__)
48
+
49
+ _CLOSED_LEVEL: Final = 0.0
50
+ _DEFAULT_TEMPERATURE_STEP: Final = 0.5
51
+ _MAX_SCHEDULER_TIME: Final = "24:00"
52
+ _MIN_SCHEDULER_TIME: Final = "00:00"
53
+ _OFF_TEMPERATURE: Final = 4.5
54
+ _PARTY_DATE_FORMAT: Final = "%Y_%m_%d %H:%M"
55
+ _PARTY_INIT_DATE: Final = "2000_01_01 00:00"
56
+ _RAW_SCHEDULE_DICT = dict[str, float | int]
57
+ _TEMP_CELSIUS: Final = "°C"
58
+ PROFILE_PREFIX: Final = "week_program_"
59
+ SCHEDULE_SLOT_RANGE: Final = range(1, 13)
60
+ SCHEDULE_SLOT_IN_RANGE: Final = range(1, 14)
61
+ SCHEDULE_TIME_RANGE: Final = range(1441)
62
+
63
+
64
+ class _ModeHm(StrEnum):
65
+ """Enum with the HM modes."""
66
+
67
+ AUTO = "AUTO-MODE" # 0
68
+ AWAY = "PARTY-MODE" # 2
69
+ BOOST = "BOOST-MODE" # 3
70
+ MANU = "MANU-MODE" # 1
71
+
72
+
73
+ class _ModeHmIP(IntEnum):
74
+ """Enum with the HmIP modes."""
75
+
76
+ AUTO = 0
77
+ AWAY = 2
78
+ MANU = 1
79
+
80
+
81
+ class _StateChangeArg(StrEnum):
82
+ """Enum with climate state change arguments."""
83
+
84
+ MODE = "mode"
85
+ PROFILE = "profile"
86
+ TEMPERATURE = "temperature"
87
+
88
+
89
+ class ClimateActivity(StrEnum):
90
+ """Enum with the climate activities."""
91
+
92
+ COOL = "cooling"
93
+ HEAT = "heating"
94
+ IDLE = "idle"
95
+ OFF = "off"
96
+
97
+
98
+ class ClimateHeatingValveType(StrEnum):
99
+ """Enum with the climate heating valve types."""
100
+
101
+ NORMALLY_CLOSE = "NORMALLY_CLOSE"
102
+ NORMALLY_OPEN = "NORMALLY_OPEN"
103
+
104
+
105
+ class ClimateMode(StrEnum):
106
+ """Enum with the thermostat modes."""
107
+
108
+ AUTO = "auto"
109
+ COOL = "cool"
110
+ HEAT = "heat"
111
+ OFF = "off"
112
+
113
+
114
+ class ClimateProfile(StrEnum):
115
+ """Enum with profiles."""
116
+
117
+ AWAY = "away"
118
+ BOOST = "boost"
119
+ COMFORT = "comfort"
120
+ ECO = "eco"
121
+ NONE = "none"
122
+ WEEK_PROGRAM_1 = "week_program_1"
123
+ WEEK_PROGRAM_2 = "week_program_2"
124
+ WEEK_PROGRAM_3 = "week_program_3"
125
+ WEEK_PROGRAM_4 = "week_program_4"
126
+ WEEK_PROGRAM_5 = "week_program_5"
127
+ WEEK_PROGRAM_6 = "week_program_6"
128
+
129
+
130
+ _HM_WEEK_PROFILE_POINTERS_TO_NAMES: Final = {
131
+ 0: "WEEK PROGRAM 1",
132
+ 1: "WEEK PROGRAM 2",
133
+ 2: "WEEK PROGRAM 3",
134
+ 3: "WEEK PROGRAM 4",
135
+ 4: "WEEK PROGRAM 5",
136
+ 5: "WEEK PROGRAM 6",
137
+ }
138
+ _HM_WEEK_PROFILE_POINTERS_TO_IDX: Final = {v: k for k, v in _HM_WEEK_PROFILE_POINTERS_TO_NAMES.items()}
139
+
140
+
141
+ class ScheduleSlotType(StrEnum):
142
+ """Enum for climate item type."""
143
+
144
+ ENDTIME = "ENDTIME"
145
+ STARTTIME = "STARTTIME"
146
+ TEMPERATURE = "TEMPERATURE"
147
+
148
+
149
+ RELEVANT_SLOT_TYPES: Final = (ScheduleSlotType.ENDTIME, ScheduleSlotType.TEMPERATURE)
150
+
151
+
152
+ class ScheduleProfile(StrEnum):
153
+ """Enum for climate profiles."""
154
+
155
+ P1 = "P1"
156
+ P2 = "P2"
157
+ P3 = "P3"
158
+ P4 = "P4"
159
+ P5 = "P5"
160
+ P6 = "P6"
161
+
162
+
163
+ class ScheduleWeekday(StrEnum):
164
+ """Enum for climate week days."""
165
+
166
+ MONDAY = "MONDAY"
167
+ TUESDAY = "TUESDAY"
168
+ WEDNESDAY = "WEDNESDAY"
169
+ THURSDAY = "THURSDAY"
170
+ FRIDAY = "FRIDAY"
171
+ SATURDAY = "SATURDAY"
172
+ SUNDAY = "SUNDAY"
173
+
174
+
175
+ SIMPLE_WEEKDAY_LIST = list[dict[ScheduleSlotType, str | float]]
176
+ SIMPLE_PROFILE_DICT = dict[ScheduleWeekday, SIMPLE_WEEKDAY_LIST]
177
+ WEEKDAY_DICT = dict[int, dict[ScheduleSlotType, str | float]]
178
+ PROFILE_DICT = dict[ScheduleWeekday, WEEKDAY_DICT]
179
+ _SCHEDULE_DICT = dict[ScheduleProfile, PROFILE_DICT]
180
+
181
+
182
+ class BaseCustomDpClimate(CustomDataPoint):
183
+ """Base Homematic climate data_point."""
184
+
185
+ __slots__ = (
186
+ "_dp_humidity",
187
+ "_dp_min_max_value_not_relevant_for_manu_mode",
188
+ "_dp_setpoint",
189
+ "_dp_temperature",
190
+ "_dp_temperature_maximum",
191
+ "_dp_temperature_minimum",
192
+ "_old_manu_setpoint",
193
+ "_supports_schedule",
194
+ )
195
+ _category = DataPointCategory.CLIMATE
196
+
197
+ def __init__(
198
+ self,
199
+ *,
200
+ channel: hmd.Channel,
201
+ unique_id: str,
202
+ device_profile: DeviceProfile,
203
+ device_def: Mapping[str, Any],
204
+ custom_data_point_def: Mapping[int | tuple[int, ...], tuple[str, ...]],
205
+ group_no: int,
206
+ custom_config: CustomConfig,
207
+ ) -> None:
208
+ """Initialize base climate data_point."""
209
+ super().__init__(
210
+ channel=channel,
211
+ unique_id=unique_id,
212
+ device_profile=device_profile,
213
+ device_def=device_def,
214
+ custom_data_point_def=custom_data_point_def,
215
+ group_no=group_no,
216
+ custom_config=custom_config,
217
+ )
218
+ self._supports_schedule = False
219
+ self._old_manu_setpoint: float | None = None
220
+
221
+ def _init_data_point_fields(self) -> None:
222
+ """Init the data_point fields."""
223
+ super()._init_data_point_fields()
224
+ self._dp_humidity: DpSensor[int | None] = self._get_data_point(
225
+ field=Field.HUMIDITY, data_point_type=DpSensor[int | None]
226
+ )
227
+ self._dp_min_max_value_not_relevant_for_manu_mode: DpBinarySensor = self._get_data_point(
228
+ field=Field.MIN_MAX_VALUE_NOT_RELEVANT_FOR_MANU_MODE, data_point_type=DpBinarySensor
229
+ )
230
+ self._dp_setpoint: DpFloat = self._get_data_point(field=Field.SETPOINT, data_point_type=DpFloat)
231
+ self._dp_temperature: DpSensor[float | None] = self._get_data_point(
232
+ field=Field.TEMPERATURE, data_point_type=DpSensor[float | None]
233
+ )
234
+ self._dp_temperature_maximum: DpFloat = self._get_data_point(
235
+ field=Field.TEMPERATURE_MAXIMUM, data_point_type=DpFloat
236
+ )
237
+ self._dp_temperature_minimum: DpFloat = self._get_data_point(
238
+ field=Field.TEMPERATURE_MINIMUM, data_point_type=DpFloat
239
+ )
240
+ self._unregister_callbacks.append(
241
+ self._dp_setpoint.register_data_point_updated_callback(
242
+ cb=self._manu_temp_changed, custom_id=InternalCustomID.MANU_TEMP
243
+ )
244
+ )
245
+
246
+ @abstractmethod
247
+ def _manu_temp_changed(self, *, data_point: GenericDataPoint | None = None, **kwargs: Any) -> None:
248
+ """Handle device state changes."""
249
+
250
+ @state_property
251
+ def current_humidity(self) -> int | None:
252
+ """Return the current humidity."""
253
+ return self._dp_humidity.value
254
+
255
+ @state_property
256
+ def current_temperature(self) -> float | None:
257
+ """Return current temperature."""
258
+ return self._dp_temperature.value
259
+
260
+ @state_property
261
+ def activity(self) -> ClimateActivity | None:
262
+ """Return the current activity."""
263
+ return None
264
+
265
+ @state_property
266
+ def mode(self) -> ClimateMode:
267
+ """Return current operation mode."""
268
+ return ClimateMode.HEAT
269
+
270
+ @state_property
271
+ def modes(self) -> tuple[ClimateMode, ...]:
272
+ """Return the available operation modes."""
273
+ return (ClimateMode.HEAT,)
274
+
275
+ @state_property
276
+ def min_max_value_not_relevant_for_manu_mode(self) -> bool:
277
+ """Return the maximum temperature."""
278
+ if self._dp_min_max_value_not_relevant_for_manu_mode.value is not None:
279
+ return self._dp_min_max_value_not_relevant_for_manu_mode.value
280
+ return False
281
+
282
+ @state_property
283
+ def min_temp(self) -> float:
284
+ """Return the minimum temperature."""
285
+ if self._dp_temperature_minimum.value is not None:
286
+ min_temp = float(self._dp_temperature_minimum.value)
287
+ else:
288
+ min_temp = self._dp_setpoint.min
289
+
290
+ if min_temp == _OFF_TEMPERATURE:
291
+ return min_temp + _DEFAULT_TEMPERATURE_STEP
292
+ return min_temp
293
+
294
+ @state_property
295
+ def max_temp(self) -> float:
296
+ """Return the maximum temperature."""
297
+ if self._dp_temperature_maximum.value is not None:
298
+ return float(self._dp_temperature_maximum.value)
299
+ return cast(float, self._dp_setpoint.max)
300
+
301
+ @state_property
302
+ def profile(self) -> ClimateProfile:
303
+ """Return the current profile."""
304
+ return ClimateProfile.NONE
305
+
306
+ @state_property
307
+ def profiles(self) -> tuple[ClimateProfile, ...]:
308
+ """Return available profiles."""
309
+ return (ClimateProfile.NONE,)
310
+
311
+ @state_property
312
+ def target_temperature(self) -> float | None:
313
+ """Return target temperature."""
314
+ return self._dp_setpoint.value
315
+
316
+ @config_property
317
+ def target_temperature_step(self) -> float:
318
+ """Return the supported step of target temperature."""
319
+ return _DEFAULT_TEMPERATURE_STEP
320
+
321
+ @property
322
+ def schedule_channel_address(self) -> str:
323
+ """Return schedule channel address."""
324
+ return (
325
+ self._channel.address
326
+ if self._channel.device.product_group in (ProductGroup.HMIP, ProductGroup.HMIPW)
327
+ else self._device.address
328
+ )
329
+
330
+ @property
331
+ def supports_profiles(self) -> bool:
332
+ """Flag if climate supports profiles."""
333
+ return False
334
+
335
+ @config_property
336
+ def temperature_unit(self) -> str:
337
+ """Return temperature unit."""
338
+ return _TEMP_CELSIUS
339
+
340
+ @property
341
+ def _temperature_for_heat_mode(self) -> float:
342
+ """
343
+ Return a safe temperature to use when setting mode to HEAT.
344
+
345
+ If the current target temperature is None or represents the special OFF value,
346
+ fall back to the device's minimum valid temperature. Otherwise, return the
347
+ current target temperature clipped to the valid [min, max] range.
348
+ """
349
+ temp = self._old_manu_setpoint or self.target_temperature
350
+ # Treat None or OFF sentinel as invalid/unsafe to restore.
351
+ if temp is None or temp <= _OFF_TEMPERATURE or temp < self.min_temp:
352
+ return self.min_temp if self.min_temp > _OFF_TEMPERATURE else _OFF_TEMPERATURE + 0.5
353
+ if temp > self.max_temp:
354
+ return self.max_temp
355
+ return temp
356
+
357
+ @property
358
+ def schedule_profile_nos(self) -> int:
359
+ """Return the number of supported profiles."""
360
+ return 0
361
+
362
+ @bind_collector()
363
+ async def set_temperature(
364
+ self,
365
+ *,
366
+ temperature: float,
367
+ collector: CallParameterCollector | None = None,
368
+ do_validate: bool = True,
369
+ ) -> None:
370
+ """Set new target temperature. The temperature must be set in all cases, even if the values are identical."""
371
+ if do_validate and self.mode == ClimateMode.HEAT and self.min_max_value_not_relevant_for_manu_mode:
372
+ do_validate = False
373
+
374
+ if do_validate and not (self.min_temp <= temperature <= self.max_temp):
375
+ raise ValidationException(
376
+ f"SET_TEMPERATURE failed: Invalid temperature: {temperature} (min: {self.min_temp}, max: {self.max_temp})"
377
+ )
378
+
379
+ await self._dp_setpoint.send_value(value=temperature, collector=collector, do_validate=do_validate)
380
+
381
+ @bind_collector()
382
+ async def set_mode(self, *, mode: ClimateMode, collector: CallParameterCollector | None = None) -> None:
383
+ """Set new target mode."""
384
+
385
+ @bind_collector()
386
+ async def set_profile(self, *, profile: ClimateProfile, collector: CallParameterCollector | None = None) -> None:
387
+ """Set new profile."""
388
+
389
+ @inspector
390
+ async def enable_away_mode_by_calendar(self, *, start: datetime, end: datetime, away_temperature: float) -> None:
391
+ """Enable the away mode by calendar on thermostat."""
392
+
393
+ @inspector
394
+ async def enable_away_mode_by_duration(self, *, hours: int, away_temperature: float) -> None:
395
+ """Enable the away mode by duration on thermostat."""
396
+
397
+ @inspector
398
+ async def disable_away_mode(self) -> None:
399
+ """Disable the away mode on thermostat."""
400
+
401
+ def is_state_change(self, **kwargs: Any) -> bool:
402
+ """Check if the state changes due to kwargs."""
403
+ if (
404
+ temperature := kwargs.get(_StateChangeArg.TEMPERATURE)
405
+ ) is not None and temperature != self.target_temperature:
406
+ return True
407
+ if (mode := kwargs.get(_StateChangeArg.MODE)) is not None and mode != self.mode:
408
+ return True
409
+ if (profile := kwargs.get(_StateChangeArg.PROFILE)) is not None and profile != self.profile:
410
+ return True
411
+ return super().is_state_change(**kwargs)
412
+
413
+ @inspector
414
+ async def copy_schedule(self, *, target_climate_data_point: BaseCustomDpClimate) -> None:
415
+ """Copy schedule to target device."""
416
+
417
+ if self.schedule_profile_nos != target_climate_data_point.schedule_profile_nos:
418
+ raise ValidationException("Copy schedule profile is only: No of schedule profile must be identical")
419
+ raw_schedule = await self._get_raw_schedule()
420
+ await self._client.put_paramset(
421
+ channel_address=target_climate_data_point.schedule_channel_address,
422
+ paramset_key_or_link_address=ParamsetKey.MASTER,
423
+ values=raw_schedule,
424
+ )
425
+
426
+ @inspector
427
+ async def copy_schedule_profile(
428
+ self,
429
+ *,
430
+ source_profile: ScheduleProfile,
431
+ target_profile: ScheduleProfile,
432
+ target_climate_data_point: BaseCustomDpClimate | None = None,
433
+ ) -> None:
434
+ """Copy schedule profile to target device."""
435
+ same_device = False
436
+ if not self._supports_schedule:
437
+ raise ValidationException(f"Schedule is not supported by device {self._device.name}")
438
+ if target_climate_data_point is None:
439
+ target_climate_data_point = self
440
+ if self is target_climate_data_point:
441
+ same_device = True
442
+
443
+ if same_device and (source_profile == target_profile or (source_profile is None or target_profile is None)):
444
+ raise ValidationException(
445
+ "Copy schedule profile on same device is only possible with defined and different source/target profiles"
446
+ )
447
+
448
+ if (source_profile_data := await self.get_schedule_profile(profile=source_profile)) is None:
449
+ raise ValidationException(f"Source profile {source_profile} could not be loaded.")
450
+ await self._set_schedule_profile(
451
+ target_channel_address=target_climate_data_point.schedule_channel_address,
452
+ profile=target_profile,
453
+ profile_data=source_profile_data,
454
+ do_validate=False,
455
+ )
456
+
457
+ @inspector
458
+ async def get_schedule_profile(self, *, profile: ScheduleProfile) -> PROFILE_DICT:
459
+ """Return a schedule by climate profile."""
460
+ if not self._supports_schedule:
461
+ raise ValidationException(f"Schedule is not supported by device {self._device.name}")
462
+ schedule_data = await self._get_schedule_profile(profile=profile)
463
+ return schedule_data.get(profile, {})
464
+
465
+ @inspector
466
+ async def get_schedule_profile_weekday(self, *, profile: ScheduleProfile, weekday: ScheduleWeekday) -> WEEKDAY_DICT:
467
+ """Return a schedule by climate profile."""
468
+ if not self._supports_schedule:
469
+ raise ValidationException(f"Schedule is not supported by device {self._device.name}")
470
+ schedule_data = await self._get_schedule_profile(profile=profile, weekday=weekday)
471
+ return schedule_data.get(profile, {}).get(weekday, {})
472
+
473
+ async def _get_raw_schedule(self) -> _RAW_SCHEDULE_DICT:
474
+ """Return the raw schedule."""
475
+ try:
476
+ raw_data = await self._client.get_paramset(
477
+ address=self.schedule_channel_address,
478
+ paramset_key=ParamsetKey.MASTER,
479
+ )
480
+ raw_schedule = {key: value for key, value in raw_data.items() if SCHEDULER_PROFILE_PATTERN.match(key)}
481
+ except ClientException as cex:
482
+ self._supports_schedule = False
483
+ raise ValidationException(f"Schedule is not supported by device {self._device.name}") from cex
484
+ return raw_schedule
485
+
486
+ async def _get_schedule_profile(
487
+ self, *, profile: ScheduleProfile | None = None, weekday: ScheduleWeekday | None = None
488
+ ) -> _SCHEDULE_DICT:
489
+ """Get the schedule."""
490
+ schedule_data: _SCHEDULE_DICT = {}
491
+ raw_schedule = await self._get_raw_schedule()
492
+ for slot_name, slot_value in raw_schedule.items():
493
+ slot_name_tuple = slot_name.split("_")
494
+ if len(slot_name_tuple) != 4:
495
+ continue
496
+ profile_name, slot_type, slot_weekday, slot_no = slot_name_tuple
497
+ _profile = ScheduleProfile(profile_name)
498
+ if profile and profile != _profile:
499
+ continue
500
+ _slot_type = ScheduleSlotType(slot_type)
501
+ _weekday = ScheduleWeekday(slot_weekday)
502
+ if weekday and weekday != _weekday:
503
+ continue
504
+ _slot_no = int(slot_no)
505
+
506
+ _add_to_schedule_data(
507
+ schedule_data=schedule_data,
508
+ profile=_profile,
509
+ weekday=_weekday,
510
+ slot_no=_slot_no,
511
+ slot_type=_slot_type,
512
+ slot_value=slot_value,
513
+ )
514
+
515
+ return schedule_data
516
+
517
+ @inspector
518
+ async def set_schedule_profile(
519
+ self, *, profile: ScheduleProfile, profile_data: PROFILE_DICT, do_validate: bool = True
520
+ ) -> None:
521
+ """Set a profile to device."""
522
+ await self._set_schedule_profile(
523
+ target_channel_address=self.schedule_channel_address,
524
+ profile=profile,
525
+ profile_data=profile_data,
526
+ do_validate=do_validate,
527
+ )
528
+
529
+ async def _set_schedule_profile(
530
+ self,
531
+ *,
532
+ target_channel_address: str,
533
+ profile: ScheduleProfile,
534
+ profile_data: PROFILE_DICT,
535
+ do_validate: bool,
536
+ ) -> None:
537
+ """Set a profile to device."""
538
+ if do_validate:
539
+ self._validate_schedule_profile(profile=profile, profile_data=profile_data)
540
+ schedule_data: _SCHEDULE_DICT = {}
541
+ for weekday, weekday_data in profile_data.items():
542
+ for slot_no, slot in weekday_data.items():
543
+ for slot_type, slot_value in slot.items():
544
+ _add_to_schedule_data(
545
+ schedule_data=schedule_data,
546
+ profile=profile,
547
+ weekday=weekday,
548
+ slot_no=slot_no,
549
+ slot_type=slot_type,
550
+ slot_value=slot_value,
551
+ )
552
+ await self._client.put_paramset(
553
+ channel_address=target_channel_address,
554
+ paramset_key_or_link_address=ParamsetKey.MASTER,
555
+ values=_get_raw_schedule_paramset(schedule_data=schedule_data),
556
+ )
557
+
558
+ @inspector
559
+ async def set_simple_schedule_profile(
560
+ self,
561
+ *,
562
+ profile: ScheduleProfile,
563
+ base_temperature: float,
564
+ simple_profile_data: SIMPLE_PROFILE_DICT,
565
+ ) -> None:
566
+ """Set a profile to device."""
567
+ profile_data = self._validate_and_convert_simple_to_profile(
568
+ base_temperature=base_temperature, simple_profile_data=simple_profile_data
569
+ )
570
+ await self.set_schedule_profile(profile=profile, profile_data=profile_data)
571
+
572
+ @inspector
573
+ async def set_schedule_profile_weekday(
574
+ self,
575
+ *,
576
+ profile: ScheduleProfile,
577
+ weekday: ScheduleWeekday,
578
+ weekday_data: WEEKDAY_DICT,
579
+ do_validate: bool = True,
580
+ ) -> None:
581
+ """Store a profile to device."""
582
+ if do_validate:
583
+ self._validate_schedule_profile_weekday(profile=profile, weekday=weekday, weekday_data=weekday_data)
584
+ schedule_data: _SCHEDULE_DICT = {}
585
+ for slot_no, slot in weekday_data.items():
586
+ for slot_type, slot_value in slot.items():
587
+ _add_to_schedule_data(
588
+ schedule_data=schedule_data,
589
+ profile=profile,
590
+ weekday=weekday,
591
+ slot_no=slot_no,
592
+ slot_type=slot_type,
593
+ slot_value=slot_value,
594
+ )
595
+ await self._client.put_paramset(
596
+ channel_address=self.schedule_channel_address,
597
+ paramset_key_or_link_address=ParamsetKey.MASTER,
598
+ values=_get_raw_schedule_paramset(schedule_data=schedule_data),
599
+ )
600
+
601
+ @inspector
602
+ async def set_simple_schedule_profile_weekday(
603
+ self,
604
+ *,
605
+ profile: ScheduleProfile,
606
+ weekday: ScheduleWeekday,
607
+ base_temperature: float,
608
+ simple_weekday_list: SIMPLE_WEEKDAY_LIST,
609
+ ) -> None:
610
+ """Store a simple weekday profile to device."""
611
+ weekday_data = self._validate_and_convert_simple_to_profile_weekday(
612
+ base_temperature=base_temperature, simple_weekday_list=simple_weekday_list
613
+ )
614
+ await self.set_schedule_profile_weekday(profile=profile, weekday=weekday, weekday_data=weekday_data)
615
+
616
+ def _validate_and_convert_simple_to_profile(
617
+ self, *, base_temperature: float, simple_profile_data: SIMPLE_PROFILE_DICT
618
+ ) -> PROFILE_DICT:
619
+ """Convert simple profile dict to profile dict."""
620
+ profile_dict: PROFILE_DICT = {}
621
+ for day, simple_weekday_list in simple_profile_data.items():
622
+ profile_dict[day] = self._validate_and_convert_simple_to_profile_weekday(
623
+ base_temperature=base_temperature, simple_weekday_list=simple_weekday_list
624
+ )
625
+ return profile_dict
626
+
627
+ def _validate_and_convert_simple_to_profile_weekday(
628
+ self, *, base_temperature: float, simple_weekday_list: SIMPLE_WEEKDAY_LIST
629
+ ) -> WEEKDAY_DICT:
630
+ """Convert simple weekday list to weekday dict."""
631
+ if not self.min_temp <= base_temperature <= self.max_temp:
632
+ raise ValidationException(
633
+ f"VALIDATE_PROFILE: Base temperature {base_temperature} not in valid range (min: {self.min_temp}, "
634
+ f"max: {self.max_temp})"
635
+ )
636
+
637
+ weekday_data: WEEKDAY_DICT = {}
638
+ sorted_simple_weekday_list = _sort_simple_weekday_list(simple_weekday_list=simple_weekday_list)
639
+ previous_endtime = _MIN_SCHEDULER_TIME
640
+ slot_no = 1
641
+ for slot in sorted_simple_weekday_list:
642
+ if (starttime := slot.get(ScheduleSlotType.STARTTIME)) is None:
643
+ raise ValidationException("VALIDATE_PROFILE: STARTTIME is missing.")
644
+ if (endtime := slot.get(ScheduleSlotType.ENDTIME)) is None:
645
+ raise ValidationException("VALIDATE_PROFILE: ENDTIME is missing.")
646
+ if (temperature := slot.get(ScheduleSlotType.TEMPERATURE)) is None:
647
+ raise ValidationException("VALIDATE_PROFILE: TEMPERATURE is missing.")
648
+
649
+ if _convert_time_str_to_minutes(time_str=str(starttime)) >= _convert_time_str_to_minutes(
650
+ time_str=str(endtime)
651
+ ):
652
+ raise ValidationException(
653
+ f"VALIDATE_PROFILE: Start time {starttime} must lower than end time {endtime}"
654
+ )
655
+
656
+ if _convert_time_str_to_minutes(time_str=str(starttime)) < _convert_time_str_to_minutes(
657
+ time_str=previous_endtime
658
+ ):
659
+ raise ValidationException(
660
+ f"VALIDATE_PROFILE: Timespans are overlapping with a previous slot for start time: {starttime} / end time: {endtime}"
661
+ )
662
+
663
+ if not self.min_temp <= float(temperature) <= self.max_temp:
664
+ raise ValidationException(
665
+ f"VALIDATE_PROFILE: Temperature {temperature} not in valid range (min: {self.min_temp}, "
666
+ f"max: {self.max_temp}) for start time: {starttime} / end time: {endtime}"
667
+ )
668
+
669
+ if _convert_time_str_to_minutes(time_str=str(starttime)) > _convert_time_str_to_minutes(
670
+ time_str=previous_endtime
671
+ ):
672
+ weekday_data[slot_no] = {
673
+ ScheduleSlotType.ENDTIME: starttime,
674
+ ScheduleSlotType.TEMPERATURE: base_temperature,
675
+ }
676
+ slot_no += 1
677
+
678
+ weekday_data[slot_no] = {
679
+ ScheduleSlotType.ENDTIME: endtime,
680
+ ScheduleSlotType.TEMPERATURE: temperature,
681
+ }
682
+ previous_endtime = str(endtime)
683
+ slot_no += 1
684
+
685
+ return _fillup_weekday_data(base_temperature=base_temperature, weekday_data=weekday_data)
686
+
687
+ def _validate_schedule_profile(self, *, profile: ScheduleProfile, profile_data: PROFILE_DICT) -> None:
688
+ """Validate the profile."""
689
+ for weekday, weekday_data in profile_data.items():
690
+ self._validate_schedule_profile_weekday(profile=profile, weekday=weekday, weekday_data=weekday_data)
691
+
692
+ def _validate_schedule_profile_weekday(
693
+ self,
694
+ *,
695
+ profile: ScheduleProfile,
696
+ weekday: ScheduleWeekday,
697
+ weekday_data: WEEKDAY_DICT,
698
+ ) -> None:
699
+ """Validate the profile weekday."""
700
+ previous_endtime = 0
701
+ if len(weekday_data) != 13:
702
+ raise ValidationException(
703
+ f"VALIDATE_PROFILE: {'Too many' if len(weekday_data) > 13 else 'Too few'} slots in profile: {profile} / week day: {weekday}"
704
+ )
705
+ for no in SCHEDULE_SLOT_RANGE:
706
+ if no not in weekday_data:
707
+ raise ValidationException(
708
+ f"VALIDATE_PROFILE: slot no {no} is missing in profile: {profile} / week day: {weekday}"
709
+ )
710
+ slot = weekday_data[no]
711
+ for slot_type in RELEVANT_SLOT_TYPES:
712
+ if slot_type not in slot:
713
+ raise ValidationException(
714
+ f"VALIDATE_PROFILE: slot type {slot_type} is missing in profile: "
715
+ f"{profile} / week day: {weekday} / slot no: {no}"
716
+ )
717
+ temperature = float(weekday_data[no][ScheduleSlotType.TEMPERATURE])
718
+ if not self.min_temp <= temperature <= self.max_temp:
719
+ raise ValidationException(
720
+ f"VALIDATE_PROFILE: Temperature {temperature} not in valid range (min: {self.min_temp}, "
721
+ f"max: {self.max_temp}) for profile: {profile} / week day: {weekday} / slot no: {no}"
722
+ )
723
+
724
+ endtime_str = str(weekday_data[no][ScheduleSlotType.ENDTIME])
725
+ if endtime := _convert_time_str_to_minutes(time_str=endtime_str):
726
+ if endtime not in SCHEDULE_TIME_RANGE:
727
+ raise ValidationException(
728
+ f"VALIDATE_PROFILE: Time {endtime_str} must be between {_convert_minutes_to_time_str(minutes=SCHEDULE_TIME_RANGE.start)} and "
729
+ f"{_convert_minutes_to_time_str(minutes=SCHEDULE_TIME_RANGE.stop - 1)} for profile: {profile} / week day: {weekday} / slot no: {no}"
730
+ )
731
+ if endtime < previous_endtime:
732
+ raise ValidationException(
733
+ f"VALIDATE_PROFILE: Time sequence must be rising. {endtime_str} is lower than the previous "
734
+ f"value {_convert_minutes_to_time_str(minutes=previous_endtime)} for profile: {profile} / week day: {weekday} / slot no: {no}"
735
+ )
736
+ previous_endtime = endtime
737
+
738
+
739
+ class CustomDpSimpleRfThermostat(BaseCustomDpClimate):
740
+ """Simple classic Homematic thermostat HM-CC-TC."""
741
+
742
+ __slots__ = ()
743
+
744
+ def _manu_temp_changed(self, *, data_point: GenericDataPoint | None = None, **kwargs: Any) -> None:
745
+ """Handle device state changes."""
746
+
747
+
748
+ class CustomDpRfThermostat(BaseCustomDpClimate):
749
+ """Classic Homematic thermostat like HM-CC-RT-DN."""
750
+
751
+ __slots__ = (
752
+ "_dp_auto_mode",
753
+ "_dp_boost_mode",
754
+ "_dp_comfort_mode",
755
+ "_dp_control_mode",
756
+ "_dp_lowering_mode",
757
+ "_dp_manu_mode",
758
+ "_dp_temperature_offset",
759
+ "_dp_valve_state",
760
+ "_dp_week_program_pointer",
761
+ )
762
+
763
+ def __init__(
764
+ self,
765
+ *,
766
+ channel: hmd.Channel,
767
+ unique_id: str,
768
+ device_profile: DeviceProfile,
769
+ device_def: Mapping[str, Any],
770
+ custom_data_point_def: Mapping[int | tuple[int, ...], tuple[str, ...]],
771
+ group_no: int,
772
+ custom_config: CustomConfig,
773
+ ) -> None:
774
+ """Initialize the Homematic thermostat."""
775
+ super().__init__(
776
+ channel=channel,
777
+ unique_id=unique_id,
778
+ device_profile=device_profile,
779
+ device_def=device_def,
780
+ custom_data_point_def=custom_data_point_def,
781
+ group_no=group_no,
782
+ custom_config=custom_config,
783
+ )
784
+ self._supports_schedule = True
785
+
786
+ def _init_data_point_fields(self) -> None:
787
+ """Init the data_point fields."""
788
+ super()._init_data_point_fields()
789
+ self._dp_boost_mode: DpAction = self._get_data_point(field=Field.BOOST_MODE, data_point_type=DpAction)
790
+ self._dp_auto_mode: DpAction = self._get_data_point(field=Field.AUTO_MODE, data_point_type=DpAction)
791
+ self._dp_manu_mode: DpAction = self._get_data_point(field=Field.MANU_MODE, data_point_type=DpAction)
792
+ self._dp_comfort_mode: DpAction = self._get_data_point(field=Field.COMFORT_MODE, data_point_type=DpAction)
793
+ self._dp_lowering_mode: DpAction = self._get_data_point(field=Field.LOWERING_MODE, data_point_type=DpAction)
794
+ self._dp_control_mode: DpSensor[str | None] = self._get_data_point(
795
+ field=Field.CONTROL_MODE, data_point_type=DpSensor[str | None]
796
+ )
797
+ self._dp_temperature_offset: DpSelect = self._get_data_point(
798
+ field=Field.TEMPERATURE_OFFSET, data_point_type=DpSelect
799
+ )
800
+ self._dp_valve_state: DpSensor[int | None] = self._get_data_point(
801
+ field=Field.VALVE_STATE, data_point_type=DpSensor[int | None]
802
+ )
803
+ self._dp_week_program_pointer: DpSelect = self._get_data_point(
804
+ field=Field.WEEK_PROGRAM_POINTER, data_point_type=DpSelect
805
+ )
806
+
807
+ self._unregister_callbacks.append(
808
+ self._dp_control_mode.register_data_point_updated_callback(
809
+ cb=self._manu_temp_changed, custom_id=InternalCustomID.MANU_TEMP
810
+ )
811
+ )
812
+
813
+ def _manu_temp_changed(self, *, data_point: GenericDataPoint | None = None, **kwargs: Any) -> None:
814
+ """Handle device state changes."""
815
+ if (
816
+ data_point == self._dp_control_mode
817
+ and self.mode == ClimateMode.HEAT
818
+ and self._dp_setpoint.refreshed_recently
819
+ ):
820
+ self._old_manu_setpoint = self.target_temperature
821
+
822
+ if (
823
+ data_point == self._dp_setpoint
824
+ and self.mode == ClimateMode.HEAT
825
+ and self._dp_control_mode.refreshed_recently
826
+ ):
827
+ self._old_manu_setpoint = self.target_temperature
828
+
829
+ @state_property
830
+ def activity(self) -> ClimateActivity | None:
831
+ """Return the current activity."""
832
+ if self._dp_valve_state.value is None:
833
+ return None
834
+ if self.mode == ClimateMode.OFF:
835
+ return ClimateActivity.OFF
836
+ if self._dp_valve_state.value and self._dp_valve_state.value > 0:
837
+ return ClimateActivity.HEAT
838
+ return ClimateActivity.IDLE
839
+
840
+ @state_property
841
+ def mode(self) -> ClimateMode:
842
+ """Return current operation mode."""
843
+ if self.target_temperature and self.target_temperature <= _OFF_TEMPERATURE:
844
+ return ClimateMode.OFF
845
+ if self._dp_control_mode.value == _ModeHm.MANU:
846
+ return ClimateMode.HEAT
847
+ return ClimateMode.AUTO
848
+
849
+ @state_property
850
+ def modes(self) -> tuple[ClimateMode, ...]:
851
+ """Return the available operation modes."""
852
+ return (ClimateMode.AUTO, ClimateMode.HEAT, ClimateMode.OFF)
853
+
854
+ @state_property
855
+ def profile(self) -> ClimateProfile:
856
+ """Return the current profile."""
857
+ if self._dp_control_mode.value is None:
858
+ return ClimateProfile.NONE
859
+ if self._dp_control_mode.value == _ModeHm.BOOST:
860
+ return ClimateProfile.BOOST
861
+ if self._dp_control_mode.value == _ModeHm.AWAY:
862
+ return ClimateProfile.AWAY
863
+ if self.mode == ClimateMode.AUTO:
864
+ return self._current_profile_name if self._current_profile_name else ClimateProfile.NONE
865
+ return ClimateProfile.NONE
866
+
867
+ @state_property
868
+ def profiles(self) -> tuple[ClimateProfile, ...]:
869
+ """Return available profile."""
870
+ control_modes = [ClimateProfile.BOOST, ClimateProfile.COMFORT, ClimateProfile.ECO, ClimateProfile.NONE]
871
+ if self.mode == ClimateMode.AUTO:
872
+ control_modes.extend(self._profile_names)
873
+ return tuple(control_modes)
874
+
875
+ @property
876
+ def supports_profiles(self) -> bool:
877
+ """Flag if climate supports profiles."""
878
+ return True
879
+
880
+ @state_property
881
+ def temperature_offset(self) -> str | None:
882
+ """Return the maximum temperature."""
883
+ return self._dp_temperature_offset.value
884
+
885
+ @bind_collector()
886
+ async def set_mode(self, *, mode: ClimateMode, collector: CallParameterCollector | None = None) -> None:
887
+ """Set new mode."""
888
+ if not self.is_state_change(mode=mode):
889
+ return
890
+ if mode == ClimateMode.AUTO:
891
+ await self._dp_auto_mode.send_value(value=True, collector=collector)
892
+ elif mode == ClimateMode.HEAT:
893
+ await self._dp_manu_mode.send_value(value=self._temperature_for_heat_mode, collector=collector)
894
+ elif mode == ClimateMode.OFF:
895
+ await self._dp_manu_mode.send_value(value=self.target_temperature, collector=collector)
896
+ # Disable validation here to allow setting a value,
897
+ # that is out of the validation range.
898
+ await self.set_temperature(temperature=_OFF_TEMPERATURE, collector=collector, do_validate=False)
899
+
900
+ @bind_collector()
901
+ async def set_profile(self, *, profile: ClimateProfile, collector: CallParameterCollector | None = None) -> None:
902
+ """Set new profile."""
903
+ if not self.is_state_change(profile=profile):
904
+ return
905
+ if profile == ClimateProfile.BOOST:
906
+ await self._dp_boost_mode.send_value(value=True, collector=collector)
907
+ elif profile == ClimateProfile.COMFORT:
908
+ await self._dp_comfort_mode.send_value(value=True, collector=collector)
909
+ elif profile == ClimateProfile.ECO:
910
+ await self._dp_lowering_mode.send_value(value=True, collector=collector)
911
+ elif profile in self._profile_names:
912
+ if self.mode != ClimateMode.AUTO:
913
+ await self.set_mode(mode=ClimateMode.AUTO, collector=collector)
914
+ await self._dp_boost_mode.send_value(value=False, collector=collector)
915
+ if (profile_idx := self._profiles.get(profile)) is not None:
916
+ await self._dp_week_program_pointer.send_value(
917
+ value=_HM_WEEK_PROFILE_POINTERS_TO_NAMES[profile_idx], collector=collector
918
+ )
919
+
920
+ @inspector
921
+ async def enable_away_mode_by_calendar(self, *, start: datetime, end: datetime, away_temperature: float) -> None:
922
+ """Enable the away mode by calendar on thermostat."""
923
+ await self._client.set_value(
924
+ channel_address=self._channel.address,
925
+ paramset_key=ParamsetKey.VALUES,
926
+ parameter=Parameter.PARTY_MODE_SUBMIT,
927
+ value=_party_mode_code(start=start, end=end, away_temperature=away_temperature),
928
+ )
929
+
930
+ @inspector
931
+ async def enable_away_mode_by_duration(self, *, hours: int, away_temperature: float) -> None:
932
+ """Enable the away mode by duration on thermostat."""
933
+ start = datetime.now() - timedelta(minutes=10)
934
+ end = datetime.now() + timedelta(hours=hours)
935
+ await self.enable_away_mode_by_calendar(start=start, end=end, away_temperature=away_temperature)
936
+
937
+ @inspector
938
+ async def disable_away_mode(self) -> None:
939
+ """Disable the away mode on thermostat."""
940
+ start = datetime.now() - timedelta(hours=11)
941
+ end = datetime.now() - timedelta(hours=10)
942
+
943
+ await self._client.set_value(
944
+ channel_address=self._channel.address,
945
+ paramset_key=ParamsetKey.VALUES,
946
+ parameter=Parameter.PARTY_MODE_SUBMIT,
947
+ value=_party_mode_code(start=start, end=end, away_temperature=12.0),
948
+ )
949
+
950
+ @property
951
+ def _profile_names(self) -> tuple[ClimateProfile, ...]:
952
+ """Return a collection of profile names."""
953
+ return tuple(self._profiles.keys())
954
+
955
+ @property
956
+ def _current_profile_name(self) -> ClimateProfile | None:
957
+ """Return a profile index by name."""
958
+ inv_profiles = {v: k for k, v in self._profiles.items()}
959
+ if self._dp_week_program_pointer.value is not None:
960
+ idx = (
961
+ int(self._dp_week_program_pointer.value)
962
+ if self._dp_week_program_pointer.value.isnumeric()
963
+ else _HM_WEEK_PROFILE_POINTERS_TO_IDX[self._dp_week_program_pointer.value]
964
+ )
965
+ return inv_profiles.get(idx)
966
+ return None
967
+
968
+ @property
969
+ def _profiles(self) -> Mapping[ClimateProfile, int]:
970
+ """Return the profile groups."""
971
+ profiles: dict[ClimateProfile, int] = {}
972
+ if self._dp_week_program_pointer.min is not None and self._dp_week_program_pointer.max is not None:
973
+ for i in range(int(self._dp_week_program_pointer.min) + 1, int(self._dp_week_program_pointer.max) + 2):
974
+ profiles[ClimateProfile(f"{PROFILE_PREFIX}{i}")] = i - 1
975
+
976
+ return profiles
977
+
978
+
979
+ def _party_mode_code(*, start: datetime, end: datetime, away_temperature: float) -> str:
980
+ """
981
+ Create the party mode code.
982
+
983
+ e.g. 21.5,1200,20,10,16,1380,20,10,16
984
+ away_temperature,start_minutes_of_day, day(2), month(2), year(2), end_minutes_of_day, day(2), month(2), year(2)
985
+ """
986
+ 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')}"
987
+
988
+
989
+ class CustomDpIpThermostat(BaseCustomDpClimate):
990
+ """HomematicIP thermostat like HmIP-BWTH, HmIP-eTRV-X."""
991
+
992
+ __slots__ = (
993
+ "_dp_active_profile",
994
+ "_dp_boost_mode",
995
+ "_dp_control_mode",
996
+ "_dp_heating_mode",
997
+ "_dp_heating_valve_type",
998
+ "_dp_level",
999
+ "_dp_optimum_start_stop",
1000
+ "_dp_party_mode",
1001
+ "_dp_set_point_mode",
1002
+ "_dp_state",
1003
+ "_dp_temperature_offset",
1004
+ "_peer_level_dp",
1005
+ "_peer_state_dp",
1006
+ "_peer_unregister_callbacks",
1007
+ )
1008
+
1009
+ def __init__(
1010
+ self,
1011
+ *,
1012
+ channel: hmd.Channel,
1013
+ unique_id: str,
1014
+ device_profile: DeviceProfile,
1015
+ device_def: Mapping[str, Any],
1016
+ custom_data_point_def: Mapping[int | tuple[int, ...], tuple[str, ...]],
1017
+ group_no: int,
1018
+ custom_config: CustomConfig,
1019
+ ) -> None:
1020
+ """Initialize the climate ip thermostat."""
1021
+ self._peer_level_dp: DpFloat | None = None
1022
+ self._peer_state_dp: DpBinarySensor | None = None
1023
+ self._peer_unregister_callbacks: list[CALLBACK_TYPE] = []
1024
+ super().__init__(
1025
+ channel=channel,
1026
+ unique_id=unique_id,
1027
+ device_profile=device_profile,
1028
+ device_def=device_def,
1029
+ custom_data_point_def=custom_data_point_def,
1030
+ group_no=group_no,
1031
+ custom_config=custom_config,
1032
+ )
1033
+ self._supports_schedule = True
1034
+
1035
+ def _init_data_point_fields(self) -> None:
1036
+ """Init the data_point fields."""
1037
+ super()._init_data_point_fields()
1038
+ self._dp_active_profile: DpInteger = self._get_data_point(field=Field.ACTIVE_PROFILE, data_point_type=DpInteger)
1039
+ self._dp_boost_mode: DpSwitch = self._get_data_point(field=Field.BOOST_MODE, data_point_type=DpSwitch)
1040
+ self._dp_control_mode: DpAction = self._get_data_point(field=Field.CONTROL_MODE, data_point_type=DpAction)
1041
+ self._dp_heating_mode: DpSelect = self._get_data_point(field=Field.HEATING_COOLING, data_point_type=DpSelect)
1042
+ self._dp_heating_valve_type: DpSelect = self._get_data_point(
1043
+ field=Field.HEATING_VALVE_TYPE, data_point_type=DpSelect
1044
+ )
1045
+ self._dp_level: DpFloat = self._get_data_point(field=Field.LEVEL, data_point_type=DpFloat)
1046
+ self._dp_optimum_start_stop: DpBinarySensor = self._get_data_point(
1047
+ field=Field.OPTIMUM_START_STOP, data_point_type=DpBinarySensor
1048
+ )
1049
+ self._dp_party_mode: DpBinarySensor = self._get_data_point(
1050
+ field=Field.PARTY_MODE, data_point_type=DpBinarySensor
1051
+ )
1052
+ self._dp_set_point_mode: DpInteger = self._get_data_point(field=Field.SET_POINT_MODE, data_point_type=DpInteger)
1053
+ self._dp_state: DpBinarySensor = self._get_data_point(field=Field.STATE, data_point_type=DpBinarySensor)
1054
+ self._dp_temperature_offset: DpFloat = self._get_data_point(
1055
+ field=Field.TEMPERATURE_OFFSET, data_point_type=DpFloat
1056
+ )
1057
+
1058
+ # register callback for set_point_mode to track manual target temp
1059
+ self._unregister_callbacks.append(
1060
+ self._dp_set_point_mode.register_data_point_updated_callback(
1061
+ cb=self._manu_temp_changed, custom_id=InternalCustomID.MANU_TEMP
1062
+ )
1063
+ )
1064
+
1065
+ if OptionalSettings.ENABLE_LINKED_ENTITY_CLIMATE_ACTIVITY not in self._device.central.config.optional_settings:
1066
+ return
1067
+
1068
+ for ch in self._device.channels.values():
1069
+ # register link-peer change callback; store unregister handle
1070
+ if (unreg := ch.register_link_peer_changed_callback(cb=self._on_link_peer_changed)) is not None:
1071
+ self._unregister_callbacks.append(unreg)
1072
+ # pre-populate peer references (if any) once
1073
+ self._refresh_link_peer_activity_sources()
1074
+
1075
+ # --- Link peer support for activity fallback -----------------------------
1076
+ def _on_link_peer_changed(self) -> None:
1077
+ """
1078
+ Handle a change of the link peer channel.
1079
+
1080
+ Refresh references to `STATE`/`LEVEL` on the peer and emit an update so
1081
+ consumers can re-evaluate `activity`.
1082
+ """
1083
+ self._refresh_link_peer_activity_sources()
1084
+ # Inform listeners that relevant inputs may have changed
1085
+ self.emit_data_point_updated_event()
1086
+
1087
+ def _refresh_link_peer_activity_sources(self) -> None:
1088
+ """
1089
+ Refresh peer data point references used for `activity` fallback.
1090
+
1091
+ - Unregister any previously registered peer callbacks.
1092
+ - Grab its `STATE` and `LEVEL` generic data points from any available linked channel (if available).
1093
+ - Subscribe to their updates to keep `activity` current.
1094
+ """
1095
+ # Unsubscribe from previous peer DPs
1096
+ for unreg in self._peer_unregister_callbacks:
1097
+ if unreg is not None:
1098
+ with contextlib.suppress(Exception):
1099
+ unreg()
1100
+
1101
+ self._peer_unregister_callbacks.clear()
1102
+ self._peer_level_dp = None
1103
+ self._peer_state_dp = None
1104
+
1105
+ try:
1106
+ # Go thru all link peer channels of the device
1107
+ for link_channels in self._device.link_peer_channels.values():
1108
+ # Some channels have multiple link peers
1109
+ for link_channel in link_channels:
1110
+ # Continue if LEVEL or STATE dp found and ignore the others
1111
+ if not link_channel.has_link_target_category(category=DataPointCategory.CLIMATE):
1112
+ continue
1113
+ if level_dp := link_channel.get_generic_data_point(parameter=Parameter.LEVEL):
1114
+ self._peer_level_dp = cast(DpFloat, level_dp)
1115
+ break
1116
+ if state_dp := link_channel.get_generic_data_point(parameter=Parameter.STATE):
1117
+ self._peer_state_dp = cast(DpBinarySensor, state_dp)
1118
+ break
1119
+ except Exception: # pragma: no cover - defensive
1120
+ self._peer_level_dp = None
1121
+ self._peer_state_dp = None
1122
+ return
1123
+
1124
+ # Subscribe to updates of peer DPs to forward update events
1125
+ for dp in (self._peer_level_dp, self._peer_state_dp):
1126
+ if dp is None:
1127
+ continue
1128
+ unreg = dp.register_internal_data_point_updated_callback(cb=self.emit_data_point_updated_event)
1129
+ if unreg is not None:
1130
+ # Track for both refresh-time cleanup and object removal cleanup
1131
+ self._peer_unregister_callbacks.append(unreg)
1132
+ self._unregister_callbacks.append(unreg)
1133
+
1134
+ def _manu_temp_changed(self, *, data_point: GenericDataPoint | None = None, **kwargs: Any) -> None:
1135
+ """Handle device state changes."""
1136
+ if (
1137
+ data_point == self._dp_set_point_mode
1138
+ and self.mode == ClimateMode.HEAT
1139
+ and self._dp_setpoint.refreshed_recently
1140
+ ):
1141
+ self._old_manu_setpoint = self.target_temperature
1142
+
1143
+ if (
1144
+ data_point == self._dp_setpoint
1145
+ and self.mode == ClimateMode.HEAT
1146
+ and self._dp_set_point_mode.refreshed_recently
1147
+ ):
1148
+ self._old_manu_setpoint = self.target_temperature
1149
+
1150
+ @property
1151
+ def _is_heating_mode(self) -> bool:
1152
+ """Return the heating_mode of the device."""
1153
+ if self._dp_heating_mode.value is not None:
1154
+ return str(self._dp_heating_mode.value) == "HEATING"
1155
+ return True
1156
+
1157
+ @state_property
1158
+ def activity(self) -> ClimateActivity | None:
1159
+ """
1160
+ Return the current activity.
1161
+
1162
+ The preferred sources for determining the activity are this channel's `LEVEL` and `STATE` data points.
1163
+ Some devices don't expose one or both; in that case we try to use the same datapoints from the linked peer channels instead.
1164
+ """
1165
+ # Determine effective data point values for LEVEL and STATE.
1166
+ level_dp = self._dp_level if self._dp_level.is_hmtype else None
1167
+ state_dp = self._dp_state if self._dp_state.is_hmtype else None
1168
+
1169
+ eff_level = None
1170
+ eff_state = None
1171
+
1172
+ # Use own DP values as-is when available to preserve legacy behavior.
1173
+ if level_dp is not None and level_dp.value is not None:
1174
+ eff_level = level_dp.value
1175
+ elif self._peer_level_dp is not None and self._peer_level_dp.value is not None:
1176
+ eff_level = self._peer_level_dp.value
1177
+
1178
+ if state_dp is not None and state_dp.value is not None:
1179
+ eff_state = state_dp.value
1180
+ elif self._peer_state_dp is not None and self._peer_state_dp.value is not None:
1181
+ eff_state = self._peer_state_dp.value
1182
+
1183
+ if eff_state is None and eff_level is None:
1184
+ return None
1185
+ if self.mode == ClimateMode.OFF:
1186
+ return ClimateActivity.OFF
1187
+ if eff_level is not None and eff_level > _CLOSED_LEVEL:
1188
+ return ClimateActivity.HEAT
1189
+ if (self._dp_heating_valve_type.value is None and eff_state is True) or (
1190
+ self._dp_heating_valve_type.value
1191
+ and (
1192
+ (eff_state is True and self._dp_heating_valve_type.value == ClimateHeatingValveType.NORMALLY_CLOSE)
1193
+ or (eff_state is False and self._dp_heating_valve_type.value == ClimateHeatingValveType.NORMALLY_OPEN)
1194
+ )
1195
+ ):
1196
+ return ClimateActivity.HEAT if self._is_heating_mode else ClimateActivity.COOL
1197
+ return ClimateActivity.IDLE
1198
+
1199
+ @state_property
1200
+ def mode(self) -> ClimateMode:
1201
+ """Return current operation mode."""
1202
+ if self.target_temperature and self.target_temperature <= _OFF_TEMPERATURE:
1203
+ return ClimateMode.OFF
1204
+ if self._dp_set_point_mode.value == _ModeHmIP.MANU:
1205
+ return ClimateMode.HEAT if self._is_heating_mode else ClimateMode.COOL
1206
+ if self._dp_set_point_mode.value == _ModeHmIP.AUTO:
1207
+ return ClimateMode.AUTO
1208
+ return ClimateMode.AUTO
1209
+
1210
+ @state_property
1211
+ def modes(self) -> tuple[ClimateMode, ...]:
1212
+ """Return the available operation modes."""
1213
+ return (
1214
+ ClimateMode.AUTO,
1215
+ ClimateMode.HEAT if self._is_heating_mode else ClimateMode.COOL,
1216
+ ClimateMode.OFF,
1217
+ )
1218
+
1219
+ @state_property
1220
+ def profile(self) -> ClimateProfile:
1221
+ """Return the current control mode."""
1222
+ if self._dp_boost_mode.value:
1223
+ return ClimateProfile.BOOST
1224
+ if self._dp_set_point_mode.value == _ModeHmIP.AWAY:
1225
+ return ClimateProfile.AWAY
1226
+ if self.mode == ClimateMode.AUTO:
1227
+ return self._current_profile_name if self._current_profile_name else ClimateProfile.NONE
1228
+ return ClimateProfile.NONE
1229
+
1230
+ @state_property
1231
+ def profiles(self) -> tuple[ClimateProfile, ...]:
1232
+ """Return available control modes."""
1233
+ control_modes = [ClimateProfile.BOOST, ClimateProfile.NONE]
1234
+ if self.mode == ClimateMode.AUTO:
1235
+ control_modes.extend(self._profile_names)
1236
+ return tuple(control_modes)
1237
+
1238
+ @property
1239
+ def optimum_start_stop(self) -> bool | None:
1240
+ """Return if optimum_start_stop is enabled."""
1241
+ return self._dp_optimum_start_stop.value
1242
+
1243
+ @property
1244
+ def supports_profiles(self) -> bool:
1245
+ """Flag if climate supports control modes."""
1246
+ return True
1247
+
1248
+ @state_property
1249
+ def temperature_offset(self) -> float | None:
1250
+ """Return the maximum temperature."""
1251
+ return self._dp_temperature_offset.value
1252
+
1253
+ @bind_collector()
1254
+ async def set_mode(self, *, mode: ClimateMode, collector: CallParameterCollector | None = None) -> None:
1255
+ """Set new target mode."""
1256
+ if not self.is_state_change(mode=mode):
1257
+ return
1258
+ # if switching mode then disable boost_mode
1259
+ if self._dp_boost_mode.value:
1260
+ await self.set_profile(profile=ClimateProfile.NONE, collector=collector)
1261
+
1262
+ if mode == ClimateMode.AUTO:
1263
+ await self._dp_control_mode.send_value(value=_ModeHmIP.AUTO, collector=collector)
1264
+ elif mode in (ClimateMode.HEAT, ClimateMode.COOL):
1265
+ await self._dp_control_mode.send_value(value=_ModeHmIP.MANU, collector=collector)
1266
+ await self.set_temperature(temperature=self._temperature_for_heat_mode, collector=collector)
1267
+ elif mode == ClimateMode.OFF:
1268
+ await self._dp_control_mode.send_value(value=_ModeHmIP.MANU, collector=collector)
1269
+ await self.set_temperature(temperature=_OFF_TEMPERATURE, collector=collector, do_validate=False)
1270
+
1271
+ @bind_collector()
1272
+ async def set_profile(self, *, profile: ClimateProfile, collector: CallParameterCollector | None = None) -> None:
1273
+ """Set new control mode."""
1274
+ if not self.is_state_change(profile=profile):
1275
+ return
1276
+ if profile == ClimateProfile.BOOST:
1277
+ await self._dp_boost_mode.send_value(value=True, collector=collector)
1278
+ elif profile == ClimateProfile.NONE:
1279
+ await self._dp_boost_mode.send_value(value=False, collector=collector)
1280
+ elif profile in self._profile_names:
1281
+ if self.mode != ClimateMode.AUTO:
1282
+ await self.set_mode(mode=ClimateMode.AUTO, collector=collector)
1283
+ await self._dp_boost_mode.send_value(value=False, collector=collector)
1284
+ if profile_idx := self._profiles.get(profile):
1285
+ await self._dp_active_profile.send_value(value=profile_idx, collector=collector)
1286
+
1287
+ @inspector
1288
+ async def enable_away_mode_by_calendar(self, *, start: datetime, end: datetime, away_temperature: float) -> None:
1289
+ """Enable the away mode by calendar on thermostat."""
1290
+ await self._client.put_paramset(
1291
+ channel_address=self._channel.address,
1292
+ paramset_key_or_link_address=ParamsetKey.VALUES,
1293
+ values={
1294
+ Parameter.SET_POINT_MODE: _ModeHmIP.AWAY,
1295
+ Parameter.SET_POINT_TEMPERATURE: away_temperature,
1296
+ Parameter.PARTY_TIME_START: start.strftime(_PARTY_DATE_FORMAT),
1297
+ Parameter.PARTY_TIME_END: end.strftime(_PARTY_DATE_FORMAT),
1298
+ },
1299
+ )
1300
+
1301
+ @inspector
1302
+ async def enable_away_mode_by_duration(self, *, hours: int, away_temperature: float) -> None:
1303
+ """Enable the away mode by duration on thermostat."""
1304
+ start = datetime.now() - timedelta(minutes=10)
1305
+ end = datetime.now() + timedelta(hours=hours)
1306
+ await self.enable_away_mode_by_calendar(start=start, end=end, away_temperature=away_temperature)
1307
+
1308
+ @inspector
1309
+ async def disable_away_mode(self) -> None:
1310
+ """Disable the away mode on thermostat."""
1311
+ await self._client.put_paramset(
1312
+ channel_address=self._channel.address,
1313
+ paramset_key_or_link_address=ParamsetKey.VALUES,
1314
+ values={
1315
+ Parameter.SET_POINT_MODE: _ModeHmIP.AWAY,
1316
+ Parameter.PARTY_TIME_START: _PARTY_INIT_DATE,
1317
+ Parameter.PARTY_TIME_END: _PARTY_INIT_DATE,
1318
+ },
1319
+ )
1320
+
1321
+ @property
1322
+ def _profile_names(self) -> tuple[ClimateProfile, ...]:
1323
+ """Return a collection of profile names."""
1324
+ return tuple(self._profiles.keys())
1325
+
1326
+ @property
1327
+ def _current_profile_name(self) -> ClimateProfile | None:
1328
+ """Return a profile index by name."""
1329
+ inv_profiles = {v: k for k, v in self._profiles.items()}
1330
+ if self._dp_active_profile.value is not None:
1331
+ return inv_profiles.get(int(self._dp_active_profile.value))
1332
+ return None
1333
+
1334
+ @property
1335
+ def _profiles(self) -> Mapping[ClimateProfile, int]:
1336
+ """Return the profile groups."""
1337
+ profiles: dict[ClimateProfile, int] = {}
1338
+ if self._dp_active_profile.min and self._dp_active_profile.max:
1339
+ for i in range(self._dp_active_profile.min, self._dp_active_profile.max + 1):
1340
+ profiles[ClimateProfile(f"{PROFILE_PREFIX}{i}")] = i
1341
+
1342
+ return profiles
1343
+
1344
+ @property
1345
+ def schedule_profile_nos(self) -> int:
1346
+ """Return the number of supported profiles."""
1347
+ return len(self._profiles)
1348
+
1349
+
1350
+ def _convert_minutes_to_time_str(minutes: Any) -> str:
1351
+ """Convert minutes to a time string."""
1352
+ if not isinstance(minutes, int):
1353
+ return _MAX_SCHEDULER_TIME
1354
+ time_str = f"{minutes // 60:0=2}:{minutes % 60:0=2}"
1355
+ if SCHEDULER_TIME_PATTERN.match(time_str) is None:
1356
+ raise ValidationException(
1357
+ f"Time {time_str} is not valid. Format must be hh:mm with min: {_MIN_SCHEDULER_TIME} and max: {_MAX_SCHEDULER_TIME}"
1358
+ )
1359
+ return time_str
1360
+
1361
+
1362
+ def _convert_time_str_to_minutes(*, time_str: str) -> int:
1363
+ """Convert minutes to a time string."""
1364
+ if SCHEDULER_TIME_PATTERN.match(time_str) is None:
1365
+ raise ValidationException(
1366
+ f"Time {time_str} is not valid. Format must be hh:mm with min: {_MIN_SCHEDULER_TIME} and max: {_MAX_SCHEDULER_TIME}"
1367
+ )
1368
+ try:
1369
+ h, m = time_str.split(":")
1370
+ return (int(h) * 60) + int(m)
1371
+ except Exception as exc:
1372
+ raise ValidationException(f"Failed to convert time {time_str}. Format must be hh:mm.") from exc
1373
+
1374
+
1375
+ def _sort_simple_weekday_list(*, simple_weekday_list: SIMPLE_WEEKDAY_LIST) -> SIMPLE_WEEKDAY_LIST:
1376
+ """Sort simple weekday list."""
1377
+ simple_weekday_dict = sorted(
1378
+ {
1379
+ _convert_time_str_to_minutes(time_str=str(slot[ScheduleSlotType.STARTTIME])): slot
1380
+ for slot in simple_weekday_list
1381
+ }.items()
1382
+ )
1383
+ return [slot[1] for slot in simple_weekday_dict]
1384
+
1385
+
1386
+ def _fillup_weekday_data(*, base_temperature: float, weekday_data: WEEKDAY_DICT) -> WEEKDAY_DICT:
1387
+ """Fillup weekday data."""
1388
+ for slot_no in SCHEDULE_SLOT_IN_RANGE:
1389
+ if slot_no not in weekday_data:
1390
+ weekday_data[slot_no] = {
1391
+ ScheduleSlotType.ENDTIME: _MAX_SCHEDULER_TIME,
1392
+ ScheduleSlotType.TEMPERATURE: base_temperature,
1393
+ }
1394
+
1395
+ return weekday_data
1396
+
1397
+
1398
+ def _get_raw_schedule_paramset(*, schedule_data: _SCHEDULE_DICT) -> _RAW_SCHEDULE_DICT:
1399
+ """Return the raw paramset."""
1400
+ raw_paramset: _RAW_SCHEDULE_DICT = {}
1401
+ for profile, profile_data in schedule_data.items():
1402
+ for weekday, weekday_data in profile_data.items():
1403
+ for slot_no, slot in weekday_data.items():
1404
+ for slot_type, slot_value in slot.items():
1405
+ raw_profile_name = f"{str(profile)}_{str(slot_type)}_{str(weekday)}_{slot_no}"
1406
+ if SCHEDULER_PROFILE_PATTERN.match(raw_profile_name) is None:
1407
+ raise ValidationException(f"Not a valid profile name: {raw_profile_name}")
1408
+ raw_value: float | int = cast(float | int, slot_value)
1409
+ if slot_type == ScheduleSlotType.ENDTIME and isinstance(slot_value, str):
1410
+ raw_value = _convert_time_str_to_minutes(time_str=slot_value)
1411
+ raw_paramset[raw_profile_name] = raw_value
1412
+ return raw_paramset
1413
+
1414
+
1415
+ def _add_to_schedule_data(
1416
+ *,
1417
+ schedule_data: _SCHEDULE_DICT,
1418
+ profile: ScheduleProfile,
1419
+ weekday: ScheduleWeekday,
1420
+ slot_no: int,
1421
+ slot_type: ScheduleSlotType,
1422
+ slot_value: str | float,
1423
+ ) -> None:
1424
+ """Add or update schedule slot."""
1425
+ if profile not in schedule_data:
1426
+ schedule_data[profile] = {}
1427
+ if weekday not in schedule_data[profile]:
1428
+ schedule_data[profile][weekday] = {}
1429
+ if slot_no not in schedule_data[profile][weekday]:
1430
+ schedule_data[profile][weekday][slot_no] = {}
1431
+ if slot_type not in schedule_data[profile][weekday][slot_no]:
1432
+ if slot_type == ScheduleSlotType.ENDTIME and isinstance(slot_value, int):
1433
+ slot_value = _convert_minutes_to_time_str(slot_value)
1434
+ schedule_data[profile][weekday][slot_no][slot_type] = slot_value
1435
+
1436
+
1437
+ def make_simple_thermostat(
1438
+ *,
1439
+ channel: hmd.Channel,
1440
+ custom_config: CustomConfig,
1441
+ ) -> None:
1442
+ """Create SimpleRfThermostat data point."""
1443
+ hmed.make_custom_data_point(
1444
+ channel=channel,
1445
+ data_point_class=CustomDpSimpleRfThermostat,
1446
+ device_profile=DeviceProfile.SIMPLE_RF_THERMOSTAT,
1447
+ custom_config=custom_config,
1448
+ )
1449
+
1450
+
1451
+ def make_thermostat(
1452
+ *,
1453
+ channel: hmd.Channel,
1454
+ custom_config: CustomConfig,
1455
+ ) -> None:
1456
+ """Create RfThermostat data point."""
1457
+ hmed.make_custom_data_point(
1458
+ channel=channel,
1459
+ data_point_class=CustomDpRfThermostat,
1460
+ device_profile=DeviceProfile.RF_THERMOSTAT,
1461
+ custom_config=custom_config,
1462
+ )
1463
+
1464
+
1465
+ def make_thermostat_group(
1466
+ *,
1467
+ channel: hmd.Channel,
1468
+ custom_config: CustomConfig,
1469
+ ) -> None:
1470
+ """Create RfThermostat group data point."""
1471
+ hmed.make_custom_data_point(
1472
+ channel=channel,
1473
+ data_point_class=CustomDpRfThermostat,
1474
+ device_profile=DeviceProfile.RF_THERMOSTAT_GROUP,
1475
+ custom_config=custom_config,
1476
+ )
1477
+
1478
+
1479
+ def make_ip_thermostat(
1480
+ *,
1481
+ channel: hmd.Channel,
1482
+ custom_config: CustomConfig,
1483
+ ) -> None:
1484
+ """Create IPThermostat data point."""
1485
+ hmed.make_custom_data_point(
1486
+ channel=channel,
1487
+ data_point_class=CustomDpIpThermostat,
1488
+ device_profile=DeviceProfile.IP_THERMOSTAT,
1489
+ custom_config=custom_config,
1490
+ )
1491
+
1492
+
1493
+ def make_ip_thermostat_group(
1494
+ *,
1495
+ channel: hmd.Channel,
1496
+ custom_config: CustomConfig,
1497
+ ) -> None:
1498
+ """Create IPThermostat group data point."""
1499
+ hmed.make_custom_data_point(
1500
+ channel=channel,
1501
+ data_point_class=CustomDpIpThermostat,
1502
+ device_profile=DeviceProfile.IP_THERMOSTAT_GROUP,
1503
+ custom_config=custom_config,
1504
+ )
1505
+
1506
+
1507
+ # Case for device model is not relevant.
1508
+ # HomeBrew (HB-) devices are always listed as HM-.
1509
+ DEVICES: Mapping[str, CustomConfig | tuple[CustomConfig, ...]] = {
1510
+ "ALPHA-IP-RBG": CustomConfig(make_ce_func=make_ip_thermostat),
1511
+ "BC-RT-TRX-CyG": CustomConfig(make_ce_func=make_thermostat),
1512
+ "BC-RT-TRX-CyN": CustomConfig(make_ce_func=make_thermostat),
1513
+ "BC-TC-C-WM": CustomConfig(make_ce_func=make_thermostat),
1514
+ "HM-CC-RT-DN": CustomConfig(make_ce_func=make_thermostat, channels=(4,)),
1515
+ "HM-CC-TC": CustomConfig(make_ce_func=make_simple_thermostat),
1516
+ "HM-CC-VG-1": CustomConfig(make_ce_func=make_thermostat_group),
1517
+ "HM-TC-IT-WM-W-EU": CustomConfig(make_ce_func=make_thermostat, channels=(2,)),
1518
+ "HmIP-BWTH": CustomConfig(make_ce_func=make_ip_thermostat),
1519
+ "HmIP-HEATING": CustomConfig(make_ce_func=make_ip_thermostat_group),
1520
+ "HmIP-STH": CustomConfig(make_ce_func=make_ip_thermostat),
1521
+ "HmIP-WTH": CustomConfig(make_ce_func=make_ip_thermostat),
1522
+ "HmIP-WGT": CustomConfig(make_ce_func=make_ip_thermostat, channels=(8,)),
1523
+ "HmIP-eTRV": CustomConfig(make_ce_func=make_ip_thermostat),
1524
+ "HmIPW-SCTHD": CustomConfig(make_ce_func=make_ip_thermostat),
1525
+ "HmIPW-STH": CustomConfig(make_ce_func=make_ip_thermostat),
1526
+ "HmIPW-WTH": CustomConfig(make_ce_func=make_ip_thermostat),
1527
+ "Thermostat AA": CustomConfig(make_ce_func=make_ip_thermostat),
1528
+ "ZEL STG RM FWT": CustomConfig(make_ce_func=make_simple_thermostat),
1529
+ }
1530
+ hmed.ALL_DEVICES[DataPointCategory.CLIMATE] = DEVICES
1531
+ BLACKLISTED_DEVICES: tuple[str, ...] = ("HmIP-STHO",)
1532
+ hmed.ALL_BLACKLISTED_DEVICES.append(BLACKLISTED_DEVICES)