diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index af64b06ebe694..3ea0f887e7611 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -69,6 +69,7 @@ FAN_TOP, HVAC_MODES, INTENT_GET_TEMPERATURE, + INTENT_SET_TEMPERATURE, PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 111401a225196..d347ccbbb298e 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -127,6 +127,7 @@ class HVACAction(StrEnum): DOMAIN = "climate" INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" +INTENT_SET_TEMPERATURE = "HassClimateSetTemperature" SERVICE_SET_AUX_HEAT = "set_aux_heat" SERVICE_SET_FAN_MODE = "set_fan_mode" diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 9a8dfdda4ec3e..9837a326188a2 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -4,15 +4,24 @@ import voluptuous as vol +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import config_validation as cv, intent -from . import DOMAIN, INTENT_GET_TEMPERATURE +from . import ( + ATTR_TEMPERATURE, + DOMAIN, + INTENT_GET_TEMPERATURE, + INTENT_SET_TEMPERATURE, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, +) async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the climate intents.""" intent.async_register(hass, GetTemperatureIntent()) + intent.async_register(hass, SetTemperatureIntent()) class GetTemperatureIntent(intent.IntentHandler): @@ -52,3 +61,84 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse response.response_type = intent.IntentResponseType.QUERY_ANSWER response.async_set_states(matched_states=match_result.states) return response + + +class SetTemperatureIntent(intent.IntentHandler): + """Handle SetTemperature intents.""" + + intent_type = INTENT_SET_TEMPERATURE + description = "Sets the target temperature of a climate device or entity" + slot_schema = { + vol.Required("temperature"): vol.Coerce(float), + vol.Optional("area"): intent.non_empty_string, + vol.Optional("name"): intent.non_empty_string, + vol.Optional("floor"): intent.non_empty_string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + temperature: float = slots["temperature"]["value"] + + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + + area_name: str | None = None + if "area" in slots: + area_name = slots["area"]["value"] + + floor_name: str | None = None + if "floor" in slots: + floor_name = slots["floor"]["value"] + + match_constraints = intent.MatchTargetsConstraints( + name=name, + area_name=area_name, + floor_name=floor_name, + domains=[DOMAIN], + assistant=intent_obj.assistant, + features=ClimateEntityFeature.TARGET_TEMPERATURE, + single_target=True, + ) + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ) + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences + ) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + assert match_result.states + climate_state = match_result.states[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + service_data={ATTR_TEMPERATURE: temperature}, + target={ATTR_ENTITY_ID: climate_state.entity_id}, + blocking=True, + ) + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_results( + success_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name=climate_state.name, + id=climate_state.entity_id, + ) + ] + ) + response.async_set_states(matched_states=[climate_state]) + return response diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index d17f3a1747dea..00ab2f8d27846 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -1,13 +1,16 @@ """Test climate intents.""" from collections.abc import Generator +from typing import Any import pytest from homeassistant.components import conversation from homeassistant.components.climate import ( + ATTR_TEMPERATURE, DOMAIN, ClimateEntity, + ClimateEntityFeature, HVACMode, intent as climate_intent, ) @@ -15,7 +18,12 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers import ( + area_registry as ar, + entity_registry as er, + floor_registry as fr, + intent, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.setup import async_setup_component @@ -107,6 +115,20 @@ class MockClimateEntity(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_mode = HVACMode.OFF _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the thermostat temperature.""" + value = kwargs[ATTR_TEMPERATURE] + self._attr_target_temperature = value + + +class MockClimateEntityNoSetTemperature(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] async def test_get_temperature( @@ -436,3 +458,231 @@ async def test_not_exposed( assistant=conversation.DOMAIN, ) assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + +async def test_set_temperature( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test HassClimateSetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + await climate_intent.async_setup_intents(hass) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + climate_1._attr_target_temperature = 10.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + climate_2._attr_target_temperature = 22.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to different areas: + # climate_1 => living room + # climate_2 => bedroom + # nothing in office + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + office_area = area_registry.async_create(name="Office") + + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) + + # Put areas on different floors: + # first floor => living room and office + # upstairs => bedroom + floor_registry = fr.async_get(hass) + first_floor = floor_registry.async_create("First floor") + living_room_area = area_registry.async_update( + living_room_area.id, floor_id=first_floor.floor_id + ) + office_area = area_registry.async_update( + office_area.id, floor_id=first_floor.floor_id + ) + + second_floor = floor_registry.async_create("Second floor") + bedroom_area = area_registry.async_update( + bedroom_area.id, floor_id=second_floor.floor_id + ) + + # Cannot target multiple climate devices + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + + # Select by area explicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"area": {"value": bedroom_area.name}, "temperature": {"value": 20.1}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.1 + + # Select by area implicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_area_id": {"value": bedroom_area.id}, + "temperature": {"value": 20.2}, + }, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.2 + + # Select by floor explicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"floor": {"value": second_floor.name}, "temperature": {"value": 20.3}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.3 + + # Select by floor implicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_floor_id": {"value": second_floor.floor_id}, + "temperature": {"value": 20.4}, + }, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.4 + + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"name": {"value": "Climate 2"}, "temperature": {"value": 20.5}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.5 + + # Check area with no climate entities (explicit) + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"area": {"value": office_area.name}, "temperature": {"value": 20.6}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name is None + assert constraints.area_name == office_area.name + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) + assert constraints.device_classes is None + + # Implicit area with no climate entities will fail with multiple targets + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_area_id": {"value": office_area.id}, + "temperature": {"value": 20.7}, + }, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + + +async def test_set_temperature_no_entities( + hass: HomeAssistant, +) -> None: + """Test HassClimateSetTemperature intent with no climate entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + await climate_intent.async_setup_intents(hass) + + await create_mock_platform(hass, []) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN + + +async def test_set_temperature_not_supported(hass: HomeAssistant) -> None: + """Test HassClimateSetTemperature intent when climate entity doesn't support required feature.""" + assert await async_setup_component(hass, "homeassistant", {}) + await climate_intent.async_setup_intents(hass) + + climate_1 = MockClimateEntityNoSetTemperature() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + climate_1._attr_target_temperature = 10.0 + + await create_mock_platform(hass, [climate_1]) + + with pytest.raises(intent.MatchFailedError) as error: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20.0}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.FEATURE