# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long, too-many-lines """ Test the over_climate Vtherm """ 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 homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN 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) @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_SAFETY_DELAY_MIN: 5, CONF_SAFETY_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.safety_manager.is_safety_detected 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 > safety_min_on_percent (0.2) await send_temperature_change_event(entity, 15, event_timestamp) # Should stay False assert entity.safety_state is not STATE_ON 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_underlying_change_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) temps = { PRESET_FROST_PROTECTION: 7, PRESET_ECO: 16, PRESET_COMFORT: 17, PRESET_BOOST: 18, } 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", temps) 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( [ 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, entity.min_temp + 1, 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, entity.min_temp + 1, True, "climate.mock_climate", # the underlying climate entity id ) assert entity.target_temperature == entity.min_temp + 1 assert entity.preset_mode is PRESET_NONE # 4. Change the target temp with < 0.1 (step) value. The value should not be taken # Wait 11 sec event_timestamp = now + timedelta(seconds=11) await send_climate_change_event_with_temperature( entity, HVACMode.HEAT, HVACMode.HEAT, HVACAction.OFF, HVACAction.OFF, event_timestamp, entity.min_temp + 1.09, True, "climate.mock_climate", # the underlying climate entity id ) assert entity.target_temperature == entity.min_temp + 1 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) temps = { PRESET_FROST_PROTECTION: 7, PRESET_ECO: 16, PRESET_COMFORT: 17, PRESET_BOOST: 18, } 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", temps) 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( 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 don't change its own temperature target if no target_temperature have already been sent""" 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 ) # 1. create the thermostat 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, ): vtherm = await create_thermostat(hass, entry, "climate.theoverclimatemockname") assert vtherm assert vtherm.name == "TheOverClimateMockName" assert vtherm.is_over_climate is True assert vtherm.hvac_mode is HVACMode.OFF # because in MockClimate HVACAction is HEATING if hvac_mode is not set assert vtherm.hvac_action is HVACAction.HEATING # Force a preset_mode without sending a temperature (as it was restored with a preset) vtherm._attr_preset_mode = PRESET_BOOST assert vtherm.target_temperature == vtherm.min_temp assert vtherm.preset_mode is PRESET_BOOST with patch( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" ) as mock_underlying_set_hvac_mode: # 2. Change the target temp of underlying thermostat at now + 1 min now = now + timedelta(minutes=1) await send_climate_change_event_with_temperature( vtherm, HVACMode.OFF, HVACMode.OFF, HVACAction.OFF, HVACAction.OFF, now, 25, True, "climate.mock_climate", # the underlying climate entity id ) # Should NOT have been taken the new target temp nor have change the preset assert vtherm.target_temperature == vtherm.min_temp assert vtherm.preset_mode is PRESET_BOOST mock_underlying_set_hvac_mode.assert_not_called() @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 ) tz = get_tz(hass) # pylint: disable=invalid-name now: datetime = datetime.now(tz=tz) # 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) now = now + timedelta(minutes=3) # avoid temporal filter entity._set_now(now) # 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, }, ), ] ) 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 now = now + timedelta(minutes=3) # avoid temporal filter entity._set_now(now) 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, }, ), ] ) @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_SAFETY_DELAY_MIN: 5, CONF_SAFETY_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_ignore_temp_outside_minmax_range( 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 ignores the target temp if it is outside the min/max range""" tz = get_tz(hass) # pylint: disable=invalid-name now: datetime = datetime.now(tz=tz) temps = { PRESET_FROST_PROTECTION: 7, PRESET_ECO: 16, PRESET_COMFORT: 17, PRESET_BOOST: 18, } 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", temps) 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. VTherm must follow the underlying's temperature changes follow_entity: FollowUnderlyingTemperatureChange = search_entity( hass, "switch.theoverclimatemockname_follow_underlying_temp_change", SWITCH_DOMAIN, ) # follow the underlying temp change follow_entity.turn_on() assert entity.follow_underlying_temp_change is True assert follow_entity.state is STATE_ON # 2. 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 # 3. Try to set the target temperature to a below min_temp -> should be ignored # 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.HEATING, HVACAction.HEATING, event_timestamp, entity.min_temp - 1, True, "climate.mock_climate", # the underlying climate entity id ) assert entity.target_temperature == 17 # 4. Try to set the target temperature to a above max_temp -> should be ignored event_timestamp = event_timestamp + timedelta(seconds=11) assert entity.is_regulated is False await send_climate_change_event_with_temperature( entity, HVACMode.HEAT, HVACMode.HEAT, HVACAction.HEATING, HVACAction.HEATING, event_timestamp, entity.max_temp + 1, True, "climate.mock_climate", # the underlying climate entity id ) assert entity.target_temperature == 17 # 5. Switch off the VTherm and receive an event from the underlying with a temp to be ignored, # but an HVACAction to be taken into account await entity.async_set_hvac_mode(HVACMode.OFF) assert entity.hvac_mode == HVACMode.OFF fake_underlying_climate.set_hvac_mode(HVACMode.OFF) fake_underlying_climate.set_hvac_action(HVACAction.IDLE) event_timestamp = event_timestamp + timedelta(seconds=11) await send_climate_change_event_with_temperature( entity, HVACMode.OFF, HVACMode.HEAT, HVACAction.IDLE, HVACAction.HEATING, event_timestamp, entity.min_temp - 1, True, "climate.mock_climate", # the underlying climate entity id ) assert entity.target_temperature == 17 assert entity.hvac_action == HVACAction.IDLE @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_manual_hvac_off_should_take_the_lead_over_window( hass: HomeAssistant, skip_hass_states_is_state ): """Test than a manual hvac_off is taken into account over a window hvac_off""" # 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: True, CONF_WINDOW_SENSOR: "binary_sensor.window_sensor", CONF_WINDOW_DELAY: 10, CONF_USE_MOTION_FEATURE: False, CONF_USE_POWER_FEATURE: False, CONF_USE_AUTO_START_STOP_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True, CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", CONF_CLIMATE: "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_TURBO, CONF_AC_MODE: True, CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_FAST, }, ) fake_underlying_climate = MockClimate( hass=hass, unique_id="mock_climate", name="mock_climate", hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT], ) 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 # Initialize all temps await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate") # Check correct initialization of auto_start_stop attributes assert ( vtherm._attr_extra_state_attributes["auto_start_stop_level"] == AUTO_START_STOP_LEVEL_FAST ) assert ( vtherm.auto_start_stop_manager.auto_start_stop_level == AUTO_START_STOP_LEVEL_FAST ) enable_entity = search_entity( hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN ) assert enable_entity is not None assert enable_entity.state == STATE_ON tz = get_tz(hass) # pylint: disable=invalid-name now: datetime = datetime.now(tz=tz) # 1. Set mode to Heat and preset to Comfort and close the window await send_window_change_event(vtherm, False, False, now, False) await send_presence_change_event(vtherm, True, False, now) await send_temperature_change_event(vtherm, 18, now, True) 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 # VTherm should be heating assert vtherm.hvac_mode == HVACMode.HEAT # VTherm window_state should be off assert vtherm.window_state == STATE_UNKNOWN # Cause try_condition is not called # 2. Open the window and wait for the delay now = now + timedelta(minutes=2) with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "homeassistant.helpers.condition.state", return_value=True ): vtherm._set_now(now) try_function = await send_window_change_event( vtherm, True, False, now, sleep=False ) await try_function(None) # Nothing should have change (window event is ignoed as we are already OFF) assert vtherm.hvac_mode == HVACMode.OFF assert vtherm.hvac_off_reason == HVAC_OFF_REASON_WINDOW_DETECTION assert vtherm._saved_hvac_mode == HVACMode.HEAT assert mock_send_event.call_count == 1 assert vtherm.window_state == STATE_ON # 3. Turn off manually the VTherm. This should be taken into account now = now + timedelta(minutes=1) vtherm._set_now(now) with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event: await vtherm.async_set_hvac_mode(HVACMode.OFF) await hass.async_block_till_done() # Should be off with reason MANUAL assert vtherm.hvac_mode == HVACMode.OFF assert vtherm.hvac_off_reason == HVAC_OFF_REASON_MANUAL assert vtherm._saved_hvac_mode == HVACMode.OFF # Window state should not change assert vtherm.window_state == STATE_ON assert mock_send_event.call_count == 1 mock_send_event.assert_has_calls( [ call(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}), ] ) # 4. close the window -> we should stay off reason manual now = now + timedelta(minutes=1) vtherm._set_now(now) with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "homeassistant.helpers.condition.state", return_value=True ): try_function = await send_window_change_event( vtherm, False, True, now, sleep=False ) await try_function(None) # The VTherm should turn on and off again due to auto-start-stop assert vtherm.hvac_mode == HVACMode.OFF assert vtherm.hvac_off_reason is HVAC_OFF_REASON_MANUAL assert vtherm._saved_hvac_mode == HVACMode.OFF assert vtherm.window_state == STATE_OFF assert mock_send_event.call_count == 0 @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_manual_hvac_off_should_take_the_lead_over_auto_start_stop( hass: HomeAssistant, skip_hass_states_is_state ): """Test than a manual hvac_off is taken into account over a auto-start/stop hvac_off""" # 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: True, CONF_WINDOW_SENSOR: "binary_sensor.window_sensor", CONF_WINDOW_DELAY: 10, CONF_USE_MOTION_FEATURE: False, CONF_USE_POWER_FEATURE: False, CONF_USE_AUTO_START_STOP_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True, CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", CONF_CLIMATE: "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_TURBO, CONF_AC_MODE: True, CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_FAST, }, ) fake_underlying_climate = MockClimate( hass=hass, unique_id="mock_climate", name="mock_climate", hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT], ) 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 # Initialize all temps await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate") # Check correct initialization of auto_start_stop attributes assert ( vtherm._attr_extra_state_attributes["auto_start_stop_level"] == AUTO_START_STOP_LEVEL_FAST ) assert ( vtherm.auto_start_stop_manager.auto_start_stop_level == AUTO_START_STOP_LEVEL_FAST ) enable_entity = search_entity( hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN ) assert enable_entity is not None assert enable_entity.state == STATE_ON tz = get_tz(hass) # pylint: disable=invalid-name now: datetime = datetime.now(tz=tz) # 1. Set mode to Heat and preset to Comfort await send_window_change_event(vtherm, False, False, now, False) await send_presence_change_event(vtherm, True, False, now) await send_temperature_change_event(vtherm, 18, now, True) 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 # VTherm should be heating assert vtherm.hvac_mode == HVACMode.HEAT # 2. Set current temperature to 21 5 min later -> should turn off VTherm now = now + timedelta(minutes=5) vtherm._set_now(now) # reset accumulated error (only for testing) vtherm.auto_start_stop_manager._auto_start_stop_algo._accumulated_error = 0 with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event: await send_temperature_change_event(vtherm, 21, now, True) await hass.async_block_till_done() # VTherm should no more be heating assert vtherm.hvac_mode == HVACMode.OFF assert vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP assert vtherm._saved_hvac_mode == HVACMode.HEAT assert mock_send_event.call_count == 2 # turned to off mock_send_event.assert_has_calls( [ call(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}), call( event_type=EventType.AUTO_START_STOP_EVENT, data={ "type": "stop", "name": "overClimate", "cause": "Auto stop conditions reached", "hvac_mode": HVACMode.OFF, "saved_hvac_mode": HVACMode.HEAT, "target_temperature": 19.0, "current_temperature": 21.0, "temperature_slope": 0.3, "accumulated_error": -2, "accumulated_error_threshold": 2, }, ), ] ) # 3. Turn off manually the VTherm. This should be taken into account now = now + timedelta(minutes=1) with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event: await vtherm.async_set_hvac_mode(HVACMode.OFF) await hass.async_block_till_done() # Should be off with reason MANUAL assert vtherm.hvac_mode == HVACMode.OFF assert vtherm.hvac_off_reason == HVAC_OFF_REASON_MANUAL assert vtherm._saved_hvac_mode == HVACMode.OFF assert mock_send_event.call_count == 1 mock_send_event.assert_has_calls( [ call(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}), ] ) # 4. removes the auto-start/stop detection now = now + timedelta(minutes=5) vtherm._set_now(now) with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "homeassistant.helpers.condition.state", return_value=True ): await send_temperature_change_event(vtherm, 15, now, True) await hass.async_block_till_done() # VTherm should no more be heating assert vtherm.hvac_mode == HVACMode.OFF 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