"""The tests for the climate component.""" from enum import Enum from typing import Any from unittest.mock import MagicMock, Mock import probatio import pytest from homeassistant.components.climate import DOMAIN, ClimateEntity, HVACMode from homeassistant.components.climate.const import ( ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_PRESET_MODE, ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_HUMIDITY_STEP, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, SWING_HORIZONTAL_OFF, SWING_HORIZONTAL_ON, ClimateEntityFeature, ) from homeassistant.components.climate.services import SET_TEMPERATURE_SCHEMA from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from tests.common import ( MockConfigEntry, MockEntity, async_mock_service, setup_test_component_platform, ) async def test_set_temp_schema_no_req( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test the set temperature schema with missing required data.""" domain = "climate" service = "test_set_temperature" schema = SET_TEMPERATURE_SCHEMA calls = async_mock_service(hass, domain, service, schema) data = {"hvac_mode": "off", "entity_id": ["climate.test_id"]} with pytest.raises(probatio.Invalid): await hass.services.async_call(domain, service, data) await hass.async_block_till_done() assert len(calls) == 0 async def test_set_temp_schema( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test the set temperature schema with ok required data.""" domain = "climate" service = "test_set_temperature" schema = SET_TEMPERATURE_SCHEMA calls = async_mock_service(hass, domain, service, schema) data = {"temperature": 20.0, "hvac_mode": "heat", "entity_id": ["climate.test_id"]} await hass.services.async_call(domain, service, data) await hass.async_block_till_done() assert len(calls) == 1 assert calls[-1].data == data class MockClimateEntity(MockEntity, ClimateEntity): """Mock Climate device to use in tests.""" _attr_supported_features = ( ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.SWING_HORIZONTAL_MODE ) _attr_preset_mode = "home" _attr_preset_modes = ["home", "away"] _attr_fan_mode = "auto" _attr_fan_modes = ["auto", "off"] _attr_swing_mode = "auto" _attr_swing_modes = ["auto", "off"] _attr_swing_horizontal_mode = "on" _attr_swing_horizontal_modes = [SWING_HORIZONTAL_ON, SWING_HORIZONTAL_OFF] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature = 20 _attr_target_temperature_high = 25 _attr_target_temperature_low = 15 @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode. Need to be one of HVACMode.*. """ return HVACMode.HEAT @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes. Need to be a subset of HVAC_MODES. """ return [HVACMode.OFF, HVACMode.HEAT] def set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" self._attr_preset_mode = preset_mode def set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" self._attr_fan_mode = fan_mode def set_swing_mode(self, swing_mode: str) -> None: """Set swing mode.""" self._attr_swing_mode = swing_mode def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: """Set horizontal swing mode.""" self._attr_swing_horizontal_mode = swing_horizontal_mode def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" self._attr_hvac_mode = hvac_mode def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ATTR_TEMPERATURE in kwargs: self._attr_target_temperature = kwargs[ATTR_TEMPERATURE] if ATTR_TARGET_TEMP_HIGH in kwargs: self._attr_target_temperature_high = kwargs[ATTR_TARGET_TEMP_HIGH] self._attr_target_temperature_low = kwargs[ATTR_TARGET_TEMP_LOW] class MockClimateEntityTestMethods(MockClimateEntity): """Mock Climate device.""" def turn_on(self) -> None: """Turn on.""" def turn_off(self) -> None: """Turn off.""" async def test_sync_turn_on(hass: HomeAssistant) -> None: """Test if async turn_on calls sync turn_on.""" climate = MockClimateEntityTestMethods() climate.hass = hass climate.turn_on = MagicMock() await climate.async_turn_on() assert climate.turn_on.called async def test_sync_turn_off(hass: HomeAssistant) -> None: """Test if async turn_off calls sync turn_off.""" climate = MockClimateEntityTestMethods() climate.hass = hass climate.turn_off = MagicMock() await climate.async_turn_off() assert climate.turn_off.called def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, str]]: return [ (enum_field, constant_prefix) for enum_field in enum if enum_field not in [ ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF, ClimateEntityFeature.SWING_HORIZONTAL_MODE, ] ] async def test_temperature_features_is_valid( hass: HomeAssistant, register_test_integration: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: """Test correct features for setting temperature.""" class MockClimateTempEntity(MockClimateEntity): @property def supported_features(self) -> int: """Return supported features.""" return ClimateEntityFeature.TARGET_TEMPERATURE_RANGE class MockClimateTempRangeEntity(MockClimateEntity): @property def supported_features(self) -> int: """Return supported features.""" return ClimateEntityFeature.TARGET_TEMPERATURE climate_temp_entity = MockClimateTempEntity( name="test", entity_id="climate.test_temp" ) climate_temp_range_entity = MockClimateTempRangeEntity( name="test", entity_id="climate.test_range" ) setup_test_component_platform( hass, DOMAIN, entities=[climate_temp_entity, climate_temp_range_entity], from_config_entry=True, ) await hass.config_entries.async_setup(register_test_integration.entry_id) await hass.async_block_till_done() with pytest.raises( ServiceValidationError, match=( "Set temperature action was used with the" " 'Target temperature' parameter but the" " entity does not support it" ), ): await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.test_temp", "temperature": 20, }, blocking=True, ) with pytest.raises( ServiceValidationError, match=( "Set temperature action was used with the" " 'Lower/Upper target temperature' parameter" " but the entity does not support it" ), ): await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.test_range", "target_temp_low": 20, "target_temp_high": 25, }, blocking=True, ) async def test_mode_validation( hass: HomeAssistant, register_test_integration: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: """Test mode validation for hvac_mode, fan, swing and preset.""" climate_entity = MockClimateEntity(name="test", entity_id="climate.test") setup_test_component_platform( hass, DOMAIN, entities=[climate_entity], from_config_entry=True ) await hass.config_entries.async_setup(register_test_integration.entry_id) await hass.async_block_till_done() state = hass.states.get("climate.test") assert state.state == "heat" assert state.attributes.get(ATTR_PRESET_MODE) == "home" assert state.attributes.get(ATTR_FAN_MODE) == "auto" assert state.attributes.get(ATTR_SWING_MODE) == "auto" assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "on" await hass.services.async_call( DOMAIN, SERVICE_SET_PRESET_MODE, { "entity_id": "climate.test", "preset_mode": "away", }, blocking=True, ) await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_MODE, { "entity_id": "climate.test", "swing_mode": "off", }, blocking=True, ) await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_HORIZONTAL_MODE, { "entity_id": "climate.test", "swing_horizontal_mode": "off", }, blocking=True, ) await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, { "entity_id": "climate.test", "fan_mode": "off", }, blocking=True, ) state = hass.states.get("climate.test") assert state.attributes.get(ATTR_PRESET_MODE) == "away" assert state.attributes.get(ATTR_FAN_MODE) == "off" assert state.attributes.get(ATTR_SWING_MODE) == "off" assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "off" with pytest.raises( ServiceValidationError, match="HVAC mode auto is not valid. Valid HVAC modes are: off, heat", ) as exc: await hass.services.async_call( DOMAIN, SERVICE_SET_HVAC_MODE, { "entity_id": "climate.test", "hvac_mode": "auto", }, blocking=True, ) assert ( str(exc.value) == "HVAC mode auto is not valid. Valid HVAC modes are: off, heat" ) assert exc.value.translation_key == "not_valid_hvac_mode" with pytest.raises( ServiceValidationError, match="Preset mode invalid is not valid. Valid preset modes are: home, away", ) as exc: await hass.services.async_call( DOMAIN, SERVICE_SET_PRESET_MODE, { "entity_id": "climate.test", "preset_mode": "invalid", }, blocking=True, ) assert ( str(exc.value) == "Preset mode invalid is not valid. Valid preset modes are: home, away" ) assert exc.value.translation_key == "not_valid_preset_mode" with pytest.raises( ServiceValidationError, match="Swing mode invalid is not valid. Valid swing modes are: auto, off", ) as exc: await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_MODE, { "entity_id": "climate.test", "swing_mode": "invalid", }, blocking=True, ) assert ( str(exc.value) == "Swing mode invalid is not valid. Valid swing modes are: auto, off" ) assert exc.value.translation_key == "not_valid_swing_mode" with pytest.raises( ServiceValidationError, match=( "Horizontal swing mode invalid is not valid." " Valid horizontal swing modes are: on, off" ), ) as exc: await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_HORIZONTAL_MODE, { "entity_id": "climate.test", "swing_horizontal_mode": "invalid", }, blocking=True, ) assert ( str(exc.value) == "Horizontal swing mode invalid is not valid." " Valid horizontal swing modes are: on, off" ) assert exc.value.translation_key == "not_valid_horizontal_swing_mode" with pytest.raises( ServiceValidationError, match="Fan mode invalid is not valid. Valid fan modes are: auto, off", ) as exc: await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, { "entity_id": "climate.test", "fan_mode": "invalid", }, blocking=True, ) assert ( str(exc.value) == "Fan mode invalid is not valid. Valid fan modes are: auto, off" ) assert exc.value.translation_key == "not_valid_fan_mode" async def test_turn_on_off_toggle(hass: HomeAssistant) -> None: """Test turn_on/turn_off/toggle methods.""" class MockClimateEntityTest(MockClimateEntity): """Mock Climate device.""" _attr_hvac_mode = HVACMode.OFF @property def hvac_mode(self) -> HVACMode: """Return hvac mode.""" return self._attr_hvac_mode async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" self._attr_hvac_mode = hvac_mode climate = MockClimateEntityTest() climate.hass = hass await climate.async_turn_on() assert climate.hvac_mode == HVACMode.HEAT await climate.async_turn_off() assert climate.hvac_mode == HVACMode.OFF await climate.async_toggle() assert climate.hvac_mode == HVACMode.HEAT await climate.async_toggle() assert climate.hvac_mode == HVACMode.OFF async def test_sync_toggle(hass: HomeAssistant) -> None: """Test if async toggle calls sync toggle.""" class MockClimateEntityTest(MockClimateEntity): """Mock Climate device.""" _attr_supported_features = ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode. Need to be one of HVACMode.*. """ return HVACMode.HEAT @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes. Need to be a subset of HVAC_MODES. """ return [HVACMode.OFF, HVACMode.HEAT] def turn_on(self) -> None: """Turn on.""" def turn_off(self) -> None: """Turn off.""" def toggle(self) -> None: """Toggle.""" climate = MockClimateEntityTest() climate.hass = hass climate.toggle = Mock() await climate.async_toggle() assert climate.toggle.called async def test_humidity_validation( hass: HomeAssistant, register_test_integration: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: """Test validation for humidity.""" class MockClimateEntityHumidity(MockClimateEntity): """Mock climate class with mocked aux heater.""" _attr_supported_features = ClimateEntityFeature.TARGET_HUMIDITY _attr_target_humidity = 50 _attr_min_humidity = 50 _attr_max_humidity = 60 _attr_target_humidity_step = 5 def set_humidity(self, humidity: int) -> None: """Set new target humidity.""" self._attr_target_humidity = humidity test_climate = MockClimateEntityHumidity( name="Test", unique_id="unique_climate_test", ) setup_test_component_platform( hass, DOMAIN, entities=[test_climate], from_config_entry=True ) await hass.config_entries.async_setup(register_test_integration.entry_id) await hass.async_block_till_done() state = hass.states.get("climate.test") assert state.attributes.get(ATTR_HUMIDITY) == 50 assert state.attributes.get(ATTR_TARGET_HUMIDITY_STEP) == 5 with pytest.raises( ServiceValidationError, match="Provided humidity 1 is not valid. Accepted range is 50 to 60", ) as exc: await hass.services.async_call( DOMAIN, SERVICE_SET_HUMIDITY, { "entity_id": "climate.test", ATTR_HUMIDITY: "1", }, blocking=True, ) assert exc.value.translation_key == "humidity_out_of_range" assert "Check valid humidity 1 in range 50 - 60" in caplog.text with pytest.raises( ServiceValidationError, match="Provided humidity 70 is not valid. Accepted range is 50 to 60", ) as exc: await hass.services.async_call( DOMAIN, SERVICE_SET_HUMIDITY, { "entity_id": "climate.test", ATTR_HUMIDITY: "70", }, blocking=True, ) async def test_temperature_validation( hass: HomeAssistant, register_test_integration: MockConfigEntry ) -> None: """Test validation for temperatures.""" class MockClimateEntityTemp(MockClimateEntity): """Mock climate class with mocked aux heater.""" _attr_supported_features = ( ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) _attr_target_temperature = 15 _attr_target_temperature_high = 18 _attr_target_temperature_low = 10 _attr_target_temperature_step = PRECISION_WHOLE def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ATTR_TEMPERATURE in kwargs: self._attr_target_temperature = kwargs[ATTR_TEMPERATURE] if ATTR_TARGET_TEMP_HIGH in kwargs: self._attr_target_temperature_high = kwargs[ATTR_TARGET_TEMP_HIGH] self._attr_target_temperature_low = kwargs[ATTR_TARGET_TEMP_LOW] test_climate = MockClimateEntityTemp( name="Test", unique_id="unique_climate_test", ) setup_test_component_platform( hass, DOMAIN, entities=[test_climate], from_config_entry=True ) await hass.config_entries.async_setup(register_test_integration.entry_id) await hass.async_block_till_done() state = hass.states.get("climate.test") assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) is None assert state.attributes.get(ATTR_MIN_TEMP) == 7 assert state.attributes.get(ATTR_MAX_TEMP) == 35 with pytest.raises( ServiceValidationError, match="Provided temperature 40.0 is not valid. Accepted range is 7 to 35", ) as exc: await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.test", ATTR_TEMPERATURE: "40", }, blocking=True, ) assert ( str(exc.value) == "Provided temperature 40.0 is not valid. Accepted range is 7 to 35" ) assert exc.value.translation_key == "temp_out_of_range" with pytest.raises( ServiceValidationError, match="Provided temperature 0.0 is not valid. Accepted range is 7 to 35", ) as exc: await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.test", ATTR_TARGET_TEMP_HIGH: "25", ATTR_TARGET_TEMP_LOW: "0", }, blocking=True, ) assert ( str(exc.value) == "Provided temperature 0.0 is not valid. Accepted range is 7 to 35" ) assert exc.value.translation_key == "temp_out_of_range" await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.test", ATTR_TARGET_TEMP_HIGH: "25", ATTR_TARGET_TEMP_LOW: "10", }, blocking=True, ) state = hass.states.get("climate.test") assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 10 assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25 async def test_target_temp_high_higher_than_low( hass: HomeAssistant, register_test_integration: MockConfigEntry ) -> None: """Test that target high is higher than target low.""" class MockClimateEntityTemp(MockClimateEntity): """Mock climate class with mocked aux heater.""" _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) _attr_current_temperature = 15 _attr_target_temperature = 15 _attr_target_temperature_high = 18 _attr_target_temperature_low = 10 _attr_target_temperature_step = PRECISION_WHOLE def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ATTR_TEMPERATURE in kwargs: self._attr_target_temperature = kwargs[ATTR_TEMPERATURE] if ATTR_TARGET_TEMP_HIGH in kwargs: self._attr_target_temperature_high = kwargs[ATTR_TARGET_TEMP_HIGH] self._attr_target_temperature_low = kwargs[ATTR_TARGET_TEMP_LOW] test_climate = MockClimateEntityTemp( name="Test", unique_id="unique_climate_test", ) setup_test_component_platform( hass, DOMAIN, entities=[test_climate], from_config_entry=True ) await hass.config_entries.async_setup(register_test_integration.entry_id) await hass.async_block_till_done() state = hass.states.get("climate.test") assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 15 assert state.attributes.get(ATTR_MIN_TEMP) == 7 assert state.attributes.get(ATTR_MAX_TEMP) == 35 with pytest.raises( ServiceValidationError, match=( "'Lower target temperature' cannot be higher" " than 'Upper target temperature'" ), ) as exc: await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.test", ATTR_TARGET_TEMP_HIGH: "15", ATTR_TARGET_TEMP_LOW: "20", }, blocking=True, ) assert ( str(exc.value) == "'Lower target temperature' cannot be higher than 'Upper target temperature'" ) assert exc.value.translation_key == "low_temp_higher_than_high_temp"