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