From 74560ab1390351411ed59de71a85edf8db7d2801 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 3 May 2023 11:18:31 -0500 Subject: [PATCH] Skip unexposed entities in intent handlers (#92415) * Filter intent handler entities by exposure * Add test for skipping unexposed entities --- .../components/conversation/default_agent.py | 1 + homeassistant/components/intent/__init__.py | 4 +- homeassistant/helpers/intent.py | 24 ++++++++- .../conversation/test_default_agent.py | 49 +++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 8a5ef7b294e..dccf394ab3f 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -201,6 +201,7 @@ class DefaultAgent(AbstractConversationAgent): user_input.text, user_input.context, language, + assistant=DOMAIN, ) except intent.IntentHandleError: _LOGGER.exception("Intent handling error") diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 6bc3d88287f..2f5ea26a8a6 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -140,16 +140,18 @@ class GetStateIntentHandler(intent.IntentHandler): area=area, domains=domains, device_classes=device_classes, + assistant=intent_obj.assistant, ) ) _LOGGER.debug( - "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s", + "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s", len(states), name, area, domains, device_classes, + intent_obj.assistant, ) # Create response diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 7a4ca862ee2..8b07c2adc9a 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -11,6 +11,7 @@ from typing import Any, TypeVar import voluptuous as vol +from homeassistant.components.homeassistant.exposed_entities import async_should_expose from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -65,6 +66,7 @@ async def async_handle( text_input: str | None = None, context: Context | None = None, language: str | None = None, + assistant: str | None = None, ) -> IntentResponse: """Handle an intent.""" handler: IntentHandler = hass.data.get(DATA_KEY, {}).get(intent_type) @@ -79,7 +81,14 @@ async def async_handle( language = hass.config.language intent = Intent( - hass, platform, intent_type, slots or {}, text_input, context, language + hass, + platform=platform, + intent_type=intent_type, + slots=slots or {}, + text_input=text_input, + context=context, + language=language, + assistant=assistant, ) try: @@ -208,6 +217,7 @@ def async_match_states( entities: entity_registry.EntityRegistry | None = None, areas: area_registry.AreaRegistry | None = None, devices: device_registry.DeviceRegistry | None = None, + assistant: str | None = None, ) -> Iterable[State]: """Find states that match the constraints.""" if states is None: @@ -258,6 +268,14 @@ def async_match_states( states_and_entities = list(_filter_by_area(states_and_entities, area, devices)) + if assistant is not None: + # Filter by exposure + states_and_entities = [ + (state, entity) + for state, entity in states_and_entities + if async_should_expose(hass, assistant, state.entity_id) + ] + if name is not None: if devices is None: devices = device_registry.async_get(hass) @@ -387,6 +405,7 @@ class ServiceIntentHandler(IntentHandler): area=area, domains=domains, device_classes=device_classes, + assistant=intent_obj.assistant, ) ) @@ -496,6 +515,7 @@ class Intent: "context", "language", "category", + "assistant", ] def __init__( @@ -508,6 +528,7 @@ class Intent: context: Context, language: str, category: IntentCategory | None = None, + assistant: str | None = None, ) -> None: """Initialize an intent.""" self.hass = hass @@ -518,6 +539,7 @@ class Intent: self.context = context self.language = language self.category = category + self.assistant = assistant @callback def create_response(self) -> IntentResponse: diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index ced9d9cc5c8..58fe9371e11 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -174,3 +174,52 @@ async def test_expose_flag_automatically_set( new_light: {"should_expose": True}, test.entity_id: {"should_expose": False}, } + + +async def test_unexposed_entities_skipped( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that unexposed entities are skipped in exposed areas.""" + area_kitchen = area_registry.async_get_or_create("kitchen") + + # Both lights are in the kitchen + exposed_light = entity_registry.async_get_or_create("light", "demo", "1234") + entity_registry.async_update_entity( + exposed_light.entity_id, + area_id=area_kitchen.id, + ) + hass.states.async_set(exposed_light.entity_id, "off") + + unexposed_light = entity_registry.async_get_or_create("light", "demo", "5678") + entity_registry.async_update_entity( + unexposed_light.entity_id, + area_id=area_kitchen.id, + ) + hass.states.async_set(unexposed_light.entity_id, "off") + + # On light is exposed, the other is not + expose_entity(hass, exposed_light.entity_id, True) + expose_entity(hass, unexposed_light.entity_id, False) + + # Only one light should be turned on + calls = async_mock_service(hass, "light", "turn_on") + result = await conversation.async_converse( + hass, "turn on kitchen lights", None, Context(), None + ) + + assert len(calls) == 1 + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Only one light should be returned + hass.states.async_set(exposed_light.entity_id, "on") + hass.states.async_set(unexposed_light.entity_id, "on") + result = await conversation.async_converse( + hass, "how many lights are on in the kitchen", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(result.response.matched_states) == 1 + assert result.response.matched_states[0].entity_id == exposed_light.entity_id