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