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.
- aiohomematic/__init__.py +61 -0
- aiohomematic/async_support.py +212 -0
- aiohomematic/central/__init__.py +2309 -0
- aiohomematic/central/decorators.py +155 -0
- aiohomematic/central/rpc_server.py +295 -0
- aiohomematic/client/__init__.py +1848 -0
- aiohomematic/client/_rpc_errors.py +81 -0
- aiohomematic/client/json_rpc.py +1326 -0
- aiohomematic/client/rpc_proxy.py +311 -0
- aiohomematic/const.py +1127 -0
- aiohomematic/context.py +18 -0
- aiohomematic/converter.py +108 -0
- aiohomematic/decorators.py +302 -0
- aiohomematic/exceptions.py +164 -0
- aiohomematic/hmcli.py +186 -0
- aiohomematic/model/__init__.py +140 -0
- aiohomematic/model/calculated/__init__.py +84 -0
- aiohomematic/model/calculated/climate.py +290 -0
- aiohomematic/model/calculated/data_point.py +327 -0
- aiohomematic/model/calculated/operating_voltage_level.py +299 -0
- aiohomematic/model/calculated/support.py +234 -0
- aiohomematic/model/custom/__init__.py +177 -0
- aiohomematic/model/custom/climate.py +1532 -0
- aiohomematic/model/custom/cover.py +792 -0
- aiohomematic/model/custom/data_point.py +334 -0
- aiohomematic/model/custom/definition.py +871 -0
- aiohomematic/model/custom/light.py +1128 -0
- aiohomematic/model/custom/lock.py +394 -0
- aiohomematic/model/custom/siren.py +275 -0
- aiohomematic/model/custom/support.py +41 -0
- aiohomematic/model/custom/switch.py +175 -0
- aiohomematic/model/custom/valve.py +114 -0
- aiohomematic/model/data_point.py +1123 -0
- aiohomematic/model/device.py +1445 -0
- aiohomematic/model/event.py +208 -0
- aiohomematic/model/generic/__init__.py +217 -0
- aiohomematic/model/generic/action.py +34 -0
- aiohomematic/model/generic/binary_sensor.py +30 -0
- aiohomematic/model/generic/button.py +27 -0
- aiohomematic/model/generic/data_point.py +171 -0
- aiohomematic/model/generic/dummy.py +147 -0
- aiohomematic/model/generic/number.py +76 -0
- aiohomematic/model/generic/select.py +39 -0
- aiohomematic/model/generic/sensor.py +74 -0
- aiohomematic/model/generic/switch.py +54 -0
- aiohomematic/model/generic/text.py +29 -0
- aiohomematic/model/hub/__init__.py +333 -0
- aiohomematic/model/hub/binary_sensor.py +24 -0
- aiohomematic/model/hub/button.py +28 -0
- aiohomematic/model/hub/data_point.py +340 -0
- aiohomematic/model/hub/number.py +39 -0
- aiohomematic/model/hub/select.py +49 -0
- aiohomematic/model/hub/sensor.py +37 -0
- aiohomematic/model/hub/switch.py +44 -0
- aiohomematic/model/hub/text.py +30 -0
- aiohomematic/model/support.py +586 -0
- aiohomematic/model/update.py +143 -0
- aiohomematic/property_decorators.py +496 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +92 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
- aiohomematic/rega_scripts/set_program_state.fn +12 -0
- aiohomematic/rega_scripts/set_system_variable.fn +15 -0
- aiohomematic/store/__init__.py +34 -0
- aiohomematic/store/dynamic.py +551 -0
- aiohomematic/store/persistent.py +988 -0
- aiohomematic/store/visibility.py +812 -0
- aiohomematic/support.py +664 -0
- aiohomematic/validator.py +112 -0
- aiohomematic-2025.11.3.dist-info/METADATA +144 -0
- aiohomematic-2025.11.3.dist-info/RECORD +77 -0
- aiohomematic-2025.11.3.dist-info/WHEEL +5 -0
- aiohomematic-2025.11.3.dist-info/entry_points.txt +2 -0
- aiohomematic-2025.11.3.dist-info/licenses/LICENSE +21 -0
- 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)
|