From 73a9ca4e5389802c83b25e2e3ee4827fb8c052c1 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 13 Oct 2024 11:30:04 +0200 Subject: [PATCH] Issue #478 vtherm doesn't follow underlying (#548) * Dispatch test_bugs into each own VTherm type tests * Local tests ok * With testus ok. --------- Co-authored-by: Jean-Marc Collin --- .devcontainer/configuration.yaml | 42 +- .../versatile_thermostat/base_thermostat.py | 11 +- .../thermostat_climate.py | 77 +- .../versatile_thermostat/underlyings.py | 15 + tests/commons.py | 10 +- tests/test_bugs.py | 941 +----------------- tests/test_central_boiler.py | 77 ++ tests/test_multiple_switch.py | 1 + tests/test_overclimate.py | 508 ++++++++++ tests/test_power.py | 2 + tests/test_sensors.py | 2 + tests/test_valve.py | 169 +++- tests/test_window.py | 159 +++ 13 files changed, 1041 insertions(+), 973 deletions(-) create mode 100644 tests/test_overclimate.py diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 9ab95d7a..ac57f03b 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -1,14 +1,30 @@ default_config: +recorder: + auto_purge: true + purge_keep_days: 1 + commit_interval: 5 + include: + domains: + - input_boolean + - input_number + - switch + - climate + - sensor + - binary_sensor + - number + - input_select + - versatile_thermostat + logger: default: warning logs: - custom_components.versatile_thermostat: debug - # custom_components.versatile_thermostat.underlyings: info - # custom_components.versatile_thermostat.climate: info - # custom_components.versatile_thermostat.base_thermostat: debug - custom_components.versatile_thermostat.sensor: info - custom_components.versatile_thermostat.binary_sensor: info + custom_components.versatile_thermostat: debug + # custom_components.versatile_thermostat.underlyings: info + # custom_components.versatile_thermostat.climate: info + # custom_components.versatile_thermostat.base_thermostat: debug + custom_components.versatile_thermostat.sensor: info + custom_components.versatile_thermostat.binary_sensor: info # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) debugpy: @@ -176,20 +192,6 @@ input_datetime: has_date: true has_time: true -recorder: - commit_interval: 0 - include: - domains: - - input_boolean - - input_number - - switch - - climate - - sensor - - binary_sensor - - number - - input_select - - versatile_thermostat - template: - binary_sensor: - name: maison_occupee diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index a0193eed..44a203a3 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -15,10 +15,12 @@ callback, Event, State, + ) from homeassistant.components.climate import ClimateEntity from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.entity import Entity from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType @@ -1159,10 +1161,17 @@ def nb_underlying_entities(self) -> int: return len(self._underlyings) @property - def underlying_entities(self) -> int: + def underlying_entities(self) -> list | None: """Returns the underlying entities""" return self._underlyings + def find_underlying_by_entity_id(self, entity_id: str) -> Entity | None: + """Get the underlying entity by a entity_id""" + for under in self._underlyings: + if under.entity_id == entity_id: + return under + return None + @property def is_on(self) -> bool: """True if the VTherm is on (! HVAC_OFF)""" diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index a6525bed..ef2da2f7 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -57,6 +57,13 @@ _LOGGER = logging.getLogger(__name__) +HVAC_ACTION_ON = [ # pylint: disable=invalid-name + HVACAction.COOLING, + HVACAction.DRYING, + HVACAction.FAN, + HVACAction.HEATING, +] + class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): """Representation of a base class for a Versatile Thermostat over a climate""" @@ -636,6 +643,15 @@ async def end_climate_changed(changes: bool): if not new_state: return + # Find the underlying which have change + under = self.find_underlying_by_entity_id(new_state.entity_id) + + if not under: + _LOGGER.warning( + "We have a receive an event from entity %s which is NOT one of our underlying entities. This is not normal and should be reported to the developper of the integration" + ) + return + changes = False new_hvac_mode = new_state.state @@ -670,20 +686,44 @@ async def end_climate_changed(changes: bool): new_state.last_updated if new_state and new_state.last_updated else None ) + new_target_temp = ( + new_state.attributes.get("temperature") + if new_state and new_state.attributes + else None + ) + # Issue 99 - some AC turn hvac_mode=cool and hvac_action=idle when sending a HVACMode_OFF command # Issue 114 - Remove this because hvac_mode is now managed by local _hvac_mode and use idle action as is # if self._hvac_mode == HVACMode.OFF and new_hvac_action == HVACAction.IDLE: # _LOGGER.debug("The underlying switch to idle instead of OFF. We will consider it as OFF") # new_hvac_mode = HVACMode.OFF + # Forget event when the event holds no real changes + if ( + new_hvac_mode == self._hvac_mode + and new_hvac_action == old_hvac_action + and new_target_temp is None + and (new_fan_mode is None or new_fan_mode == self._attr_fan_mode) + ): + _LOGGER.debug( + "%s - a underlying state change event is received but no real change have been found. Forget the event", + self, + ) + return + + # A real changes have to be managed _LOGGER.info( - "%s - Underlying climate %s changed. Event.new_hvac_mode is %s, current_hvac_mode=%s, new_hvac_action=%s, old_hvac_action=%s", + "%s - Underlying climate %s have changed. new_hvac_mode is %s (vs %s), new_hvac_action=%s (vs %s), new_target_temp=%s (vs %s), new_fan_mode=%s (vs %s)", self, - new_state.entity_id, + under.entity_id, new_hvac_mode, self._hvac_mode, new_hvac_action, old_hvac_action, + new_target_temp, + self.target_temperature, + new_fan_mode, + self._attr_fan_mode, ) _LOGGER.debug( @@ -697,12 +737,6 @@ async def end_climate_changed(changes: bool): ) # Interpretation of hvac action - HVAC_ACTION_ON = [ # pylint: disable=invalid-name - HVACAction.COOLING, - HVACAction.DRYING, - HVACAction.FAN, - HVACAction.HEATING, - ] if old_hvac_action not in HVAC_ACTION_ON and new_hvac_action in HVAC_ACTION_ON: self._underlying_climate_start_hvac_action_date = ( self.get_last_updated_date_or_now(new_state) @@ -735,6 +769,7 @@ async def end_climate_changed(changes: bool): ) changes = True + # Filter new state when received just after a change from VTherm # Issue #120 - Some TRV are changing target temperature a very long time (6 sec) after the change. # In that case a loop is possible if a user change multiple times during this 6 sec. if new_state_date_updated and self._last_change_time: @@ -747,6 +782,7 @@ async def end_climate_changed(changes: bool): await end_climate_changed(changes) return + # Update all underlyings hvac_mode state if it has change if ( new_hvac_mode in [ @@ -761,7 +797,6 @@ async def end_climate_changed(changes: bool): ] and self._hvac_mode != new_hvac_mode ): - # Update all underlyings state # Issue #334 - if all underlyings are not aligned with the same hvac_mode don't change the underlying and wait they are aligned if self.is_over_climate: for under in self._underlyings: @@ -792,27 +827,31 @@ async def end_climate_changed(changes: bool): self._attr_fan_mode = new_fan_mode changes = True + # try to manage new target temperature set if state if no other changes have been found if not changes: - # try to manage new target temperature set if state _LOGGER.debug( - "Do temperature check. temperature is %s, new_state.attributes is %s", - self.target_temperature, - new_state.attributes, + "Do temperature check. under.last_sent_temperature is %s, new_target_temp is %s", + under.last_sent_temperature, + new_target_temp, ) if ( - # we do not change target temperature on regulated VTherm - not self.is_regulated - and new_state.attributes - and (new_target_temp := new_state.attributes.get("temperature")) - and new_target_temp != self.target_temperature + # if the underlying have change its target temperature + new_target_temp is not None + and new_target_temp != under.last_sent_temperature ): _LOGGER.info( - "%s - Target temp in underlying have change to %s", + "%s - Target temp in underlying have change to %s (vs %s)", self, new_target_temp, + under.last_sent_temperature, ) await self.async_set_temperature(temperature=new_target_temp) changes = True + else: + _LOGGER.debug( + "%s - Forget the eventual underlying temperature change because VTherm is regulated", + self, + ) await end_climate_changed(changes) diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index ae1dda8d..d8bdf128 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -488,6 +488,7 @@ def __init__( entity_id=climate_entity_id, ) self._underlying_climate = None + self._last_sent_temperature = None def find_underlying_climate(self) -> ClimateEntity: """Find the underlying climate entity""" @@ -637,6 +638,13 @@ async def set_temperature(self, temperature, max_temp, min_temp): data, ) + self._last_sent_temperature = target_temp + + @property + def last_sent_temperature(self) -> float | None: + """Get the last send temperature. None if no temperature have been sent yet""" + return self._last_sent_temperature + @property def hvac_action(self) -> HVACAction | None: """Get the hvac action of the underlying""" @@ -721,6 +729,13 @@ def target_temperature_low(self) -> float: return 15 return self._underlying_climate.target_temperature_low + @property + def target_temperature(self) -> float: + """Get the target_temperature""" + if not self.is_initialized: + return None + return self._underlying_climate.target_temperature + @property def is_aux_heat(self) -> bool: """Get the is_aux_heat""" diff --git a/tests/commons.py b/tests/commons.py index 14438129..51ab62df 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -1,4 +1,4 @@ -# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long, abstract-method +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long, abstract-method, too-many-lines, redefined-builtin """ Some common resources """ import asyncio @@ -931,6 +931,7 @@ async def send_climate_change_event_with_temperature( date, temperature, sleep=True, + underlying_entity_id=None, ): """Sending a new climate event simulating a change on the underlying climate state""" _LOGGER.info( @@ -943,18 +944,21 @@ async def send_climate_change_event_with_temperature( temperature, entity, ) + if not underlying_entity_id: + underlying_entity_id = entity.entity_id + climate_event = Event( EVENT_STATE_CHANGED, { "new_state": State( - entity_id=entity.entity_id, + entity_id=underlying_entity_id, state=new_hvac_mode, attributes={"hvac_action": new_hvac_action, "temperature": temperature}, last_changed=date, last_updated=date, ), "old_state": State( - entity_id=entity.entity_id, + entity_id=underlying_entity_id, state=old_hvac_mode, attributes={"hvac_action": old_hvac_action}, last_changed=date, diff --git a/tests/test_bugs.py b/tests/test_bugs.py index d79b27fe..361802e6 100644 --- a/tests/test_bugs.py +++ b/tests/test_bugs.py @@ -9,7 +9,7 @@ import logging from homeassistant.const import STATE_ON, STATE_OFF -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, ) @@ -17,7 +17,6 @@ from custom_components.versatile_thermostat.config_flow import ( VersatileThermostatBaseConfigFlow, ) -from custom_components.versatile_thermostat.thermostat_valve import ThermostatOverValve from custom_components.versatile_thermostat.thermostat_climate import ( ThermostatOverClimate, ) @@ -30,83 +29,6 @@ logging.getLogger().setLevel(logging.DEBUG) -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_bug_56( - hass: HomeAssistant, - skip_hass_states_is_state, - skip_turn_on_off_heater, - skip_send_event, -): - """Test that in over_climate mode there is no error when underlying climate is not available""" - - the_mock_underlying = MagicMockClimate() - with patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", - return_value=None, # dont find the underlying climate - ): - entry = MockConfigEntry( - domain=DOMAIN, - title="TheOverClimateMockName", - unique_id="uniqueId", - data={ - CONF_NAME: "TheOverClimateMockName", - CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, - CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", - CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", - CONF_CYCLE_MIN: 5, - CONF_TEMP_MIN: 15, - CONF_TEMP_MAX: 30, - "eco_temp": 17, - "comfort_temp": 18, - "boost_temp": 19, - CONF_USE_WINDOW_FEATURE: False, - CONF_USE_MOTION_FEATURE: False, - CONF_USE_POWER_FEATURE: False, - CONF_USE_PRESENCE_FEATURE: False, - CONF_CLIMATE: "climate.mock_climate", - CONF_MINIMAL_ACTIVATION_DELAY: 30, - CONF_SECURITY_DELAY_MIN: 5, - CONF_SECURITY_MIN_ON_PERCENT: 0.3, - }, - ) - - entity: BaseThermostat = await create_thermostat( - hass, entry, "climate.theoverclimatemockname" - ) - assert entity - # cause the underlying climate was not found - assert entity.is_over_climate is True - assert entity.underlying_entity(0)._underlying_climate is None - - # Should not failed - entity.update_custom_attributes() - - # try to call async_control_heating - try: - ret = await entity.async_control_heating() - # an exception should be send - assert ret is False - except Exception: # pylint: disable=broad-exception-caught - assert False - - # This time the underlying will be found - with patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", - return_value=the_mock_underlying, # dont find the underlying climate - ): - # try to call async_control_heating - try: - await entity.async_control_heating() - except UnknownEntity: - assert False - except Exception: # pylint: disable=broad-exception-caught - assert False - - # Should not failed - entity.update_custom_attributes() - - @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_bug_63( @@ -206,391 +128,6 @@ async def test_bug_64( assert entity -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_bug_66( - hass: HomeAssistant, - skip_hass_states_is_state, - skip_turn_on_off_heater, - skip_send_event, -): - """Test that it should be possible to open/close the window rapidly without side effect""" - - tz = get_tz(hass) # pylint: disable=invalid-name - now: datetime = datetime.now(tz=tz) - - entry = MockConfigEntry( - domain=DOMAIN, - title="TheOverSwitchMockName", - unique_id="uniqueId", - data={ - CONF_NAME: "TheOverSwitchMockName", - CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, - CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", - CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", - CONF_CYCLE_MIN: 5, - CONF_TEMP_MIN: 15, - CONF_TEMP_MAX: 30, - "eco_temp": 17, - "comfort_temp": 18, - "boost_temp": 19, - CONF_USE_WINDOW_FEATURE: True, - CONF_USE_MOTION_FEATURE: False, - CONF_USE_POWER_FEATURE: False, - CONF_USE_PRESENCE_FEATURE: False, - CONF_HEATER: "switch.mock_switch", - CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, - CONF_TPI_COEF_INT: 0.3, - CONF_TPI_COEF_EXT: 0.01, - CONF_MINIMAL_ACTIVATION_DELAY: 30, - CONF_SECURITY_DELAY_MIN: 5, - CONF_SECURITY_MIN_ON_PERCENT: 0.5, - CONF_SECURITY_DEFAULT_ON_PERCENT: 0.1, # !! here - CONF_DEVICE_POWER: 200, - CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor", - CONF_WINDOW_DELAY: 0, # important to not been obliged to wait - }, - ) - - entity: BaseThermostat = await create_thermostat( - hass, entry, "climate.theoverswitchmockname" - ) - assert entity - - await entity.async_set_hvac_mode(HVACMode.HEAT) - await entity.async_set_preset_mode(PRESET_BOOST) - - assert entity.hvac_mode is HVACMode.HEAT - assert entity.preset_mode is PRESET_BOOST - assert entity.target_temperature == 19 - assert entity.window_state is STATE_OFF - - # Open the window and let the thermostat shut down - with patch( - "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" - ) as mock_send_event, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" - ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" - ) as mock_heater_off, patch( - "homeassistant.helpers.condition.state", return_value=True - ) as mock_condition, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", - return_value=True, - ): - await send_temperature_change_event(entity, 15, now) - try_window_condition = await send_window_change_event( - entity, True, False, now, False - ) - - # simulate the call to try_window_condition - await try_window_condition(None) - - assert mock_send_event.call_count == 1 - assert mock_heater_on.call_count == 1 - assert mock_heater_off.call_count >= 1 - assert mock_condition.call_count == 1 - - assert entity.window_state == STATE_ON - - # Close the window but too shortly - with patch( - "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" - ) as mock_send_event, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" - ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" - ) as mock_heater_off, patch( - "homeassistant.helpers.condition.state", return_value=False - ) as mock_condition, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", - return_value=False, - ): - event_timestamp = now + timedelta(minutes=1) - try_window_condition = await send_window_change_event( - entity, False, True, event_timestamp - ) - # simulate the call to try_window_condition - await try_window_condition(None) - - # window state should not have change - assert entity.window_state == STATE_ON - - # Reopen immediatly with sufficient time - with patch( - "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" - ) as mock_send_event, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" - ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" - ) as mock_heater_off, patch( - "homeassistant.helpers.condition.state", return_value=True - ) as mock_condition, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", - return_value=False, - ): - try_window_condition = await send_window_change_event( - entity, True, False, event_timestamp - ) - # simulate the call to try_window_condition - await try_window_condition(None) - - # still no change - assert entity.window_state == STATE_ON - assert entity.hvac_mode == HVACMode.OFF - - # Close the window but with sufficient time this time - with patch( - "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" - ) as mock_send_event, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" - ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" - ) as mock_heater_off, patch( - "homeassistant.helpers.condition.state", return_value=True - ) as mock_condition, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", - return_value=False, - ): - event_timestamp = now + timedelta(minutes=2) - try_window_condition = await send_window_change_event( - entity, False, True, event_timestamp - ) - # simulate the call to try_window_condition - await try_window_condition(None) - - # window state should be Off this time and old state should have been restored - assert entity.window_state == STATE_OFF - assert entity.hvac_mode is HVACMode.HEAT - assert entity.preset_mode is PRESET_BOOST - - -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_bug_82( - hass: HomeAssistant, - skip_hass_states_is_state, - skip_turn_on_off_heater, - skip_send_event, -): - """Test that when a underlying climate is not available the VTherm doesn't go into safety mode""" - - 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_CONFIG, # 5 minutes security delay - ) - - fake_underlying_climate = MockUnavailableClimate( - hass, "mockUniqueId", "MockClimateName", {} - ) - - 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: - entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname") - # entry.add_to_hass(hass) - # await hass.config_entries.async_setup(entry.entry_id) - # assert entry.state is ConfigEntryState.LOADED - # - # def find_my_entity(entity_id) -> ClimateEntity: - # """Find my new entity""" - # component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN] - # for entity in component.entities: - # if entity.entity_id == entity_id: - # return entity - # - # entity = find_my_entity("climate.theoverclimatemockname") - - assert entity - - assert entity.name == "TheOverClimateMockName" - assert entity.is_over_climate is True - # assert entity.hvac_action is HVACAction.OFF - assert entity.hvac_mode is HVACMode.OFF - # assert entity.hvac_mode is None - assert entity.target_temperature == entity.min_temp - assert entity.preset_modes == [ - PRESET_NONE, - PRESET_FROST_PROTECTION, - PRESET_ECO, - PRESET_COMFORT, - PRESET_BOOST, - ] - assert entity.preset_mode is PRESET_NONE - assert entity._security_state is False - - # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT - assert mock_send_event.call_count == 2 - mock_send_event.assert_has_calls( - [ - call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}), - call.send_event( - EventType.HVAC_MODE_EVENT, - {"hvac_mode": HVACMode.OFF}, - ), - ] - ) - - assert mock_find_climate.call_count == 1 - assert mock_find_climate.mock_calls[0] == call() - mock_find_climate.assert_has_calls([call.find_underlying_entity()]) - - # Force safety mode - assert entity._last_ext_temperature_measure is not None - assert entity._last_temperature_measure is not None - assert ( - entity._last_temperature_measure.astimezone(tz) - now - ).total_seconds() < 1 - assert ( - entity._last_ext_temperature_measure.astimezone(tz) - now - ).total_seconds() < 1 - - # Tries to turns on the Thermostat - await entity.async_set_hvac_mode(HVACMode.HEAT) - assert entity.hvac_mode == HVACMode.HEAT - - # 2. activate security feature when date is expired - with patch( - "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" - ) as mock_send_event, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" - ): - event_timestamp = now - timedelta(minutes=6) - - # set temperature to 15 so that on_percent will be > security_min_on_percent (0.2) - await send_temperature_change_event(entity, 15, event_timestamp) - # Should stay False - assert entity.security_state is False - assert entity.preset_mode == "none" - assert entity._saved_preset_mode == "none" - - -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_bug_101( - 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") - # entry.add_to_hass(hass) - # await hass.config_entries.async_setup(entry.entry_id) - # assert entry.state is ConfigEntryState.LOADED - # - # def find_my_entity(entity_id) -> ClimateEntity: - # """Find my new entity""" - # component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN] - # for entity in component.entities: - # if entity.entity_id == entity_id: - # return entity - # - # entity = find_my_entity("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 - # Underlying should have been shutdown - assert mock_underlying_set_hvac_mode.call_count == 1 - mock_underlying_set_hvac_mode.assert_has_calls( - [ - call.set_hvac_mode(HVACMode.OFF), - ] - ) - - assert entity.target_temperature == entity.min_temp - assert entity.preset_mode is PRESET_NONE - - # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT - assert mock_send_event.call_count == 2 - mock_send_event.assert_has_calls( - [ - call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}), - call.send_event( - EventType.HVAC_MODE_EVENT, - {"hvac_mode": HVACMode.OFF}, - ), - ] - ) - - assert mock_find_climate.call_count == 1 - assert mock_find_climate.mock_calls[0] == call() - mock_find_climate.assert_has_calls([call.find_underlying_entity()]) - - # 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 - - # 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, - HVACMode.HEAT, - HVACAction.OFF, - HVACAction.OFF, - now, - 12.75, - ) - # Should NOT have been switched to Manual preset - assert entity.target_temperature == 17 - assert entity.preset_mode is PRESET_COMFORT - - # 2. Change the target temp of underlying thermostat at 11 sec later -> the event will be taken - # Wait 11 sec - event_timestamp = now + timedelta(seconds=11) - assert entity.is_regulated is False - await send_climate_change_event_with_temperature( - entity, - HVACMode.HEAT, - HVACMode.HEAT, - HVACAction.OFF, - HVACAction.OFF, - event_timestamp, - 12.75, - ) - assert entity.target_temperature == 12.75 - assert entity.preset_mode is PRESET_NONE - - @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_bug_272( @@ -862,189 +399,21 @@ async def test_bug_407(hass: HomeAssistant, skip_hass_states_is_state): assert entity.overpowering_state is True -async def test_bug_339( - hass: HomeAssistant, - # skip_hass_states_is_state, - init_central_config_with_boiler_fixture, -): - """Test that the counter of active Vtherm in central boiler is - correctly updated with underlying is in auto and device is active - """ +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_bug_500_1(hass: HomeAssistant, init_vtherm_api) -> None: + """Test that the form is served with no input""" - api = VersatileThermostatAPI.get_vtherm_api(hass) + config = { + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_USE_WINDOW_CENTRAL_CONFIG: True, + CONF_USE_POWER_CENTRAL_CONFIG: True, + CONF_USE_PRESENCE_CENTRAL_CONFIG: True, + CONF_USE_MOTION_FEATURE: True, + CONF_MOTION_SENSOR: "sensor.theMotionSensor", + } - climate1 = MockClimate( - hass=hass, - unique_id="climate1", - name="theClimate1", - hvac_mode=HVACMode.AUTO, - hvac_modes=[HVACMode.AUTO, HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL], - hvac_action=HVACAction.HEATING, - ) - - entry = MockConfigEntry( - domain=DOMAIN, - title="TheOverClimateMockName", - unique_id="uniqueId", - data={ - CONF_NAME: "TheOverClimateMockName", - CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, - CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", - CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", - CONF_CYCLE_MIN: 5, - CONF_TEMP_MIN: 8, - CONF_TEMP_MAX: 18, - "frost_temp": 10, - "eco_temp": 17, - "comfort_temp": 18, - "boost_temp": 21, - CONF_USE_WINDOW_FEATURE: False, - CONF_USE_MOTION_FEATURE: False, - CONF_USE_POWER_FEATURE: False, - CONF_USE_PRESENCE_FEATURE: False, - CONF_CLIMATE: climate1.entity_id, - CONF_MINIMAL_ACTIVATION_DELAY: 30, - CONF_SECURITY_DELAY_MIN: 5, - CONF_SECURITY_MIN_ON_PERCENT: 0.3, - CONF_SECURITY_DEFAULT_ON_PERCENT: 0.1, - CONF_USE_MAIN_CENTRAL_CONFIG: True, - CONF_USE_PRESETS_CENTRAL_CONFIG: True, - CONF_USE_ADVANCED_CENTRAL_CONFIG: True, - CONF_USED_BY_CENTRAL_BOILER: True, - }, - ) - - with patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", - return_value=climate1, - ): - entity: ThermostatOverValve = await create_thermostat( - hass, entry, "climate.theoverclimatemockname" - ) - assert entity - assert entity.name == "TheOverClimateMockName" - assert entity.is_over_climate - assert entity.underlying_entities[0].entity_id == "climate.climate1" - assert api.nb_active_device_for_boiler_threshold == 1 - - await entity.async_set_hvac_mode(HVACMode.AUTO) - # Simulate a state change in underelying - await api.nb_active_device_for_boiler_entity.calculate_nb_active_devices(None) - - # The VTherm should be active - assert entity.underlying_entity(0).is_device_active is True - assert entity.is_device_active is True - assert api.nb_active_device_for_boiler == 1 - - entity.remove_thermostat() - - -@pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_bug_508( - hass: HomeAssistant, - skip_hass_states_is_state, - skip_turn_on_off_heater, - skip_send_event, -): - """Test that it not possible to set the target temperature under the min_temp setting""" - - tz = get_tz(hass) # pylint: disable=invalid-name - now: datetime = datetime.now(tz=tz) - - entry = MockConfigEntry( - domain=DOMAIN, - title="TheOverClimateMockName", - unique_id="uniqueId", - # default value are min 15°, max 31°, step 0.1 - data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay - ) - - # Min_temp is 10 and max_temp is 31 and features contains TARGET_TEMPERATURE_RANGE - fake_underlying_climate = MagicMockClimateWithTemperatureRange() - - with patch( - "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" - ), patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", - return_value=fake_underlying_climate, - ), patch( - "homeassistant.core.ServiceRegistry.async_call" - ) as mock_service_call: - 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 - # The VTherm value and not the underlying value - assert entity.target_temperature_step == 0.1 - assert entity.target_temperature == entity.min_temp - assert entity.is_regulated is True - - assert mock_service_call.call_count == 0 - - # Set the hvac_mode to HEAT - await entity.async_set_hvac_mode(HVACMode.HEAT) - - # Not In the accepted interval -> should be converted into 10 (the min) and send with target_temp_high and target_temp_low - await entity.async_set_temperature(temperature=8.5) - - # MagicMock climate is already HEAT by default. So there is no SET_HAVC_MODE call - assert mock_service_call.call_count == 1 - mock_service_call.assert_has_calls( - [ - call.async_call( - "climate", - SERVICE_SET_TEMPERATURE, - { - "entity_id": "climate.mock_climate", - # "temperature": 17.5, - "target_temp_high": 10, - "target_temp_low": 10, - "temperature": 10, - }, - ), - ] - ) - - with patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: - # Not In the accepted interval -> should be converted into 10 (the min) and send with target_temp_high and target_temp_low - await entity.async_set_temperature(temperature=32) - - # MagicMock climate is already HEAT by default. So there is no SET_HAVC_MODE call - assert mock_service_call.call_count == 1 - mock_service_call.assert_has_calls( - [ - call.async_call( - "climate", - SERVICE_SET_TEMPERATURE, - { - "entity_id": "climate.mock_climate", - "target_temp_high": 31, - "target_temp_low": 31, - "temperature": 31, - }, - ), - ] - ) - - -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_bug_500_1(hass: HomeAssistant, init_vtherm_api) -> None: - """Test that the form is served with no input""" - - config = { - CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, - CONF_USE_WINDOW_CENTRAL_CONFIG: True, - CONF_USE_POWER_CENTRAL_CONFIG: True, - CONF_USE_PRESENCE_CENTRAL_CONFIG: True, - CONF_USE_MOTION_FEATURE: True, - CONF_MOTION_SENSOR: "sensor.theMotionSensor", - } - - flow = VersatileThermostatBaseConfigFlow(config) + flow = VersatileThermostatBaseConfigFlow(config) assert flow._infos[CONF_USE_WINDOW_FEATURE] is True assert flow._infos[CONF_USE_POWER_FEATURE] is True @@ -1099,292 +468,12 @@ async def test_bug_500_3(hass: HomeAssistant, init_vtherm_api) -> None: assert flow._infos[CONF_USE_MOTION_FEATURE] is True -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_bug_524(hass: HomeAssistant, skip_hass_states_is_state): - """Test when switching from Cool to Heat the new temperature in Heat mode should be used""" - - vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) - - # The temperatures to set - temps = { - "frost": 7.0, - "eco": 17.0, - "comfort": 19.0, - "boost": 21.0, - "eco_ac": 27.0, - "comfort_ac": 25.0, - "boost_ac": 23.0, - "frost_away": 7.1, - "eco_away": 17.1, - "comfort_away": 19.1, - "boost_away": 21.1, - "eco_ac_away": 27.1, - "comfort_ac_away": 25.1, - "boost_ac_away": 23.1, - } - - config_entry = MockConfigEntry( - domain=DOMAIN, - title="TheOverClimateMockName", - unique_id="overClimateUniqueId", - data={ - CONF_NAME: "overClimate", - 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_PRESENCE_FEATURE: True, - CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", - CONF_CLIMATE: "climate.mock_climate", - CONF_MINIMAL_ACTIVATION_DELAY: 30, - CONF_SECURITY_DELAY_MIN: 5, - CONF_SECURITY_MIN_ON_PERCENT: 0.3, - CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_TURBO, - CONF_AC_MODE: True, - }, - # | temps, - ) - - fake_underlying_climate = MockClimate( - hass=hass, - unique_id="mock_climate", - name="mock_climate", - hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT, HVACMode.FAN_ONLY], - ) - - with patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", - return_value=fake_underlying_climate, - ): - vtherm: ThermostatOverClimate = await create_thermostat( - hass, config_entry, "climate.overclimate" - ) - - assert vtherm is not None - - # We search for NumberEntities - for preset_name, value in temps.items(): - - await set_climate_preset_temp(vtherm, preset_name, value) - - temp_entity: NumberEntity = search_entity( - hass, - "number.overclimate_preset_" + preset_name + PRESET_TEMP_SUFFIX, - NUMBER_DOMAIN, - ) - assert temp_entity - # Because set_value is not implemented in Number class (really don't understand why...) - assert temp_entity.state == value - - # 1. Set mode to Heat and preset to Comfort - await send_presence_change_event(vtherm, True, False, datetime.now()) - await vtherm.async_set_hvac_mode(HVACMode.HEAT) - await vtherm.async_set_preset_mode(PRESET_COMFORT) - await hass.async_block_till_done() - - assert vtherm.target_temperature == 19.0 - - # 2. Only change the HVAC_MODE (and keep preset to comfort) - await vtherm.async_set_hvac_mode(HVACMode.COOL) - await hass.async_block_till_done() - assert vtherm.target_temperature == 25.0 - - # 3. Only change the HVAC_MODE (and keep preset to comfort) - await vtherm.async_set_hvac_mode(HVACMode.HEAT) - await hass.async_block_till_done() - assert vtherm.target_temperature == 19.0 - - # 4. Change presence to off - await send_presence_change_event(vtherm, False, True, datetime.now()) - await hass.async_block_till_done() - assert vtherm.target_temperature == 19.1 - - # 5. Change hvac_mode to AC - await vtherm.async_set_hvac_mode(HVACMode.COOL) - await hass.async_block_till_done() - assert vtherm.target_temperature == 25.1 - - # 6. Change presence to on - await send_presence_change_event(vtherm, True, False, datetime.now()) - await hass.async_block_till_done() - assert vtherm.target_temperature == 25 - - -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_bug_533(hass: HomeAssistant, skip_hass_states_is_state): - """Test that with an over_valve and _auto_regulation_dpercent is set that the valve could close totally""" - - vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) - - # The temperatures to set - temps = { - "frost": 7.0, - "eco": 17.0, - "comfort": 19.0, - "boost": 21.0, - } - - config_entry = MockConfigEntry( - domain=DOMAIN, - title="TheOverValveMockName", - unique_id="overValveUniqueId", - data={ - CONF_NAME: "overValve", - CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_VALVE, - CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, - CONF_TPI_COEF_INT: 0.5, - CONF_TPI_COEF_EXT: 0, - CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", - 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_PRESENCE_FEATURE: False, - CONF_VALVE: "number.mock_valve", - CONF_AUTO_REGULATION_DTEMP: 10, # This parameter makes the bug - CONF_MINIMAL_ACTIVATION_DELAY: 30, - CONF_SECURITY_DELAY_MIN: 60, - }, - # | temps, - ) - - # Not used because number is not registred so we can use directly the underlying number - # fake_underlying_number = MockNumber( - # hass=hass, unique_id="mock_number", name="mock_number" - # ) - - vtherm: ThermostatOverClimate = await create_thermostat( - hass, config_entry, "climate.overvalve" - ) - - assert vtherm is not None - - tz = get_tz(hass) # pylint: disable=invalid-name - now: datetime = datetime.now(tz=tz) - - # Set all temps and check they are correctly initialized - await set_all_climate_preset_temp(hass, vtherm, temps, "overvalve") - await send_temperature_change_event(vtherm, 15, now) - await send_ext_temperature_change_event(vtherm, 15, now) - - # 1. Set mode to Heat and preset to Comfort - await vtherm.async_set_hvac_mode(HVACMode.HEAT) - with patch( - "homeassistant.core.StateMachine.get", - return_value=State( - entity_id="number.mock_valve", - state="100", - attributes={"min": 0, "max": 100}, - ), - ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: - await vtherm.async_set_preset_mode(PRESET_COMFORT) - await hass.async_block_till_done() - - assert vtherm.target_temperature == 19.0 - assert mock_service_call.call_count == 1 - mock_service_call.assert_has_calls( - [ - call.async_call( - domain="number", - service="set_value", - service_data={"value": 100}, - target={"entity_id": "number.mock_valve"}, - ), - ] - ) - - # 2. set current temperature to 18 -> still 50% open, so there is a call - now = now + timedelta(minutes=1) - with patch( - "homeassistant.core.StateMachine.get", - return_value=State( - entity_id="number.mock_valve", - state="100", - attributes={"min": 0, "max": 100}, - ), - ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: - await send_temperature_change_event(vtherm, 18, now) - await hass.async_block_till_done() - - assert mock_service_call.call_count == 1 - mock_service_call.assert_has_calls( - [ - call.async_call( - domain="number", - service="set_value", - service_data={"value": 50}, - target={"entity_id": "number.mock_valve"}, - ), - ] - ) - - # 3. set current temperature to 18.8 -> still 10% open, so there is one call - now = now + timedelta(minutes=1) - with patch( - "homeassistant.core.StateMachine.get", - return_value=State( - entity_id="number.mock_valve", - state="50", - attributes={"min": 0, "max": 100}, - ), - ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: - await send_temperature_change_event(vtherm, 18.8, now) - await hass.async_block_till_done() - - assert mock_service_call.call_count == 1 - mock_service_call.assert_has_calls( - [ - call.async_call( - domain="number", - service="set_value", - service_data={"value": 10}, - target={"entity_id": "number.mock_valve"}, - ), - ] - ) - - # 4. set current temperature to 19 -> should have 0% open and one call to set the 0 - now = now + timedelta(minutes=1) - with patch( - "homeassistant.core.StateMachine.get", - return_value=State( - entity_id="number.mock_valve", - state="10", # the previous value - attributes={"min": 0, "max": 100}, - ), - ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: - await send_temperature_change_event(vtherm, 19, now) - await hass.async_block_till_done() - - assert mock_service_call.call_count == 1 - mock_service_call.assert_has_calls( - [ - call.async_call( - domain="number", - service="set_value", - service_data={"value": 0}, - target={"entity_id": "number.mock_valve"}, - ), - ] - ) - - @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_bug_465(hass: HomeAssistant, skip_hass_states_is_state): """Test store and restore hvac_mode on toggle hvac state""" - vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + # vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) # The temperatures to set temps = { diff --git a/tests/test_central_boiler.py b/tests/test_central_boiler.py index 8daada69..be2915ef 100644 --- a/tests/test_central_boiler.py +++ b/tests/test_central_boiler.py @@ -842,3 +842,80 @@ async def test_update_central_boiler_state_simple_climate( assert boiler_binary_sensor.state == STATE_OFF entity.remove_thermostat() + + +async def test_bug_339( + hass: HomeAssistant, + # skip_hass_states_is_state, + init_central_config_with_boiler_fixture, +): + """Test that the counter of active Vtherm in central boiler is + correctly updated with underlying is in auto and device is active + """ + + api = VersatileThermostatAPI.get_vtherm_api(hass) + + climate1 = MockClimate( + hass=hass, + unique_id="climate1", + name="theClimate1", + hvac_mode=HVACMode.AUTO, + hvac_modes=[HVACMode.AUTO, HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL], + hvac_action=HVACAction.HEATING, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 8, + CONF_TEMP_MAX: 18, + "frost_temp": 10, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 21, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: climate1.entity_id, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_SECURITY_DEFAULT_ON_PERCENT: 0.1, + CONF_USE_MAIN_CENTRAL_CONFIG: True, + CONF_USE_PRESETS_CENTRAL_CONFIG: True, + CONF_USE_ADVANCED_CENTRAL_CONFIG: True, + CONF_USED_BY_CENTRAL_BOILER: True, + }, + ) + + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=climate1, + ): + entity: ThermostatOverValve = await create_thermostat( + hass, entry, "climate.theoverclimatemockname" + ) + assert entity + assert entity.name == "TheOverClimateMockName" + assert entity.is_over_climate + assert entity.underlying_entities[0].entity_id == "climate.climate1" + assert api.nb_active_device_for_boiler_threshold == 1 + + await entity.async_set_hvac_mode(HVACMode.AUTO) + # Simulate a state change in underelying + await api.nb_active_device_for_boiler_entity.calculate_nb_active_devices(None) + + # The VTherm should be active + assert entity.underlying_entity(0).is_device_active is True + assert entity.is_device_active is True + assert api.nb_active_device_for_boiler == 1 + + entity.remove_thermostat() diff --git a/tests/test_multiple_switch.py b/tests/test_multiple_switch.py index 3ead63d0..1d781318 100644 --- a/tests/test_multiple_switch.py +++ b/tests/test_multiple_switch.py @@ -596,6 +596,7 @@ async def test_multiple_climates_underlying_changes( HVACAction.IDLE, HVACAction.OFF, event_timestamp, + underlying_entity_id="switch.mock_climate3", ) # Should be call for all Switch diff --git a/tests/test_overclimate.py b/tests/test_overclimate.py new file mode 100644 index 00000000..8f2ef83a --- /dev/null +++ b/tests/test_overclimate.py @@ -0,0 +1,508 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long, too-many-lines + +""" Test the Window management """ +from unittest.mock import patch, call +from datetime import datetime, timedelta + +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.components.climate import ( + SERVICE_SET_TEMPERATURE, +) + +from custom_components.versatile_thermostat.thermostat_climate import ( + ThermostatOverClimate, +) + +from .commons import * + +logging.getLogger().setLevel(logging.DEBUG) + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_bug_56( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test that in over_climate mode there is no error when underlying climate is not available""" + + the_mock_underlying = MagicMockClimate() + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=None, # dont find the underlying climate + ): + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "climate.mock_climate", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + }, + ) + + entity: BaseThermostat = await create_thermostat( + hass, entry, "climate.theoverclimatemockname" + ) + assert entity + # cause the underlying climate was not found + assert entity.is_over_climate is True + assert entity.underlying_entity(0)._underlying_climate is None + + # Should not failed + entity.update_custom_attributes() + + # try to call async_control_heating + try: + ret = await entity.async_control_heating() + # an exception should be send + assert ret is False + except Exception: # pylint: disable=broad-exception-caught + assert False + + # This time the underlying will be found + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=the_mock_underlying, # dont find the underlying climate + ): + # try to call async_control_heating + try: + await entity.async_control_heating() + except UnknownEntity: + assert False + except Exception: # pylint: disable=broad-exception-caught + assert False + + # Should not failed + entity.update_custom_attributes() + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_bug_82( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test that when a underlying climate is not available the VTherm doesn't go into safety mode""" + + 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_CONFIG, # 5 minutes security delay + ) + + fake_underlying_climate = MockUnavailableClimate( + hass, "mockUniqueId", "MockClimateName", {} + ) + + 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: + entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname") + + assert entity + + assert entity.name == "TheOverClimateMockName" + assert entity.is_over_climate is True + # assert entity.hvac_action is HVACAction.OFF + assert entity.hvac_mode is HVACMode.OFF + # assert entity.hvac_mode is None + assert entity.target_temperature == entity.min_temp + assert entity.preset_modes == [ + PRESET_NONE, + PRESET_FROST_PROTECTION, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] + assert entity.preset_mode is PRESET_NONE + assert entity._security_state is False + + # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT + assert mock_send_event.call_count == 2 + mock_send_event.assert_has_calls( + [ + call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}), + call.send_event( + EventType.HVAC_MODE_EVENT, + {"hvac_mode": HVACMode.OFF}, + ), + ] + ) + + assert mock_find_climate.call_count == 1 + assert mock_find_climate.mock_calls[0] == call() + mock_find_climate.assert_has_calls([call.find_underlying_entity()]) + + # Force safety mode + assert entity._last_ext_temperature_measure is not None + assert entity._last_temperature_measure is not None + assert ( + entity._last_temperature_measure.astimezone(tz) - now + ).total_seconds() < 1 + assert ( + entity._last_ext_temperature_measure.astimezone(tz) - now + ).total_seconds() < 1 + + # Tries to turns on the Thermostat + await entity.async_set_hvac_mode(HVACMode.HEAT) + assert entity.hvac_mode == HVACMode.HEAT + + # 2. activate security feature when date is expired + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ): + event_timestamp = now - timedelta(minutes=6) + + # set temperature to 15 so that on_percent will be > security_min_on_percent (0.2) + await send_temperature_change_event(entity, 15, event_timestamp) + # Should stay False + assert entity.security_state is False + assert entity.preset_mode == "none" + assert entity._saved_preset_mode == "none" + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_bug_101( + 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 + # Underlying should have been shutdown + assert mock_underlying_set_hvac_mode.call_count == 1 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.OFF), + ] + ) + + assert entity.target_temperature == entity.min_temp + assert entity.preset_mode is PRESET_NONE + + # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT + assert mock_send_event.call_count == 2 + mock_send_event.assert_has_calls( + [ + call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}), + call.send_event( + EventType.HVAC_MODE_EVENT, + {"hvac_mode": HVACMode.OFF}, + ), + ] + ) + + assert mock_find_climate.call_count == 1 + assert mock_find_climate.mock_calls[0] == call() + mock_find_climate.assert_has_calls([call.find_underlying_entity()]) + + # 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 + + # 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, + HVACMode.HEAT, + HVACAction.OFF, + HVACAction.OFF, + now, + 12.75, + 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 + + # 3. Change the target temp of underlying thermostat at 11 sec later -> the event will be taken + # Wait 11 sec + event_timestamp = now + timedelta(seconds=11) + assert entity.is_regulated is False + await send_climate_change_event_with_temperature( + entity, + HVACMode.HEAT, + HVACMode.HEAT, + HVACAction.OFF, + HVACAction.OFF, + event_timestamp, + 12.75, + True, + "climate.mock_climate", # the underlying climate entity id + ) + assert entity.target_temperature == 12.75 + assert entity.preset_mode is PRESET_NONE + + +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_bug_508( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test that it not possible to set the target temperature under the min_temp setting""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + # default value are min 15°, max 31°, step 0.1 + data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay + ) + + # Min_temp is 10 and max_temp is 31 and features contains TARGET_TEMPERATURE_RANGE + fake_underlying_climate = MagicMockClimateWithTemperatureRange() + + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ), patch( + "homeassistant.core.ServiceRegistry.async_call" + ) as mock_service_call: + 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 + # The VTherm value and not the underlying value + assert entity.target_temperature_step == 0.1 + assert entity.target_temperature == entity.min_temp + assert entity.is_regulated is True + + assert mock_service_call.call_count == 0 + + # Set the hvac_mode to HEAT + await entity.async_set_hvac_mode(HVACMode.HEAT) + + # Not In the accepted interval -> should be converted into 10 (the min) and send with target_temp_high and target_temp_low + await entity.async_set_temperature(temperature=8.5) + + # MagicMock climate is already HEAT by default. So there is no SET_HAVC_MODE call + assert mock_service_call.call_count == 1 + mock_service_call.assert_has_calls( + [ + call.async_call( + "climate", + SERVICE_SET_TEMPERATURE, + { + "entity_id": "climate.mock_climate", + # "temperature": 17.5, + "target_temp_high": 10, + "target_temp_low": 10, + "temperature": 10, + }, + ), + ] + ) + + with patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + # Not In the accepted interval -> should be converted into 10 (the min) and send with target_temp_high and target_temp_low + await entity.async_set_temperature(temperature=32) + + # MagicMock climate is already HEAT by default. So there is no SET_HAVC_MODE call + assert mock_service_call.call_count == 1 + mock_service_call.assert_has_calls( + [ + call.async_call( + "climate", + SERVICE_SET_TEMPERATURE, + { + "entity_id": "climate.mock_climate", + "target_temp_high": 31, + "target_temp_low": 31, + "temperature": 31, + }, + ), + ] + ) + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_bug_524(hass: HomeAssistant, skip_hass_states_is_state): + """Test when switching from Cool to Heat the new temperature in Heat mode should be used""" + + # vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + + # The temperatures to set + temps = { + "frost": 7.0, + "eco": 17.0, + "comfort": 19.0, + "boost": 21.0, + "eco_ac": 27.0, + "comfort_ac": 25.0, + "boost_ac": 23.0, + "frost_away": 7.1, + "eco_away": 17.1, + "comfort_away": 19.1, + "boost_away": 21.1, + "eco_ac_away": 27.1, + "comfort_ac_away": 25.1, + "boost_ac_away": 23.1, + } + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="overClimateUniqueId", + data={ + CONF_NAME: "overClimate", + 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_PRESENCE_FEATURE: True, + CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", + CONF_CLIMATE: "climate.mock_climate", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_TURBO, + CONF_AC_MODE: True, + }, + # | temps, + ) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mock_climate", + name="mock_climate", + hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT, HVACMode.FAN_ONLY], + ) + + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ): + vtherm: ThermostatOverClimate = await create_thermostat( + hass, config_entry, "climate.overclimate" + ) + + assert vtherm is not None + + # We search for NumberEntities + for preset_name, value in temps.items(): + + await set_climate_preset_temp(vtherm, preset_name, value) + + temp_entity: NumberEntity = search_entity( + hass, + "number.overclimate_preset_" + preset_name + PRESET_TEMP_SUFFIX, + NUMBER_DOMAIN, + ) + assert temp_entity + # Because set_value is not implemented in Number class (really don't understand why...) + assert temp_entity.state == value + + # 1. Set mode to Heat and preset to Comfort + await send_presence_change_event(vtherm, True, False, datetime.now()) + await vtherm.async_set_hvac_mode(HVACMode.HEAT) + await vtherm.async_set_preset_mode(PRESET_COMFORT) + await hass.async_block_till_done() + + assert vtherm.target_temperature == 19.0 + + # 2. Only change the HVAC_MODE (and keep preset to comfort) + await vtherm.async_set_hvac_mode(HVACMode.COOL) + await hass.async_block_till_done() + assert vtherm.target_temperature == 25.0 + + # 3. Only change the HVAC_MODE (and keep preset to comfort) + await vtherm.async_set_hvac_mode(HVACMode.HEAT) + await hass.async_block_till_done() + assert vtherm.target_temperature == 19.0 + + # 4. Change presence to off + await send_presence_change_event(vtherm, False, True, datetime.now()) + await hass.async_block_till_done() + assert vtherm.target_temperature == 19.1 + + # 5. Change hvac_mode to AC + await vtherm.async_set_hvac_mode(HVACMode.COOL) + await hass.async_block_till_done() + assert vtherm.target_temperature == 25.1 + + # 6. Change presence to on + await send_presence_change_event(vtherm, True, False, datetime.now()) + await hass.async_block_till_done() + assert vtherm.target_temperature == 25 diff --git a/tests/test_power.py b/tests/test_power.py index 49046e17..4fdad1cb 100644 --- a/tests/test_power.py +++ b/tests/test_power.py @@ -433,6 +433,7 @@ async def test_power_management_energy_over_climate( new_hvac_action=HVACAction.HEATING, old_hvac_action=HVACAction.OFF, date=event_timestamp, + underlying_entity_id="climate.mock_climate", ) # We have the start event and not the end event assert (entity._underlying_climate_start_hvac_action_date - now).total_seconds() < 1 @@ -448,6 +449,7 @@ async def test_power_management_energy_over_climate( new_hvac_action=HVACAction.IDLE, old_hvac_action=HVACAction.HEATING, date=now, + underlying_entity_id="climate.mock_climate", ) # We have the end event -> we should have some power and on_percent assert entity._underlying_climate_start_hvac_action_date is None diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 44279d40..18dde956 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -283,6 +283,7 @@ async def test_sensors_over_climate( new_hvac_action=HVACAction.HEATING, old_hvac_action=HVACAction.OFF, date=event_timestamp, + underlying_entity_id="climate.mock_climate", ) # Send a climate_change event with HVACAction=IDLE (end of heating) @@ -293,6 +294,7 @@ async def test_sensors_over_climate( new_hvac_action=HVACAction.IDLE, old_hvac_action=HVACAction.HEATING, date=now, + underlying_entity_id="climate.mock_climate", ) # 60 minutes heating with 1.5 kW heating -> 1.5 kWh diff --git a/tests/test_valve.py b/tests/test_valve.py index 96b8d9e1..75decbb0 100644 --- a/tests/test_valve.py +++ b/tests/test_valve.py @@ -6,10 +6,6 @@ from homeassistant.core import HomeAssistant, State from homeassistant.components.climate import HVACAction, HVACMode -from homeassistant.config_entries import ConfigEntryState - -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -551,3 +547,168 @@ async def test_over_valve_regulation( assert mock_service_call.call_count == 0 assert mock_send_event.call_count == 0 + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_bug_533( + hass: HomeAssistant, skip_hass_states_is_state +): # pylint: disable=unused-argument + """Test that with an over_valve and _auto_regulation_dpercent is set that the valve could close totally""" + + # vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + + # The temperatures to set + temps = { + "frost": 7.0, + "eco": 17.0, + "comfort": 19.0, + "boost": 21.0, + } + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverValveMockName", + unique_id="overValveUniqueId", + data={ + CONF_NAME: "overValve", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_VALVE, + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.5, + CONF_TPI_COEF_EXT: 0, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + 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_PRESENCE_FEATURE: False, + CONF_VALVE: "number.mock_valve", + CONF_AUTO_REGULATION_DTEMP: 10, # This parameter makes the bug + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 60, + }, + # | temps, + ) + + # Not used because number is not registred so we can use directly the underlying number + # fake_underlying_number = MockNumber( + # hass=hass, unique_id="mock_number", name="mock_number" + # ) + + vtherm: ThermostatOverValve = await create_thermostat( + hass, config_entry, "climate.overvalve" + ) + + assert vtherm is not None + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + # Set all temps and check they are correctly initialized + await set_all_climate_preset_temp(hass, vtherm, temps, "overvalve") + await send_temperature_change_event(vtherm, 15, now) + await send_ext_temperature_change_event(vtherm, 15, now) + + # 1. Set mode to Heat and preset to Comfort + await vtherm.async_set_hvac_mode(HVACMode.HEAT) + with patch( + "homeassistant.core.StateMachine.get", + return_value=State( + entity_id="number.mock_valve", + state="100", + attributes={"min": 0, "max": 100}, + ), + ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + await vtherm.async_set_preset_mode(PRESET_COMFORT) + await hass.async_block_till_done() + + assert vtherm.target_temperature == 19.0 + assert mock_service_call.call_count == 1 + mock_service_call.assert_has_calls( + [ + call.async_call( + domain="number", + service="set_value", + service_data={"value": 100}, + target={"entity_id": "number.mock_valve"}, + ), + ] + ) + + # 2. set current temperature to 18 -> still 50% open, so there is a call + now = now + timedelta(minutes=1) + with patch( + "homeassistant.core.StateMachine.get", + return_value=State( + entity_id="number.mock_valve", + state="100", + attributes={"min": 0, "max": 100}, + ), + ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + await send_temperature_change_event(vtherm, 18, now) + await hass.async_block_till_done() + + assert mock_service_call.call_count == 1 + mock_service_call.assert_has_calls( + [ + call.async_call( + domain="number", + service="set_value", + service_data={"value": 50}, + target={"entity_id": "number.mock_valve"}, + ), + ] + ) + + # 3. set current temperature to 18.8 -> still 10% open, so there is one call + now = now + timedelta(minutes=1) + with patch( + "homeassistant.core.StateMachine.get", + return_value=State( + entity_id="number.mock_valve", + state="50", + attributes={"min": 0, "max": 100}, + ), + ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + await send_temperature_change_event(vtherm, 18.8, now) + await hass.async_block_till_done() + + assert mock_service_call.call_count == 1 + mock_service_call.assert_has_calls( + [ + call.async_call( + domain="number", + service="set_value", + service_data={"value": 10}, + target={"entity_id": "number.mock_valve"}, + ), + ] + ) + + # 4. set current temperature to 19 -> should have 0% open and one call to set the 0 + now = now + timedelta(minutes=1) + with patch( + "homeassistant.core.StateMachine.get", + return_value=State( + entity_id="number.mock_valve", + state="10", # the previous value + attributes={"min": 0, "max": 100}, + ), + ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + await send_temperature_change_event(vtherm, 19, now) + await hass.async_block_till_done() + + assert mock_service_call.call_count == 1 + mock_service_call.assert_has_calls( + [ + call.async_call( + domain="number", + service="set_value", + service_data={"value": 0}, + target={"entity_id": "number.mock_valve"}, + ), + ] + ) diff --git a/tests/test_window.py b/tests/test_window.py index 189dcb17..8638eeb2 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1925,3 +1925,162 @@ async def test_window_action_frost_temp(hass: HomeAssistant, skip_hass_states_is # Clean the entity entity.remove_thermostat() + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_bug_66( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test that it should be possible to open/close the window rapidly without side effect""" + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_HEATER: "switch.mock_switch", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.5, + CONF_SECURITY_DEFAULT_ON_PERCENT: 0.1, # !! here + CONF_DEVICE_POWER: 200, + CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor", + CONF_WINDOW_DELAY: 0, # important to not been obliged to wait + }, + ) + + entity: BaseThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + await entity.async_set_hvac_mode(HVACMode.HEAT) + await entity.async_set_preset_mode(PRESET_BOOST) + + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + assert entity.target_temperature == 19 + assert entity.window_state is STATE_OFF + + # Open the window and let the thermostat shut down + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=True, + ): + await send_temperature_change_event(entity, 15, now) + try_window_condition = await send_window_change_event( + entity, True, False, now, False + ) + + # simulate the call to try_window_condition + await try_window_condition(None) + + assert mock_send_event.call_count == 1 + assert mock_heater_on.call_count == 1 + assert mock_heater_off.call_count >= 1 + assert mock_condition.call_count == 1 + + assert entity.window_state == STATE_ON + + # Close the window but too shortly + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "homeassistant.helpers.condition.state", return_value=False + ) as mock_condition, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=False, + ): + event_timestamp = now + timedelta(minutes=1) + try_window_condition = await send_window_change_event( + entity, False, True, event_timestamp + ) + # simulate the call to try_window_condition + await try_window_condition(None) + + # window state should not have change + assert entity.window_state == STATE_ON + + # Reopen immediatly with sufficient time + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=False, + ): + try_window_condition = await send_window_change_event( + entity, True, False, event_timestamp + ) + # simulate the call to try_window_condition + await try_window_condition(None) + + # still no change + assert entity.window_state == STATE_ON + assert entity.hvac_mode == HVACMode.OFF + + # Close the window but with sufficient time this time + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=False, + ): + event_timestamp = now + timedelta(minutes=2) + try_window_condition = await send_window_change_event( + entity, False, True, event_timestamp + ) + # simulate the call to try_window_condition + await try_window_condition(None) + + # window state should be Off this time and old state should have been restored + assert entity.window_state == STATE_OFF + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST