From adeaf746ec543c0f2df3885b553022e183c2def3 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 26 Jan 2023 09:48:49 -0600 Subject: [PATCH] Use device area id in intent matching (#86678) * Use device area id when matching * Normalize whitespace in response * Add extra test entity --- .../components/conversation/default_agent.py | 18 ++++---- homeassistant/helpers/intent.py | 22 +++++++++- tests/helpers/test_intent.py | 42 ++++++++++++++++++- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 7be37062d13..71d53028821 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -160,17 +160,19 @@ class DefaultAgent(AbstractConversationAgent): ).get(response_key) if response_str: response_template = template.Template(response_str, self.hass) - intent_response.async_set_speech( - response_template.async_render( - { - "slots": { - entity_name: entity_value.text or entity_value.value - for entity_name, entity_value in result.entities.items() - } + speech = response_template.async_render( + { + "slots": { + entity_name: entity_value.text or entity_value.value + for entity_name, entity_value in result.entities.items() } - ) + } ) + # Normalize whitespace + speech = " ".join(speech.strip().split()) + intent_response.async_set_speech(speech) + return ConversationResult( response=intent_response, conversation_id=conversation_id ) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index f7ee7008b68..511c2b2c009 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -20,7 +20,7 @@ from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from . import area_registry, config_validation as cv, entity_registry +from . import area_registry, config_validation as cv, device_registry, entity_registry _LOGGER = logging.getLogger(__name__) _SlotsType = dict[str, Any] @@ -159,6 +159,7 @@ def async_match_states( states: Iterable[State] | None = None, entities: entity_registry.EntityRegistry | None = None, areas: area_registry.AreaRegistry | None = None, + devices: device_registry.DeviceRegistry | None = None, ) -> Iterable[State]: """Find states that match the constraints.""" if states is None: @@ -206,11 +207,28 @@ def async_match_states( assert area is not None, f"No area named {area_name}" if area is not None: + if devices is None: + devices = device_registry.async_get(hass) + + entity_area_ids: dict[str, str | None] = {} + for _state, entity in states_and_entities: + if entity is None: + continue + + if entity.area_id: + # Use entity's area id first + entity_area_ids[entity.id] = entity.area_id + elif entity.device_id: + # Fall back to device area if not set on entity + device = devices.async_get(entity.device_id) + if device is not None: + entity_area_ids[entity.id] = device.area_id + # Filter by area states_and_entities = [ (state, entity) for state, entity in states_and_entities - if (entity is not None) and (entity.area_id == area.id) + if (entity is not None) and (entity_area_ids.get(entity.id) == area.id) ] if name is not None: diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index f190f41072f..11a54b3b529 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -9,6 +9,7 @@ from homeassistant.core import State from homeassistant.helpers import ( area_registry, config_validation as cv, + device_registry, entity_registry, intent, ) @@ -41,7 +42,7 @@ async def test_async_match_states(hass): entities.async_update_entity(state1.entity_id, area_id=area_kitchen.id) entities.async_get_or_create( - "switch", "demo", "1234", suggested_object_id="bedroom" + "switch", "demo", "5678", suggested_object_id="bedroom" ) entities.async_update_entity( state2.entity_id, @@ -92,6 +93,45 @@ async def test_async_match_states(hass): ) +async def test_match_device_area(hass): + """Test async_match_state with a device in an area.""" + areas = area_registry.async_get(hass) + area_kitchen = areas.async_get_or_create("kitchen") + area_bedroom = areas.async_get_or_create("bedroom") + + devices = device_registry.async_get(hass) + kitchen_device = devices.async_get_or_create( + config_entry_id="1234", connections=set(), identifiers={("demo", "id-1234")} + ) + devices.async_update_device(kitchen_device.id, area_id=area_kitchen.id) + + state1 = State( + "light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} + ) + state2 = State( + "light.bedroom", "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"} + ) + state3 = State( + "light.living_room", "on", attributes={ATTR_FRIENDLY_NAME: "living room light"} + ) + entities = entity_registry.async_get(hass) + entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") + entities.async_update_entity(state1.entity_id, device_id=kitchen_device.id) + + entities.async_get_or_create("light", "demo", "5678", suggested_object_id="bedroom") + entities.async_update_entity(state2.entity_id, area_id=area_bedroom.id) + + # Match on area/domain + assert [state1] == list( + intent.async_match_states( + hass, + domains={"light"}, + area_name="kitchen", + states=[state1, state2, state3], + ) + ) + + def test_async_validate_slots(): """Test async_validate_slots of IntentHandler.""" handler1 = MockIntentHandler({vol.Required("name"): cv.string})