From 56db7fc7dce30ebbdb9ff13e0cef1441a711360d Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 6 Jun 2024 20:41:25 -0500 Subject: [PATCH] Fix exposure checks on some intents (#118988) * Check exposure in climate intent * Check exposure in todo list * Check exposure for weather * Check exposure in humidity intents * Add extra checks to weather tests * Add more checks to todo intent test * Move climate intents to async_match_targets * Update test_intent.py * Update test_intent.py * Remove patch --- homeassistant/components/climate/intent.py | 90 ++------ homeassistant/components/humidifier/intent.py | 45 ++-- homeassistant/components/todo/intent.py | 20 +- homeassistant/components/weather/intent.py | 52 ++--- homeassistant/helpers/intent.py | 2 + tests/components/climate/test_intent.py | 202 +++++++++++++++--- tests/components/humidifier/test_intent.py | 128 ++++++++++- tests/components/todo/test_init.py | 42 +++- tests/components/weather/test_intent.py | 76 ++++--- 9 files changed, 453 insertions(+), 204 deletions(-) diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 48b5c134bbd..53d0891fcda 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -4,11 +4,10 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from homeassistant.helpers.entity_component import EntityComponent -from . import DOMAIN, ClimateEntity +from . import DOMAIN INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" @@ -23,7 +22,10 @@ class GetTemperatureIntent(intent.IntentHandler): intent_type = INTENT_GET_TEMPERATURE description = "Gets the current temperature of a climate device or entity" - slot_schema = {vol.Optional("area"): str, vol.Optional("name"): str} + slot_schema = { + vol.Optional("area"): intent.non_empty_string, + vol.Optional("name"): intent.non_empty_string, + } platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: @@ -31,74 +33,24 @@ class GetTemperatureIntent(intent.IntentHandler): hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - component: EntityComponent[ClimateEntity] = hass.data[DOMAIN] - entities: list[ClimateEntity] = list(component.entities) - climate_entity: ClimateEntity | None = None - climate_state: State | None = None + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] - if not entities: - raise intent.IntentHandleError("No climate entities") + area: str | None = None + if "area" in slots: + area = slots["area"]["value"] - name_slot = slots.get("name", {}) - entity_name: str | None = name_slot.get("value") - entity_text: str | None = name_slot.get("text") - - area_slot = slots.get("area", {}) - area_id = area_slot.get("value") - - if area_id: - # Filter by area and optionally name - area_name = area_slot.get("text") - - for maybe_climate in intent.async_match_states( - hass, name=entity_name, area_name=area_id, domains=[DOMAIN] - ): - climate_state = maybe_climate - break - - if climate_state is None: - raise intent.NoStatesMatchedError( - reason=intent.MatchFailedReason.AREA, - name=entity_text or entity_name, - area=area_name or area_id, - floor=None, - domains={DOMAIN}, - device_classes=None, - ) - - climate_entity = component.get_entity(climate_state.entity_id) - elif entity_name: - # Filter by name - for maybe_climate in intent.async_match_states( - hass, name=entity_name, domains=[DOMAIN] - ): - climate_state = maybe_climate - break - - if climate_state is None: - raise intent.NoStatesMatchedError( - reason=intent.MatchFailedReason.NAME, - name=entity_name, - area=None, - floor=None, - domains={DOMAIN}, - device_classes=None, - ) - - climate_entity = component.get_entity(climate_state.entity_id) - else: - # First entity - climate_entity = entities[0] - climate_state = hass.states.get(climate_entity.entity_id) - - assert climate_entity is not None - - if climate_state is None: - raise intent.IntentHandleError(f"No state for {climate_entity.name}") - - assert climate_state is not None + match_constraints = intent.MatchTargetsConstraints( + name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) response = intent_obj.create_response() response.response_type = intent.IntentResponseType.QUERY_ANSWER - response.async_set_states(matched_states=[climate_state]) + response.async_set_states(matched_states=match_result.states) return response diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py index c713f08b857..425fdbcc679 100644 --- a/homeassistant/components/humidifier/intent.py +++ b/homeassistant/components/humidifier/intent.py @@ -35,7 +35,7 @@ class HumidityHandler(intent.IntentHandler): intent_type = INTENT_HUMIDITY description = "Set desired humidity level" slot_schema = { - vol.Required("name"): cv.string, + vol.Required("name"): intent.non_empty_string, vol.Required("humidity"): vol.All(vol.Coerce(int), vol.Range(0, 100)), } platforms = {DOMAIN} @@ -44,18 +44,19 @@ class HumidityHandler(intent.IntentHandler): """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - states = list( - intent.async_match_states( - hass, - name=slots["name"]["value"], - states=hass.states.async_all(DOMAIN), - ) + + match_constraints = intent.MatchTargetsConstraints( + name=slots["name"]["value"], + domains=[DOMAIN], + assistant=intent_obj.assistant, ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) - if not states: - raise intent.IntentHandleError("No entities matched") - - state = states[0] + state = match_result.states[0] service_data = {ATTR_ENTITY_ID: state.entity_id} humidity = slots["humidity"]["value"] @@ -89,7 +90,7 @@ class SetModeHandler(intent.IntentHandler): intent_type = INTENT_MODE description = "Set humidifier mode" slot_schema = { - vol.Required("name"): cv.string, + vol.Required("name"): intent.non_empty_string, vol.Required("mode"): cv.string, } platforms = {DOMAIN} @@ -98,18 +99,18 @@ class SetModeHandler(intent.IntentHandler): """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - states = list( - intent.async_match_states( - hass, - name=slots["name"]["value"], - states=hass.states.async_all(DOMAIN), - ) + match_constraints = intent.MatchTargetsConstraints( + name=slots["name"]["value"], + domains=[DOMAIN], + assistant=intent_obj.assistant, ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) - if not states: - raise intent.IntentHandleError("No entities matched") - - state = states[0] + state = match_result.states[0] service_data = {ATTR_ENTITY_ID: state.entity_id} intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes") diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index c3c18ea304f..50afe916b27 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -4,7 +4,6 @@ from __future__ import annotations from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from . import DOMAIN, TodoItem, TodoItemStatus, TodoListEntity @@ -22,7 +21,7 @@ class ListAddItemIntent(intent.IntentHandler): intent_type = INTENT_LIST_ADD_ITEM description = "Add item to a todo list" - slot_schema = {"item": cv.string, "name": cv.string} + slot_schema = {"item": intent.non_empty_string, "name": intent.non_empty_string} platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: @@ -37,18 +36,19 @@ class ListAddItemIntent(intent.IntentHandler): target_list: TodoListEntity | None = None # Find matching list - for list_state in intent.async_match_states( - hass, name=list_name, domains=[DOMAIN] - ): - target_list = component.get_entity(list_state.entity_id) - if target_list is not None: - break + match_constraints = intent.MatchTargetsConstraints( + name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + target_list = component.get_entity(match_result.states[0].entity_id) if target_list is None: raise intent.IntentHandleError(f"No to-do list: {list_name}") - assert target_list is not None - # Add to list await target_list.async_create_todo_item( TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) diff --git a/homeassistant/components/weather/intent.py b/homeassistant/components/weather/intent.py index cbb46b943e8..e00a386b619 100644 --- a/homeassistant/components/weather/intent.py +++ b/homeassistant/components/weather/intent.py @@ -6,10 +6,8 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, State from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_component import EntityComponent -from . import DOMAIN, WeatherEntity +from . import DOMAIN INTENT_GET_WEATHER = "HassGetWeather" @@ -24,7 +22,7 @@ class GetWeatherIntent(intent.IntentHandler): intent_type = INTENT_GET_WEATHER description = "Gets the current weather" - slot_schema = {vol.Optional("name"): cv.string} + slot_schema = {vol.Optional("name"): intent.non_empty_string} platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: @@ -32,43 +30,21 @@ class GetWeatherIntent(intent.IntentHandler): hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - weather: WeatherEntity | None = None weather_state: State | None = None - component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] - entities = list(component.entities) - + name: str | None = None if "name" in slots: - # Named weather entity - weather_name = slots["name"]["value"] + name = slots["name"]["value"] - # Find matching weather entity - matching_states = intent.async_match_states( - hass, name=weather_name, domains=[DOMAIN] + match_constraints = intent.MatchTargetsConstraints( + name=name, domains=[DOMAIN], assistant=intent_obj.assistant + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints ) - for maybe_weather_state in matching_states: - weather = component.get_entity(maybe_weather_state.entity_id) - if weather is not None: - weather_state = maybe_weather_state - break - if weather is None: - raise intent.IntentHandleError( - f"No weather entity named {weather_name}" - ) - elif entities: - # First weather entity - weather = entities[0] - weather_name = weather.name - weather_state = hass.states.get(weather.entity_id) - - if weather is None: - raise intent.IntentHandleError("No weather entity") - - if weather_state is None: - raise intent.IntentHandleError(f"No state for weather: {weather.name}") - - assert weather is not None - assert weather_state is not None + weather_state = match_result.states[0] # Create response response = intent_obj.create_response() @@ -77,8 +53,8 @@ class GetWeatherIntent(intent.IntentHandler): success_results=[ intent.IntentResponseTarget( type=intent.IntentResponseTargetType.ENTITY, - name=weather_name, - id=weather.entity_id, + name=weather_state.name, + id=weather_state.entity_id, ) ] ) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index ccef934d6ad..d7c0f90e2f9 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -712,6 +712,7 @@ def async_match_states( domains: Collection[str] | None = None, device_classes: Collection[str] | None = None, states: list[State] | None = None, + assistant: str | None = None, ) -> Iterable[State]: """Simplified interface to async_match_targets that returns states matching the constraints.""" result = async_match_targets( @@ -722,6 +723,7 @@ def async_match_states( floor_name=floor_name, domains=domains, device_classes=device_classes, + assistant=assistant, ), states=states, ) diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 1aaea386320..c9bc27fce53 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -1,21 +1,23 @@ """Test climate intents.""" from collections.abc import Generator -from unittest.mock import patch import pytest +from homeassistant.components import conversation from homeassistant.components.climate import ( DOMAIN, ClimateEntity, HVACMode, intent as climate_intent, ) +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity 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.entity_platform import AddEntitiesCallback +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, @@ -113,6 +115,7 @@ async def test_get_temperature( entity_registry: er.EntityRegistry, ) -> None: """Test HassClimateGetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) await climate_intent.async_setup_intents(hass) climate_1 = MockClimateEntity() @@ -148,10 +151,14 @@ async def test_get_temperature( # First climate entity will be selected (no area) response = await intent.async_handle( - hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 + assert response.matched_states assert response.matched_states[0].entity_id == climate_1.entity_id state = response.matched_states[0] assert state.attributes["current_temperature"] == 10.0 @@ -162,6 +169,7 @@ async def test_get_temperature( "test", climate_intent.INTENT_GET_TEMPERATURE, {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(response.matched_states) == 1 @@ -175,6 +183,7 @@ async def test_get_temperature( "test", climate_intent.INTENT_GET_TEMPERATURE, {"name": {"value": "Climate 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(response.matched_states) == 1 @@ -189,6 +198,7 @@ async def test_get_temperature( "test", climate_intent.INTENT_GET_TEMPERATURE, {"area": {"value": office_area.name}}, + assistant=conversation.DOMAIN, ) # Exception should contain details of what we tried to match @@ -197,7 +207,7 @@ async def test_get_temperature( constraints = error.value.constraints assert constraints.name is None assert constraints.area_name == office_area.name - assert constraints.domains == {DOMAIN} + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) assert constraints.device_classes is None # Check wrong name @@ -214,7 +224,7 @@ async def test_get_temperature( constraints = error.value.constraints assert constraints.name == "Does not exist" assert constraints.area_name is None - assert constraints.domains == {DOMAIN} + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) assert constraints.device_classes is None # Check wrong name with area @@ -231,7 +241,7 @@ async def test_get_temperature( constraints = error.value.constraints assert constraints.name == "Climate 1" assert constraints.area_name == bedroom_area.name - assert constraints.domains == {DOMAIN} + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) assert constraints.device_classes is None @@ -239,62 +249,190 @@ async def test_get_temperature_no_entities( hass: HomeAssistant, ) -> None: """Test HassClimateGetTemperature 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.IntentHandleError): + with pytest.raises(intent.MatchFailedError) as err: await intent.async_handle( - hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN -async def test_get_temperature_no_state( +async def test_not_exposed( hass: HomeAssistant, area_registry: ar.AreaRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test HassClimateGetTemperature intent when states are missing.""" + """Test HassClimateGetTemperature intent when entities aren't exposed.""" + 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 entity_registry.async_get_or_create( DOMAIN, "test", "1234", suggested_object_id="climate_1" ) - await create_mock_platform(hass, [climate_1]) + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_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 same area living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") 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=living_room_area.id + ) - with ( - patch("homeassistant.core.StateMachine.get", return_value=None), - pytest.raises(intent.IntentHandleError), - ): - await intent.async_handle( - hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} - ) - - with ( - patch("homeassistant.core.StateMachine.async_all", return_value=[]), - pytest.raises(intent.MatchFailedError) as error, - ): + # Should fail with empty name + with pytest.raises(intent.InvalidSlotInfo): await intent.async_handle( hass, "test", climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": "Living Room"}}, + {"name": {"value": ""}}, + 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 == "Living Room" - assert constraints.domains == {DOMAIN} - assert constraints.device_classes is None + # Should fail with empty area + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Expose second, hide first + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the area should work + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the exposed entity should work + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_2.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the *unexposed* entity should fail + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_1.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.NAME + + # Expose first, hide second + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + + # Wrong area name + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA + + # Neither are exposed + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with area + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with both names + for name in (climate_1.name, climate_2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT diff --git a/tests/components/humidifier/test_intent.py b/tests/components/humidifier/test_intent.py index 936369f8aa7..6318c5f136d 100644 --- a/tests/components/humidifier/test_intent.py +++ b/tests/components/humidifier/test_intent.py @@ -2,6 +2,8 @@ import pytest +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.humidifier import ( ATTR_AVAILABLE_MODES, ATTR_HUMIDITY, @@ -19,13 +21,22 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.intent import IntentHandleError, async_handle +from homeassistant.helpers.intent import ( + IntentHandleError, + IntentResponseType, + InvalidSlotInfo, + MatchFailedError, + MatchFailedReason, + async_handle, +) +from homeassistant.setup import async_setup_component from tests.common import async_mock_service async def test_intent_set_humidity(hass: HomeAssistant) -> None: """Test the set humidity intent.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} ) @@ -38,6 +49,7 @@ async def test_intent_set_humidity(hass: HomeAssistant) -> None: "test", intent.INTENT_HUMIDITY, {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -54,6 +66,7 @@ async def test_intent_set_humidity(hass: HomeAssistant) -> None: async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None: """Test the set humidity intent for turned off humidifier.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_OFF, {ATTR_HUMIDITY: 40} ) @@ -66,6 +79,7 @@ async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None: "test", intent.INTENT_HUMIDITY, {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -89,6 +103,7 @@ async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None: async def test_intent_set_mode(hass: HomeAssistant) -> None: """Test the set mode intent.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, @@ -108,6 +123,7 @@ async def test_intent_set_mode(hass: HomeAssistant) -> None: "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -127,6 +143,7 @@ async def test_intent_set_mode(hass: HomeAssistant) -> None: async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None: """Test the set mode intent.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_OFF, @@ -146,6 +163,7 @@ async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None: "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -169,6 +187,7 @@ async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None: async def test_intent_set_mode_tests_feature(hass: HomeAssistant) -> None: """Test the set mode intent where modes are not supported.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} ) @@ -181,6 +200,7 @@ async def test_intent_set_mode_tests_feature(hass: HomeAssistant) -> None: "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, ) assert str(excinfo.value) == "Entity bedroom humidifier does not support modes" assert len(mode_calls) == 0 @@ -191,6 +211,7 @@ async def test_intent_set_unknown_mode( hass: HomeAssistant, available_modes: list[str] | None ) -> None: """Test the set mode intent for unsupported mode.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, @@ -210,6 +231,111 @@ async def test_intent_set_unknown_mode( "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "eco"}}, + assistant=conversation.DOMAIN, ) assert str(excinfo.value) == "Entity bedroom humidifier does not support eco mode" assert len(mode_calls) == 0 + + +async def test_intent_errors(hass: HomeAssistant) -> None: + """Test the error conditions for set humidity and set mode intents.""" + assert await async_setup_component(hass, "homeassistant", {}) + entity_id = "humidifier.bedroom_humidifier" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_HUMIDITY: 40, + ATTR_SUPPORTED_FEATURES: 1, + ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_MODE: None, + }, + ) + async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) + await intent.async_setup_intents(hass) + + # Humidifiers are exposed by default + result = await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + assert result.response_type == IntentResponseType.ACTION_DONE + + result = await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + assert result.response_type == IntentResponseType.ACTION_DONE + + # Unexposing it should fail + async_expose_entity(hass, conversation.DOMAIN, entity_id, False) + + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.ASSISTANT + + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.ASSISTANT + + # Expose again to test other errors + async_expose_entity(hass, conversation.DOMAIN, entity_id, True) + + # Empty name should fail + with pytest.raises(InvalidSlotInfo): + await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": ""}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + + with pytest.raises(InvalidSlotInfo): + await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": ""}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + + # Wrong name should fail + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "does not exist"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.NAME + + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": "does not exist"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.NAME diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 4b8e35c9061..72cfaf7e544 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -9,6 +9,8 @@ import zoneinfo import pytest import voluptuous as vol +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.todo import ( DOMAIN, TodoItem, @@ -23,6 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, @@ -1110,6 +1113,7 @@ async def test_add_item_intent( hass_ws_client: WebSocketGenerator, ) -> None: """Test adding items to lists using an intent.""" + assert await async_setup_component(hass, "homeassistant", {}) await todo_intent.async_setup_intents(hass) entity1 = MockTodoListEntity() @@ -1128,6 +1132,7 @@ async def test_add_item_intent( "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "beer"}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -1143,6 +1148,7 @@ async def test_add_item_intent( "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "cheese"}, "name": {"value": "List 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -1157,6 +1163,7 @@ async def test_add_item_intent( "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "wine"}, "name": {"value": "lIST 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -1165,13 +1172,46 @@ async def test_add_item_intent( assert entity2.items[1].summary == "wine" assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION + # Should fail if lists are not exposed + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "cookies"}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + # Missing list - with pytest.raises(intent.IntentHandleError): + with pytest.raises(intent.MatchFailedError): await intent.async_handle( hass, "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, + assistant=conversation.DOMAIN, + ) + + # Fail with empty name/item + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": ""}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, ) diff --git a/tests/components/weather/test_intent.py b/tests/components/weather/test_intent.py index 1fde5882d6e..0f9884791a5 100644 --- a/tests/components/weather/test_intent.py +++ b/tests/components/weather/test_intent.py @@ -1,9 +1,9 @@ """Test weather intents.""" -from unittest.mock import patch - import pytest +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.weather import ( DOMAIN, WeatherEntity, @@ -16,15 +16,18 @@ from homeassistant.setup import async_setup_component async def test_get_weather(hass: HomeAssistant) -> None: """Test get weather for first entity and by name.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "weather", {"weather": {}}) entity1 = WeatherEntity() entity1._attr_name = "Weather 1" entity1.entity_id = "weather.test_1" + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, True) entity2 = WeatherEntity() entity2._attr_name = "Weather 2" entity2.entity_id = "weather.test_2" + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, True) await hass.data[DOMAIN].async_add_entities([entity1, entity2]) @@ -45,15 +48,31 @@ async def test_get_weather(hass: HomeAssistant) -> None: "test", weather_intent.INTENT_GET_WEATHER, {"name": {"value": "Weather 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(response.matched_states) == 1 state = response.matched_states[0] assert state.entity_id == entity2.entity_id + # Should fail if not exposed + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) + for name in (entity1.name, entity2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + async def test_get_weather_wrong_name(hass: HomeAssistant) -> None: """Test get weather with the wrong name.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "weather", {"weather": {}}) entity1 = WeatherEntity() @@ -63,48 +82,43 @@ async def test_get_weather_wrong_name(hass: HomeAssistant) -> None: await hass.data[DOMAIN].async_add_entities([entity1]) await weather_intent.async_setup_intents(hass) + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, True) # Incorrect name - with pytest.raises(intent.IntentHandleError): + with pytest.raises(intent.MatchFailedError) as err: await intent.async_handle( hass, "test", weather_intent.INTENT_GET_WEATHER, {"name": {"value": "not the right name"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.NAME + + # Empty name + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {"name": {"value": ""}}, + assistant=conversation.DOMAIN, ) async def test_get_weather_no_entities(hass: HomeAssistant) -> None: """Test get weather with no weather entities.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "weather", {"weather": {}}) await weather_intent.async_setup_intents(hass) # No weather entities - with pytest.raises(intent.IntentHandleError): - await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {}) - - -async def test_get_weather_no_state(hass: HomeAssistant) -> None: - """Test get weather when state is not returned.""" - assert await async_setup_component(hass, "weather", {"weather": {}}) - - entity1 = WeatherEntity() - entity1._attr_name = "Weather 1" - entity1.entity_id = "weather.test_1" - - await hass.data[DOMAIN].async_add_entities([entity1]) - - await weather_intent.async_setup_intents(hass) - - # Success with state - response = await intent.async_handle( - hass, "test", weather_intent.INTENT_GET_WEATHER, {} - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - - # Failure without state - with ( - patch("homeassistant.core.StateMachine.get", return_value=None), - pytest.raises(intent.IntentHandleError), - ): - await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {}) + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN