From b46a24f834b7c7dd969ce278616dbdc597dcd140 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Wed, 13 Nov 2024 19:14:03 +0100 Subject: [PATCH] Issue #628 add follow underlying temp change entity (#630) * First commit (no test) * With tests ok --------- Co-authored-by: Jean-Marc Collin --- .../versatile_thermostat/manifest.json | 2 +- .../versatile_thermostat/switch.py | 74 +++++++++++- .../thermostat_climate.py | 22 +++- tests/test_overclimate.py | 110 +++++++++++++++++- 4 files changed, 200 insertions(+), 8 deletions(-) diff --git a/custom_components/versatile_thermostat/manifest.json b/custom_components/versatile_thermostat/manifest.json index 53e4a30..5631d54 100644 --- a/custom_components/versatile_thermostat/manifest.json +++ b/custom_components/versatile_thermostat/manifest.json @@ -14,6 +14,6 @@ "quality_scale": "silver", "requirements": [], "ssdp": [], - "version": "6.6.0", + "version": "6.7.0", "zeroconf": [] } \ No newline at end of file diff --git a/custom_components/versatile_thermostat/switch.py b/custom_components/versatile_thermostat/switch.py index c414f2c..754eaef 100644 --- a/custom_components/versatile_thermostat/switch.py +++ b/custom_components/versatile_thermostat/switch.py @@ -34,10 +34,16 @@ async def async_setup_entry( vt_type = entry.data.get(CONF_THERMOSTAT_TYPE) auto_start_stop_feature = entry.data.get(CONF_USE_AUTO_START_STOP_FEATURE) - if vt_type == CONF_THERMOSTAT_CLIMATE and auto_start_stop_feature is True: - # Creates a switch to enable the auto-start/stop - enable_entity = AutoStartStopEnable(hass, unique_id, name, entry) - async_add_entities([enable_entity], True) + entities = [] + if vt_type == CONF_THERMOSTAT_CLIMATE: + entities.append(FollowUnderlyingTemperatureChange(hass, unique_id, name, entry)) + + if auto_start_stop_feature is True: + # Creates a switch to enable the auto-start/stop + enable_entity = AutoStartStopEnable(hass, unique_id, name, entry) + entities.append(enable_entity) + + async_add_entities(entities, True) class AutoStartStopEnable(VersatileThermostatBaseEntity, SwitchEntity, RestoreEntity): @@ -100,3 +106,63 @@ class AutoStartStopEnable(VersatileThermostatBaseEntity, SwitchEntity, RestoreEn def turn_on(self, **kwargs: Any): self._attr_is_on = True self.update_my_state_and_vtherm() + + +class FollowUnderlyingTemperatureChange( + VersatileThermostatBaseEntity, SwitchEntity, RestoreEntity +): + """The that enables the ManagedDevice optimisation with""" + + def __init__( + self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigEntry + ): + super().__init__(hass, unique_id, name) + self._attr_name = "Follow underlying temp change" + self._attr_unique_id = f"{self._device_name}_follow_underlying_temp_change" + self._attr_is_on = False + + @property + def icon(self) -> str | None: + """The icon""" + return "mdi:content-copy" + + async def async_added_to_hass(self): + await super().async_added_to_hass() + + # Récupérer le dernier état sauvegardé de l'entité + last_state = await self.async_get_last_state() + + # Si l'état précédent existe, vous pouvez l'utiliser + if last_state is not None: + self._attr_is_on = last_state.state == "on" + else: + # If no previous state set it to false by default + self._attr_is_on = False + + self.update_my_state_and_vtherm() + + def update_my_state_and_vtherm(self): + """Update the follow flag in my VTherm""" + self.async_write_ha_state() + if self.my_climate is not None: + self.my_climate.set_follow_underlying_temp_change(self._attr_is_on) + + @callback + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + self.turn_on() + + @callback + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + self.turn_off() + + @overrides + def turn_off(self, **kwargs: Any): + self._attr_is_on = False + self.update_my_state_and_vtherm() + + @overrides + def turn_on(self, **kwargs: Any): + self._attr_is_on = True + self.update_my_state_and_vtherm() diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 5e8dc0d..6a9ba20 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -62,6 +62,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): _auto_start_stop_level: TYPE_AUTO_START_STOP_LEVELS = AUTO_START_STOP_LEVEL_NONE _auto_start_stop_algo: AutoStartStopDetectionAlgorithm | None = None _is_auto_start_stop_enabled: bool = False + _follow_underlying_temp_change: bool = False _entity_component_unrecorded_attributes = ( BaseThermostat._entity_component_unrecorded_attributes.union( @@ -82,6 +83,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): "auto_start_stop_enable", "auto_start_stop_accumulated_error", "auto_start_stop_accumulated_error_threshold", + "follow_underlying_temp_change", } ) ) @@ -552,6 +554,10 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): "auto_start_stop_accumulated_error_threshold" ] = self._auto_start_stop_algo.accumulated_error_threshold + self._attr_extra_state_attributes["follow_underlying_temp_change"] = ( + self._follow_underlying_temp_change + ) + self.async_write_ha_state() _LOGGER.debug( @@ -853,7 +859,11 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): # try to manage new target temperature set if state if no other changes have been found # and if a target temperature have already been sent - if not changes and under.last_sent_temperature is not None: + if ( + self._follow_underlying_temp_change + and not changes + and under.last_sent_temperature is not None + ): _LOGGER.debug( "Do temperature check. under.last_sent_temperature is %s, new_target_temp is %s", under.last_sent_temperature, @@ -972,6 +982,11 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): self._is_auto_start_stop_enabled = is_enabled self.update_custom_attributes() + def set_follow_underlying_temp_change(self, follow: bool): + """Set the flaf follow the underlying temperature changes""" + self._follow_underlying_temp_change = follow + self.update_custom_attributes() + @property def auto_regulation_mode(self) -> str | None: """Get the regulation mode""" @@ -1128,6 +1143,11 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): """Returns the auto_start_stop_enable""" return self._is_auto_start_stop_enabled + @property + def follow_underlying_temp_change(self) -> bool: + """Get the follow underlying temp change flag""" + return self._follow_underlying_temp_change + @overrides def init_underlyings(self): """Init the underlyings if not already done""" diff --git a/tests/test_overclimate.py b/tests/test_overclimate.py index a36ee73..6bf0a66 100644 --- a/tests/test_overclimate.py +++ b/tests/test_overclimate.py @@ -17,6 +17,10 @@ from custom_components.versatile_thermostat.thermostat_climate import ( ThermostatOverClimate, ) +from custom_components.versatile_thermostat.switch import ( + FollowUnderlyingTemperatureChange, +) + from .commons import * logging.getLogger().setLevel(logging.DEBUG) @@ -197,7 +201,7 @@ async def test_bug_82( @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_bug_101( +async def test_underlying_change_follow( hass: HomeAssistant, skip_hass_states_is_state, skip_turn_on_off_heater, @@ -231,12 +235,27 @@ async def test_bug_101( entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname") assert entity - assert entity.name == "TheOverClimateMockName" assert entity.is_over_climate is True assert entity.hvac_mode is HVACMode.OFF # because in MockClimate HVACAction is HEATING if hvac_mode is not set assert entity.hvac_action is HVACAction.HEATING + assert entity.follow_underlying_temp_change is False + + follow_entity: FollowUnderlyingTemperatureChange = search_entity( + hass, + "switch.theoverclimatemockname_follow_underlying_temp_change", + SWITCH_DOMAIN, + ) + assert follow_entity is not None + assert follow_entity.state is STATE_OFF + + # follow the underlying temp change + follow_entity.turn_on() + + assert entity.follow_underlying_temp_change is True + assert follow_entity.state is STATE_ON + # Underlying should have been shutdown assert mock_underlying_set_hvac_mode.call_count == 1 mock_underlying_set_hvac_mode.assert_has_calls( @@ -322,6 +341,93 @@ async def test_bug_101( assert entity.preset_mode is PRESET_NONE +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_underlying_change_not_follow( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test that when a underlying climate target temp is changed, the VTherm change its own temperature target and switch to manual""" + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data=PARTIAL_CLIMATE_NOT_REGULATED_CONFIG, # 5 minutes security delay + ) + + # Underlying is in HEAT mode but should be shutdown at startup + fake_underlying_climate = MockClimate( + hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT, HVACAction.HEATING + ) + + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ) as mock_find_climate, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" + ) as mock_underlying_set_hvac_mode: + entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname") + + assert entity + + assert entity.name == "TheOverClimateMockName" + assert entity.is_over_climate is True + assert entity.hvac_mode is HVACMode.OFF + # because in MockClimate HVACAction is HEATING if hvac_mode is not set + assert entity.hvac_action is HVACAction.HEATING + assert entity.target_temperature == 15 + assert entity.preset_mode is PRESET_NONE + + # default value + assert entity.follow_underlying_temp_change is False + + follow_entity: FollowUnderlyingTemperatureChange = search_entity( + hass, + "switch.theoverclimatemockname_follow_underlying_temp_change", + SWITCH_DOMAIN, + ) + assert follow_entity is not None + assert follow_entity.state is STATE_OFF + + # follow the underlying temp change + follow_entity.turn_off() + + assert entity.follow_underlying_temp_change is False + assert follow_entity.state is STATE_OFF + + # 1. Force preset mode + await entity.async_set_hvac_mode(HVACMode.HEAT) + assert entity.hvac_mode == HVACMode.HEAT + await entity.async_set_preset_mode(PRESET_COMFORT) + assert entity.preset_mode == PRESET_COMFORT + assert entity.target_temperature == 17 + + # 2. Change the target temp of underlying thermostat at 11 sec later to avoid temporal filter + event_timestamp = now + timedelta(seconds=30) + await send_climate_change_event_with_temperature( + entity, + HVACMode.HEAT, + HVACMode.HEAT, + HVACAction.OFF, + HVACAction.OFF, + event_timestamp, + 21, + True, + "climate.mock_climate", # the underlying climate entity id + ) + # Should NOT have been switched to Manual preset + assert entity.target_temperature == 17 + assert entity.preset_mode is PRESET_COMFORT + + @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_bug_615(