diff --git a/changelog.md b/changelog.md index ddca26f9..2534867e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,6 @@ # Version 2025.1.18 (2025-01-28) +- Add climate presets based on WEEK_PROGRAM_POINTER - Add WEEK_PROGRAM_POINTER for bidcos climate devices - Define schedule_channel_address for HM schedule usage - Fix usage of master dps for bidcos climate devices diff --git a/hahomematic/model/custom/climate.py b/hahomematic/model/custom/climate.py index 93365953..3325ea5d 100644 --- a/hahomematic/model/custom/climate.py +++ b/hahomematic/model/custom/climate.py @@ -109,6 +109,17 @@ class ClimateProfile(StrEnum): WEEK_PROGRAM_6 = "week_program_6" +_HM_WEEK_PROFILE_POINTERS_TO_NAMES: Final = { + 0: "WEEK PROGRAM 1", + 1: "WEEK PROGRAM 2", + 2: "WEEK PROGRAM 3", + 3: "WEEK PROGRAM 4", + 4: "WEEK PROGRAM 5", + 5: "WEEK PROGRAM 6", +} +_HM_WEEK_PROFILE_POINTERS_TO_IDX: Final = {v: k for k, v in _HM_WEEK_PROFILE_POINTERS_TO_NAMES.items()} + + class ScheduleSlotType(StrEnum): """Enum for climate item type.""" @@ -707,17 +718,17 @@ def profile(self) -> ClimateProfile: return ClimateProfile.BOOST if self._dp_control_mode.value == _ModeHm.AWAY: return ClimateProfile.AWAY + if self.mode == ClimateMode.AUTO: + return self._current_profile_name if self._current_profile_name else ClimateProfile.NONE return ClimateProfile.NONE @state_property def profiles(self) -> tuple[ClimateProfile, ...]: """Return available profile.""" - return ( - ClimateProfile.BOOST, - ClimateProfile.COMFORT, - ClimateProfile.ECO, - ClimateProfile.NONE, - ) + control_modes = [ClimateProfile.BOOST, ClimateProfile.COMFORT, ClimateProfile.ECO, ClimateProfile.NONE] + if self.mode == ClimateMode.AUTO: + control_modes.extend(self._profile_names) + return tuple(control_modes) @property def supports_profiles(self) -> bool: @@ -755,6 +766,14 @@ async def set_profile(self, profile: ClimateProfile, collector: CallParameterCol await self._dp_comfort_mode.send_value(value=True, collector=collector) elif profile == ClimateProfile.ECO: await self._dp_lowering_mode.send_value(value=True, collector=collector) + elif profile in self._profile_names: + if self.mode != ClimateMode.AUTO: + await self.set_mode(mode=ClimateMode.AUTO, collector=collector) + await self._dp_boost_mode.send_value(value=False, collector=collector) + if profile_idx := self._profiles.get(profile): + await self._dp_week_program_pointer.send_value( + value=_HM_WEEK_PROFILE_POINTERS_TO_NAMES[profile_idx], collector=collector + ) @inspector() async def enable_away_mode_by_calendar(self, start: datetime, end: datetime, away_temperature: float) -> None: @@ -786,6 +805,34 @@ async def disable_away_mode(self) -> None: value=_party_mode_code(start=start, end=end, away_temperature=12.0), ) + @property + def _profile_names(self) -> tuple[ClimateProfile, ...]: + """Return a collection of profile names.""" + return tuple(self._profiles.keys()) + + @property + def _current_profile_name(self) -> ClimateProfile | None: + """Return a profile index by name.""" + inv_profiles = {v: k for k, v in self._profiles.items()} + if self._dp_week_program_pointer.value is not None: + idx = ( + int(self._dp_week_program_pointer.value) + if self._dp_week_program_pointer.value.isnumeric() + else _HM_WEEK_PROFILE_POINTERS_TO_IDX[self._dp_week_program_pointer.value] + ) + return inv_profiles.get(idx) + return None + + @property + def _profiles(self) -> Mapping[ClimateProfile, int]: + """Return the profile groups.""" + profiles: dict[ClimateProfile, int] = {} + if self._dp_week_program_pointer.min is not None and self._dp_week_program_pointer.max is not None: + for i in range(int(self._dp_week_program_pointer.min) + 1, int(self._dp_week_program_pointer.max) + 2): + profiles[ClimateProfile(f"{PROFILE_PREFIX}{i}")] = i - 1 + + return profiles + def _party_mode_code(start: datetime, end: datetime, away_temperature: float) -> str: """ diff --git a/tests/test_climate.py b/tests/test_climate.py index 90c81da9..9da5156f 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -305,6 +305,181 @@ async def test_cerfthermostat( ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ( + "address_device_translation", + "do_mock_client", + "add_sysvars", + "add_programs", + "ignore_devices_on_create", + "un_ignore_list", + ), + [ + (TEST_DEVICES, True, False, False, None, None), + ], +) +async def test_cerfthermostat_with_profiles( + central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory], +) -> None: + """Test CustomDpRfThermostat.""" + central, mock_client, _ = central_client_factory + climate: CustomDpRfThermostat = cast( + CustomDpRfThermostat, helper.get_prepared_custom_data_point(central, "VCU0000341", 2) + ) + assert climate.usage == DataPointUsage.CDP_PRIMARY + assert climate.service_method_names == ( + "copy_schedule", + "copy_schedule_profile", + "disable_away_mode", + "enable_away_mode_by_calendar", + "enable_away_mode_by_duration", + "get_schedule_profile", + "get_schedule_profile_weekday", + "set_mode", + "set_profile", + "set_schedule_profile", + "set_schedule_profile_weekday", + "set_simple_schedule_profile", + "set_simple_schedule_profile_weekday", + "set_temperature", + ) + assert climate.min_temp == 5.0 + assert climate.max_temp == 30.5 + assert climate.supports_profiles is True + assert climate.target_temperature_step == 0.5 + assert climate.profile == ClimateProfile.NONE + assert climate.activity is None + assert climate.current_humidity is None + assert climate.target_temperature is None + await climate.set_temperature(12.0) + assert mock_client.method_calls[-1] == call.set_value( + channel_address="VCU0000341:2", + paramset_key="VALUES", + parameter="SET_TEMPERATURE", + value=12.0, + wait_for_callback=WAIT_FOR_CALLBACK, + ) + assert climate.target_temperature == 12.0 + + assert climate.current_temperature is None + await central.data_point_event(const.INTERFACE_ID, "VCU0000341:2", "ACTUAL_TEMPERATURE", 11.0) + assert climate.current_temperature == 11.0 + + assert climate.mode == ClimateMode.AUTO + assert climate.modes == (ClimateMode.AUTO, ClimateMode.HEAT, ClimateMode.OFF) + await climate.set_mode(ClimateMode.HEAT) + assert mock_client.method_calls[-1] == call.set_value( + channel_address="VCU0000341:2", + paramset_key="VALUES", + parameter="MANU_MODE", + value=12.0, + wait_for_callback=WAIT_FOR_CALLBACK, + ) + await central.data_point_event(const.INTERFACE_ID, "VCU0000341:2", "CONTROL_MODE", _ModeHmIP.MANU.value) + assert climate.mode == ClimateMode.HEAT + + await climate.set_mode(ClimateMode.OFF) + assert mock_client.method_calls[-1] == call.put_paramset( + channel_address="VCU0000341:2", + paramset_key="VALUES", + values={"MANU_MODE": 12.0, "SET_TEMPERATURE": 4.5}, + wait_for_callback=WAIT_FOR_CALLBACK, + ) + + assert climate.mode == ClimateMode.OFF + + await climate.set_mode(ClimateMode.AUTO) + assert mock_client.method_calls[-1] == call.set_value( + channel_address="VCU0000341:2", + paramset_key="VALUES", + parameter="AUTO_MODE", + value=True, + wait_for_callback=WAIT_FOR_CALLBACK, + ) + await central.data_point_event(const.INTERFACE_ID, "VCU0000341:2", "CONTROL_MODE", 0) + await central.data_point_event(const.INTERFACE_ID, "VCU0000341:2", "SET_TEMPERATURE", 24.0) + assert climate.mode == ClimateMode.AUTO + + assert climate.profile == ClimateProfile.WEEK_PROGRAM_1 + assert climate.profiles == ( + ClimateProfile.BOOST, + ClimateProfile.COMFORT, + ClimateProfile.ECO, + ClimateProfile.NONE, + ClimateProfile.WEEK_PROGRAM_1, + ClimateProfile.WEEK_PROGRAM_2, + ClimateProfile.WEEK_PROGRAM_3, + ) + await climate.set_profile(ClimateProfile.BOOST) + assert mock_client.method_calls[-1] == call.set_value( + channel_address="VCU0000341:2", + paramset_key="VALUES", + parameter="BOOST_MODE", + value=True, + wait_for_callback=WAIT_FOR_CALLBACK, + ) + await central.data_point_event(const.INTERFACE_ID, "VCU0000341:2", "CONTROL_MODE", 3) + assert climate.profile == ClimateProfile.BOOST + await central.data_point_event(const.INTERFACE_ID, "VCU0000341:2", "CONTROL_MODE", 2) + assert climate.profile == ClimateProfile.AWAY + await climate.set_profile(ClimateProfile.COMFORT) + assert mock_client.method_calls[-1] == call.set_value( + channel_address="VCU0000341:2", + paramset_key="VALUES", + parameter="COMFORT_MODE", + value=True, + wait_for_callback=WAIT_FOR_CALLBACK, + ) + await climate.set_profile(ClimateProfile.ECO) + assert mock_client.method_calls[-1] == call.set_value( + channel_address="VCU0000341:2", + paramset_key="VALUES", + parameter="LOWERING_MODE", + value=True, + wait_for_callback=WAIT_FOR_CALLBACK, + ) + + await central.data_point_event(const.INTERFACE_ID, "VCU0000341:2", "CONTROL_MODE", 3) + call_count = len(mock_client.method_calls) + await climate.set_profile(ClimateProfile.BOOST) + assert call_count == len(mock_client.method_calls) + + await climate.set_mode(ClimateMode.AUTO) + call_count = len(mock_client.method_calls) + await climate.set_mode(ClimateMode.AUTO) + assert call_count == len(mock_client.method_calls) + + with freeze_time("2023-03-03 08:00:00"): + await climate.enable_away_mode_by_duration(hours=100, away_temperature=17.0) + assert mock_client.method_calls[-1] == call.set_value( + channel_address="VCU0000341:2", + paramset_key=ParamsetKey.VALUES, + parameter="PARTY_MODE_SUBMIT", + value="17.0,470,03,03,23,720,07,03,23", + ) + + with freeze_time("2023-03-03 08:00:00"): + await climate.enable_away_mode_by_calendar( + start=datetime(2000, 12, 1), end=datetime(2024, 12, 1), away_temperature=17.0 + ) + assert mock_client.method_calls[-1] == call.set_value( + channel_address="VCU0000341:2", + paramset_key=ParamsetKey.VALUES, + parameter="PARTY_MODE_SUBMIT", + value="17.0,0,01,12,00,0,01,12,24", + ) + + with freeze_time("2023-03-03 08:00:00"): + await climate.disable_away_mode() + assert mock_client.method_calls[-1] == call.set_value( + channel_address="VCU0000341:2", + paramset_key=ParamsetKey.VALUES, + parameter="PARTY_MODE_SUBMIT", + value="12.0,1260,02,03,23,1320,02,03,23", + ) + + @pytest.mark.asyncio @pytest.mark.parametrize( (