diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 4a8368f..0ce7f6a 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -1067,9 +1067,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): save_state() @overrides - async def async_set_preset_mode( - self, preset_mode: str, overwrite_saved_preset=True - ): + async def async_set_preset_mode(self, preset_mode: str, overwrite_saved_preset=True): """Set new preset mode.""" # We accept a new preset when: @@ -1097,14 +1095,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): return - await self.async_set_preset_mode_internal( - preset_mode, force=False, overwrite_saved_preset=overwrite_saved_preset - ) + await self.async_set_preset_mode_internal(preset_mode, force=False, overwrite_saved_preset=overwrite_saved_preset) await self.async_control_heating(force=True) - async def async_set_preset_mode_internal( - self, preset_mode: str, force=False, overwrite_saved_preset=True - ): + async def async_set_preset_mode_internal(self, preset_mode: str, force=False, overwrite_saved_preset=True): """Set new preset mode.""" _LOGGER.info("%s - Set preset_mode: %s force=%s", self, preset_mode, force) if ( @@ -1573,9 +1567,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): and HVACMode.HEAT in self.hvac_modes ): await self.async_set_hvac_mode(HVACMode.HEAT) - await self.async_set_preset_mode( - PRESET_FROST_PROTECTION, overwrite_saved_preset=False - ) + await self.async_set_preset_mode(PRESET_FROST_PROTECTION, overwrite_saved_preset=False) else: self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL) await self.async_set_hvac_mode(HVACMode.OFF) @@ -1800,9 +1792,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): # If the changed preset is active, change the current temperature # Issue #119 - reload new preset temperature also in ac mode if preset.startswith(self._attr_preset_mode): - await self.async_set_preset_mode_internal( - preset.rstrip(PRESET_AC_SUFFIX), force=True - ) + await self.async_set_preset_mode_internal(preset.rstrip(PRESET_AC_SUFFIX), force=True) await self.async_control_heating(force=True) async def SERVICE_SET_SAFETY( diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index f857a3a..276afae 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -3,7 +3,7 @@ import logging from datetime import timedelta, datetime -from homeassistant.const import STATE_ON +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers.event import ( async_track_state_change_event, @@ -614,7 +614,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): return # Find the underlying which have change - under = self.find_underlying_by_entity_id(new_state.entity_id) + under: UnderlyingClimate = self.find_underlying_by_entity_id(new_state.entity_id) if not under: _LOGGER.warning( @@ -626,6 +626,16 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): new_hvac_mode = new_state.state old_state = event.data.get("old_state") + + # Issue #829 - refresh underlying command if it comes back to life + if old_state is not None and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) and old_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + _LOGGER.warning("%s - underlying %s come back to life. New state=%s, old_state=%s. Will refresh its status", self, under.entity_id, new_state.state, old_state.state) + # Force hvac_mode and target temperature + await under.set_hvac_mode(self.hvac_mode) + await self._send_regulated_temperature(force=True) + + return + old_hvac_action = ( old_state.attributes.get("hvac_action") if old_state and old_state.attributes diff --git a/tests/commons.py b/tests/commons.py index 3dde038..757080c 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -87,7 +87,6 @@ FULL_SWITCH_CONFIG = ( | MOCK_TH_OVER_SWITCH_CENTRAL_MAIN_CONFIG | MOCK_TH_OVER_SWITCH_TYPE_CONFIG | MOCK_TH_OVER_SWITCH_TPI_CONFIG - # | MOCK_PRESETS_CONFIG | MOCK_FULL_FEATURES | MOCK_WINDOW_CONFIG | MOCK_MOTION_CONFIG @@ -111,12 +110,7 @@ FULL_SWITCH_AC_CONFIG = ( ) PARTIAL_CLIMATE_CONFIG = ( - MOCK_TH_OVER_CLIMATE_USER_CONFIG - | MOCK_TH_OVER_CLIMATE_MAIN_CONFIG - | MOCK_TH_OVER_CLIMATE_CENTRAL_MAIN_CONFIG - | MOCK_TH_OVER_CLIMATE_TYPE_CONFIG - # | MOCK_PRESETS_CONFIG - | MOCK_ADVANCED_CONFIG + MOCK_TH_OVER_CLIMATE_USER_CONFIG | MOCK_TH_OVER_CLIMATE_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_CENTRAL_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_TYPE_CONFIG | MOCK_ADVANCED_CONFIG ) PARTIAL_CLIMATE_CONFIG_USE_DEVICE_TEMP = ( @@ -124,7 +118,6 @@ PARTIAL_CLIMATE_CONFIG_USE_DEVICE_TEMP = ( | MOCK_TH_OVER_CLIMATE_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_CENTRAL_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_TYPE_USE_DEVICE_TEMP_CONFIG - # | MOCK_PRESETS_CONFIG | MOCK_ADVANCED_CONFIG ) @@ -133,24 +126,17 @@ PARTIAL_CLIMATE_NOT_REGULATED_CONFIG = ( | MOCK_TH_OVER_CLIMATE_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_CENTRAL_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG - # | MOCK_PRESETS_CONFIG | MOCK_ADVANCED_CONFIG ) PARTIAL_CLIMATE_AC_CONFIG = ( - MOCK_TH_OVER_CLIMATE_USER_CONFIG - | MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG - | MOCK_TH_OVER_CLIMATE_MAIN_CONFIG - | MOCK_TH_OVER_CLIMATE_CENTRAL_MAIN_CONFIG - # | MOCK_PRESETS_CONFIG - | MOCK_ADVANCED_CONFIG + MOCK_TH_OVER_CLIMATE_USER_CONFIG | MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG | MOCK_TH_OVER_CLIMATE_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_CENTRAL_MAIN_CONFIG | MOCK_ADVANCED_CONFIG ) FULL_4SWITCH_CONFIG = ( MOCK_TH_OVER_4SWITCH_USER_CONFIG | MOCK_TH_OVER_4SWITCH_TYPE_CONFIG | MOCK_TH_OVER_SWITCH_TPI_CONFIG - # | MOCK_PRESETS_CONFIG | MOCK_WINDOW_CONFIG | MOCK_MOTION_CONFIG | MOCK_POWER_CONFIG @@ -1037,6 +1023,16 @@ default_temperatures_ac_away = { "boost_ac_away": 23.1, } +default_temperatures_ac = { + "frost": 7.0, + "eco": 17.0, + "comfort": 19.0, + "boost": 21.0, + "eco_ac": 27.0, + "comfort_ac": 25.0, + "boost_ac": 23.0, +} + default_temperatures_away = { "frost": 7.0, "eco": 17.0, diff --git a/tests/test_overclimate.py b/tests/test_overclimate.py index 30c27ce..ad317a5 100644 --- a/tests/test_overclimate.py +++ b/tests/test_overclimate.py @@ -1233,3 +1233,111 @@ async def test_manual_hvac_off_should_take_the_lead_over_auto_start_stop( assert vtherm.hvac_off_reason == HVAC_OFF_REASON_MANUAL assert vtherm._saved_hvac_mode == HVACMode.OFF assert mock_send_event.call_count == 0 # nothing have change + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_underlying_from_comes_back_to_life( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test that when a underlying climate comes back to life (from unkwown or unavailable) the last state is send""" + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_UNDERLYING_LIST: ["climate.mock_climate"], + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SAFETY_DELAY_MIN: 5, + CONF_SAFETY_MIN_ON_PERCENT: 0.3, + CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_NONE, + CONF_AC_MODE: True, + }, # 5 minutes security delay + ) + + # Underlying is in HEAT mode but should be shutdown at startup + fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.COOL, HVACAction.COOLING) + + # 1. initialize the vtherm in COOL with Boost + # fmt: off + with patch("custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",return_value=fake_underlying_climate) as mock_find_climate: + # fmt: on + entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname", temps=default_temperatures_ac) + + assert entity + assert entity.name == "TheOverClimateMockName" + assert entity.is_over_climate is True + + # Set hvac_mode to COOL + await entity.async_set_hvac_mode(HVACMode.COOL) + await entity.async_set_preset_mode(PRESET_BOOST) + + # it is very hot today + await send_temperature_change_event(entity, 27, now, False) + await send_ext_temperature_change_event(entity, 35, now, False) + await hass.async_block_till_done() + + assert entity.hvac_mode is HVACMode.COOL + # because in MockClimate HVACAction is HEATING if hvac_mode is not set + assert entity.hvac_action is HVACAction.COOLING + assert entity.preset_mode is PRESET_BOOST + assert entity.target_temperature == 23 + + + # 2. send under state event comes back from life + # fmt: off + with patch("custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode") as mock_underlying_set_hvac_mode, \ + patch("custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_temperature") as mock_underlying_set_temperature: + # fmt: on + now = now + timedelta(minutes=2) + # 2. Change the target temp of underlying thermostat at now -> the event will be disgarded because to fast (to avoid loop cf issue 121) + await send_climate_change_event_with_temperature( + entity, + HVACMode.HEAT, + STATE_UNKNOWN, + HVACAction.OFF, + STATE_UNKNOWN, + now, + entity.min_temp + 1, + True, + "climate.mock_climate", # the underlying climate entity id + ) + + assert mock_underlying_set_hvac_mode.call_count == 1 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.COOL), + ] + ) + + assert mock_underlying_set_temperature.call_count == 1 + mock_underlying_set_temperature.assert_has_calls( + [ + call.set_temperature(23, 30, 15), + ] + ) + + + # Nothing should have changed + assert entity.target_temperature == 23 + assert entity.preset_mode is PRESET_BOOST + assert entity.hvac_mode is HVACMode.COOL