From 67e0ccf677cb7c3c9eaa49d0b11d6b413118e8a1 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 11 Oct 2024 12:06:03 -0500 Subject: [PATCH] Use exposed error messages in Assist (#127503) * Use exposed error messages * Report expose errors * Remove comment * Relative import * Rework expose check logic * Delay creation of all names list, and skip config/hidden entities * Clean up commented code and type issue * Fix test * Move assistant check --- .../components/conversation/default_agent.py | 269 ++++++--- homeassistant/helpers/intent.py | 48 +- tests/components/climate/test_intent.py | 2 +- .../snapshots/test_default_agent.ambr | 4 +- .../conversation/test_default_agent.py | 550 +++++++++++++++++- 5 files changed, 780 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 155909d5fe3..b607ac1d41f 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -437,6 +437,130 @@ class DefaultAgent(ConversationEntity): language: str, ) -> RecognizeResult | None: """Search intents for a match to user input.""" + strict_result = self._recognize_strict( + user_input, lang_intents, slot_lists, intent_context, language + ) + + if strict_result is not None: + # Successful strict match + return strict_result + + # Try again with all entities (including unexposed) + entity_registry = er.async_get(self.hass) + all_entity_names: list[tuple[str, str, dict[str, Any]]] = [] + + for state in self.hass.states.async_all(): + context = {"domain": state.domain} + if state.attributes: + # Include some attributes + for attr in DEFAULT_EXPOSED_ATTRIBUTES: + if attr not in state.attributes: + continue + context[attr] = state.attributes[attr] + + if entity := entity_registry.async_get(state.entity_id): + # Skip config/hidden entities + if (entity.entity_category is not None) or ( + entity.hidden_by is not None + ): + continue + + if entity.aliases: + # Also add aliases + for alias in entity.aliases: + if not alias.strip(): + continue + + all_entity_names.append((alias, alias, context)) + + # Default name + all_entity_names.append((state.name, state.name, context)) + + slot_lists = { + **slot_lists, + "name": TextSlotList.from_tuples(all_entity_names, allow_template=False), + } + + strict_result = self._recognize_strict( + user_input, + lang_intents, + slot_lists, + intent_context, + language, + ) + + if strict_result is not None: + # Not a successful match, but useful for an error message. + # This should fail the intent handling phase (async_match_targets). + return strict_result + + # Try again with missing entities enabled + maybe_result: RecognizeResult | None = None + best_num_matched_entities = 0 + best_num_unmatched_entities = 0 + for result in recognize_all( + user_input.text, + lang_intents.intents, + slot_lists=slot_lists, + intent_context=intent_context, + allow_unmatched_entities=True, + ): + if result.text_chunks_matched < 1: + # Skip results that don't match any literal text + continue + + # Don't count missing entities that couldn't be filled from context + num_matched_entities = 0 + for matched_entity in result.entities_list: + if matched_entity.name not in result.unmatched_entities: + num_matched_entities += 1 + + num_unmatched_entities = 0 + for unmatched_entity in result.unmatched_entities_list: + if isinstance(unmatched_entity, UnmatchedTextEntity): + if unmatched_entity.text != MISSING_ENTITY: + num_unmatched_entities += 1 + else: + num_unmatched_entities += 1 + + if ( + (maybe_result is None) # first result + or (num_matched_entities > best_num_matched_entities) + or ( + # Fewer unmatched entities + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities < best_num_unmatched_entities) + ) + or ( + # More literal text matched + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities == best_num_unmatched_entities) + and (result.text_chunks_matched > maybe_result.text_chunks_matched) + ) + or ( + # Prefer match failures with entities + (result.text_chunks_matched == maybe_result.text_chunks_matched) + and ( + ("name" in result.entities) + or ("name" in result.unmatched_entities) + ) + ) + ): + maybe_result = result + best_num_matched_entities = num_matched_entities + best_num_unmatched_entities = num_unmatched_entities + + return maybe_result + + def _recognize_strict( + self, + user_input: ConversationInput, + lang_intents: LanguageIntents, + slot_lists: dict[str, SlotList], + intent_context: dict[str, Any] | None, + language: str, + ) -> RecognizeResult | None: + """Search intents for a strict match to user input.""" custom_result: RecognizeResult | None = None name_result: RecognizeResult | None = None best_results: list[RecognizeResult] = [] @@ -498,49 +622,6 @@ class DefaultAgent(ConversationEntity): # Successful strict match return best_results[0] - # Try again with missing entities enabled - maybe_result: RecognizeResult | None = None - for result in recognize_all( - user_input.text, - lang_intents.intents, - slot_lists=slot_lists, - intent_context=intent_context, - allow_unmatched_entities=True, - ): - if result.text_chunks_matched < 1: - # Skip results that don't match any literal text - continue - - # Don't count missing entities that couldn't be filled from context - num_unmatched_entities = 0 - for entity in result.unmatched_entities_list: - if isinstance(entity, UnmatchedTextEntity): - if entity.text != MISSING_ENTITY: - num_unmatched_entities += 1 - else: - num_unmatched_entities += 1 - - if maybe_result is None: - # First result - maybe_result = result - best_num_unmatched_entities = num_unmatched_entities - elif num_unmatched_entities < best_num_unmatched_entities: - # Fewer unmatched entities - maybe_result = result - best_num_unmatched_entities = num_unmatched_entities - elif num_unmatched_entities == best_num_unmatched_entities: - if (result.text_chunks_matched > maybe_result.text_chunks_matched) or ( - (result.text_chunks_matched == maybe_result.text_chunks_matched) - and ("name" in result.unmatched_entities) # prefer entities - ): - # More literal text chunks matched, but prefer entities to areas, etc. - maybe_result = result - - if (maybe_result is not None) and maybe_result.unmatched_entities: - # Failed to match, but we have more information about why in unmatched_entities - return maybe_result - - # Complete match failure return None async def _build_speech( @@ -824,20 +905,18 @@ class DefaultAgent(ConversationEntity): start = time.monotonic() entity_registry = er.async_get(self.hass) - states = [ - state - for state in self.hass.states.async_all() - if async_should_expose(self.hass, DOMAIN, state.entity_id) - ] - # Gather exposed entity names. + # Gather entity names, keeping track of exposed names. + # We try intent recognition with only exposed names first, then all names. # # NOTE: We do not pass entity ids in here because multiple entities may # have the same name. The intent matcher doesn't gather all matching # values for a list, just the first. So we will need to match by name no # matter what. - entity_names = [] - for state in states: + exposed_entity_names = [] + for state in self.hass.states.async_all(): + is_exposed = async_should_expose(self.hass, DOMAIN, state.entity_id) + # Checked against "requires_context" and "excludes_context" in hassil context = {"domain": state.domain} if state.attributes: @@ -847,24 +926,23 @@ class DefaultAgent(ConversationEntity): continue context[attr] = state.attributes[attr] - entity = entity_registry.async_get(state.entity_id) - - if not entity: - # Default name - entity_names.append((state.name, state.name, context)) - continue - - if entity.aliases: + if ( + entity := entity_registry.async_get(state.entity_id) + ) and entity.aliases: for alias in entity.aliases: if not alias.strip(): continue - entity_names.append((alias, alias, context)) + name_tuple = (alias, alias, context) + if is_exposed: + exposed_entity_names.append(name_tuple) # Default name - entity_names.append((state.name, state.name, context)) + name_tuple = (state.name, state.name, context) + if is_exposed: + exposed_entity_names.append(name_tuple) - _LOGGER.debug("Exposed entities: %s", entity_names) + _LOGGER.debug("Exposed entities: %s", exposed_entity_names) # Expose all areas. areas = ar.async_get(self.hass) @@ -898,7 +976,9 @@ class DefaultAgent(ConversationEntity): self._slot_lists = { "area": TextSlotList.from_tuples(area_names, allow_template=False), - "name": TextSlotList.from_tuples(entity_names, allow_template=False), + "name": TextSlotList.from_tuples( + exposed_entity_names, allow_template=False + ), "floor": TextSlotList.from_tuples(floor_names, allow_template=False), } @@ -1092,6 +1172,10 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str if matched_area_entity := result.entities.get("area"): matched_area = matched_area_entity.text.strip() + matched_floor: str | None = None + if matched_floor_entity := result.entities.get("floor"): + matched_floor = matched_floor_entity.text.strip() + if unmatched_name := unmatched_text.get("name"): if matched_area: # device in area @@ -1099,6 +1183,12 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str "entity": unmatched_name, "area": matched_area, } + if matched_floor: + # device on floor + return ErrorKey.NO_ENTITY_IN_FLOOR, { + "entity": unmatched_name, + "floor": matched_floor, + } # device only return ErrorKey.NO_ENTITY, {"entity": unmatched_name} @@ -1181,17 +1271,62 @@ def _get_match_error_response( if reason == intent.MatchFailedReason.STATE: # Entity is not in correct state - assert match_error.constraints.states - state = next(iter(match_error.constraints.states)) - if match_error.constraints.domains: + assert constraints.states + state = next(iter(constraints.states)) + if constraints.domains: # Translate if domain is available - domain = next(iter(match_error.constraints.domains)) + domain = next(iter(constraints.domains)) state = translation.async_translate_state( hass, state, domain, None, None, None ) return ErrorKey.ENTITY_WRONG_STATE, {"state": state} + if reason == intent.MatchFailedReason.ASSISTANT: + # Not exposed + if constraints.name: + if constraints.area_name: + return ErrorKey.NO_ENTITY_IN_AREA_EXPOSED, { + "entity": constraints.name, + "area": constraints.area_name, + } + if constraints.floor_name: + return ErrorKey.NO_ENTITY_IN_FLOOR_EXPOSED, { + "entity": constraints.name, + "floor": constraints.floor_name, + } + return ErrorKey.NO_ENTITY_EXPOSED, {"entity": constraints.name} + + if constraints.device_classes: + device_class = next(iter(constraints.device_classes)) + + if constraints.area_name: + return ErrorKey.NO_DEVICE_CLASS_IN_AREA_EXPOSED, { + "device_class": device_class, + "area": constraints.area_name, + } + if constraints.floor_name: + return ErrorKey.NO_DEVICE_CLASS_IN_FLOOR_EXPOSED, { + "device_class": device_class, + "floor": constraints.floor_name, + } + return ErrorKey.NO_DEVICE_CLASS_EXPOSED, {"device_class": device_class} + + if constraints.domains: + domain = next(iter(constraints.domains)) + + if constraints.area_name: + return ErrorKey.NO_DOMAIN_IN_AREA_EXPOSED, { + "domain": domain, + "area": constraints.area_name, + } + if constraints.floor_name: + return ErrorKey.NO_DOMAIN_IN_FLOOR_EXPOSED, { + "domain": domain, + "floor": constraints.floor_name, + } + return ErrorKey.NO_DOMAIN_EXPOSED, {"domain": domain} + # Default error return ErrorKey.NO_INTENT, {} diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 15e38d39dda..6bd02b8660a 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -351,6 +351,7 @@ class MatchTargetsCandidate: """Candidate for async_match_targets.""" state: State + is_exposed: bool entity: entity_registry.RegistryEntry | None = None area: area_registry.AreaEntry | None = None floor: floor_registry.FloorEntry | None = None @@ -514,29 +515,31 @@ def async_match_targets( # noqa: C901 if not states: return MatchTargetsResult(False, MatchFailedReason.DOMAIN) - if constraints.assistant: - # Filter by exposure - states = [ - s - for s in states - if async_should_expose(hass, constraints.assistant, s.entity_id) - ] - if not states: - return MatchTargetsResult(False, MatchFailedReason.ASSISTANT) + candidates = [ + MatchTargetsCandidate( + state=state, + is_exposed=( + async_should_expose(hass, constraints.assistant, state.entity_id) + if constraints.assistant + else True + ), + ) + for state in states + ] if constraints.domains and (not filtered_by_domain): # Filter by domain (if we didn't already do it) - states = [s for s in states if s.domain in constraints.domains] - if not states: + candidates = [c for c in candidates if c.state.domain in constraints.domains] + if not candidates: return MatchTargetsResult(False, MatchFailedReason.DOMAIN) if constraints.states: # Filter by state - states = [s for s in states if s.state in constraints.states] - if not states: + candidates = [c for c in candidates if c.state.state in constraints.states] + if not candidates: return MatchTargetsResult(False, MatchFailedReason.STATE) - # Exit early so we can avoid registry lookups + # Try to exit early so we can avoid registry lookups if not ( constraints.name or constraints.features @@ -544,11 +547,18 @@ def async_match_targets( # noqa: C901 or constraints.area_name or constraints.floor_name ): - return MatchTargetsResult(True, states=states) + if constraints.assistant: + # Check exposure + candidates = [c for c in candidates if c.is_exposed] + if not candidates: + return MatchTargetsResult(False, MatchFailedReason.ASSISTANT) + + return MatchTargetsResult(True, states=[c.state for c in candidates]) # We need entity registry entries now er = entity_registry.async_get(hass) - candidates = [MatchTargetsCandidate(s, er.async_get(s.entity_id)) for s in states] + for candidate in candidates: + candidate.entity = er.async_get(candidate.state.entity_id) if constraints.name: # Filter by entity name or alias @@ -637,6 +647,12 @@ def async_match_targets( # noqa: C901 False, MatchFailedReason.AREA, areas=targeted_areas ) + if constraints.assistant: + # Check exposure + candidates = [c for c in candidates if c.is_exposed] + if not candidates: + return MatchTargetsResult(False, MatchFailedReason.ASSISTANT) + if constraints.name and (not constraints.allow_duplicate_names): # Check for duplicates if not areas_added: diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 54e2e4ff1a6..d17f3a1747d 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -371,7 +371,7 @@ async def test_not_exposed( {"name": {"value": climate_1.name}}, assistant=conversation.DOMAIN, ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.NAME + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT # Expose first, hide second async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr index 051613f0300..b1f2ea0db75 100644 --- a/tests/components/conversation/snapshots/test_default_agent.ambr +++ b/tests/components/conversation/snapshots/test_default_agent.ambr @@ -168,7 +168,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called kitchen light', + 'speech': 'Sorry, kitchen light is not exposed', }), }), }), @@ -358,7 +358,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called kitchen light', + 'speech': 'Sorry, kitchen light is not exposed', }), }), }), diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index cf9d575ebe0..729ef004d9e 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -581,7 +581,7 @@ async def test_device_area_context( @pytest.mark.usefixtures("init_components") async def test_error_no_device(hass: HomeAssistant) -> None: - """Test error message when device/entity is missing.""" + """Test error message when device/entity doesn't exist.""" result = await conversation.async_converse( hass, "turn on missing entity", None, Context(), None ) @@ -594,9 +594,27 @@ async def test_error_no_device(hass: HomeAssistant) -> None: ) +@pytest.mark.usefixtures("init_components") +async def test_error_no_device_exposed(hass: HomeAssistant) -> None: + """Test error message when device/entity exists but is not exposed.""" + hass.states.async_set("light.kitchen_light", "off") + expose_entity(hass, "light.kitchen_light", False) + + result = await conversation.async_converse( + hass, "turn on kitchen light", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, kitchen light is not exposed" + ) + + @pytest.mark.usefixtures("init_components") async def test_error_no_area(hass: HomeAssistant) -> None: - """Test error message when area is missing.""" + """Test error message when area doesn't exist.""" result = await conversation.async_converse( hass, "turn on the lights in missing area", None, Context(), None ) @@ -611,7 +629,7 @@ async def test_error_no_area(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("init_components") async def test_error_no_floor(hass: HomeAssistant) -> None: - """Test error message when floor is missing.""" + """Test error message when floor doesn't exist.""" result = await conversation.async_converse( hass, "turn on all the lights on missing floor", None, Context(), None ) @@ -628,7 +646,7 @@ async def test_error_no_floor(hass: HomeAssistant) -> None: async def test_error_no_device_in_area( hass: HomeAssistant, area_registry: ar.AreaRegistry ) -> None: - """Test error message when area is missing a device/entity.""" + """Test error message when area exists but is does not contain a device/entity.""" area_kitchen = area_registry.async_get_or_create("kitchen_id") area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") result = await conversation.async_converse( @@ -643,6 +661,119 @@ async def test_error_no_device_in_area( ) +@pytest.mark.usefixtures("init_components") +async def test_error_no_device_on_floor( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, +) -> None: + """Test error message when floor exists but is does not contain a device/entity.""" + floor_registry.async_create("ground") + result = await conversation.async_converse( + hass, "turn on missing entity on ground floor", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any device called missing entity on ground floor" + ) + + +@pytest.mark.usefixtures("init_components") +async def test_error_no_device_on_floor_exposed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test error message when a device/entity exists on a floor but isn't exposed.""" + floor_ground = floor_registry.async_create("ground") + + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update( + area_kitchen.id, name="kitchen", floor_id=floor_ground.floor_id + ) + + kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light = entity_registry.async_update_entity( + kitchen_light.entity_id, + name="test light", + area_id=area_kitchen.id, + ) + hass.states.async_set( + kitchen_light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: kitchen_light.name}, + ) + expose_entity(hass, kitchen_light.entity_id, False) + await hass.async_block_till_done() + + # We don't have a sentence for turning on devices by floor + name = MatchEntity(name="name", value=kitchen_light.name, text=kitchen_light.name) + floor = MatchEntity(name="floor", value=floor_ground.name, text=floor_ground.name) + recognize_result = RecognizeResult( + intent=Intent("HassTurnOn"), + intent_data=IntentData([]), + entities={"name": name, "floor": floor}, + entities_list=[name, floor], + ) + + with patch( + "homeassistant.components.conversation.default_agent.recognize_all", + return_value=[recognize_result], + ): + result = await conversation.async_converse( + hass, "turn on test light on the ground floor", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, test light in the ground floor is not exposed" + ) + + +@pytest.mark.usefixtures("init_components") +async def test_error_no_device_in_area_exposed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test error message when a device/entity exists in an area but isn't exposed.""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + + kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light = entity_registry.async_update_entity( + kitchen_light.entity_id, + name="test light", + area_id=area_kitchen.id, + ) + hass.states.async_set( + kitchen_light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: kitchen_light.name}, + ) + expose_entity(hass, kitchen_light.entity_id, False) + await hass.async_block_till_done() + + result = await conversation.async_converse( + hass, "turn on test light in the kitchen", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, test light in the kitchen area is not exposed" + ) + + @pytest.mark.usefixtures("init_components") async def test_error_no_domain(hass: HomeAssistant) -> None: """Test error message when no devices/entities exist for a domain.""" @@ -675,6 +806,38 @@ async def test_error_no_domain(hass: HomeAssistant) -> None: ) +@pytest.mark.usefixtures("init_components") +async def test_error_no_domain_exposed(hass: HomeAssistant) -> None: + """Test error message when devices/entities exist for a domain but are not exposed.""" + hass.states.async_set("fan.test_fan", "off") + expose_entity(hass, "fan.test_fan", False) + await hass.async_block_till_done() + + # We don't have a sentence for turning on all fans + fan_domain = MatchEntity(name="domain", value="fan", text="fans") + recognize_result = RecognizeResult( + intent=Intent("HassTurnOn"), + intent_data=IntentData([]), + entities={"domain": fan_domain}, + entities_list=[fan_domain], + ) + + with patch( + "homeassistant.components.conversation.default_agent.recognize_all", + return_value=[recognize_result], + ): + result = await conversation.async_converse( + hass, "turn on the fans", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + assert result.response.speech["plain"]["speech"] == "Sorry, no fan is exposed" + + @pytest.mark.usefixtures("init_components") async def test_error_no_domain_in_area( hass: HomeAssistant, area_registry: ar.AreaRegistry @@ -695,7 +858,43 @@ async def test_error_no_domain_in_area( @pytest.mark.usefixtures("init_components") -async def test_error_no_domain_in_floor( +async def test_error_no_domain_in_area_exposed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test error message when devices/entities for a domain exist in an area but are not exposed.""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + + kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light = entity_registry.async_update_entity( + kitchen_light.entity_id, + name="test light", + area_id=area_kitchen.id, + ) + hass.states.async_set( + kitchen_light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: kitchen_light.name}, + ) + expose_entity(hass, kitchen_light.entity_id, False) + await hass.async_block_till_done() + + result = await conversation.async_converse( + hass, "turn on the lights in the kitchen", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, no light in the kitchen area is exposed" + ) + + +@pytest.mark.usefixtures("init_components") +async def test_error_no_domain_on_floor( hass: HomeAssistant, area_registry: ar.AreaRegistry, floor_registry: fr.FloorRegistry, @@ -736,6 +935,45 @@ async def test_error_no_domain_in_floor( ) +@pytest.mark.usefixtures("init_components") +async def test_error_no_domain_on_floor_exposed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test error message when devices/entities for a domain exist on a floor but are not exposed.""" + floor_ground = floor_registry.async_create("ground") + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update( + area_kitchen.id, name="kitchen", floor_id=floor_ground.floor_id + ) + kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light = entity_registry.async_update_entity( + kitchen_light.entity_id, + name="test light", + area_id=area_kitchen.id, + ) + hass.states.async_set( + kitchen_light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: kitchen_light.name}, + ) + expose_entity(hass, kitchen_light.entity_id, False) + await hass.async_block_till_done() + + result = await conversation.async_converse( + hass, "turn on all lights on the ground floor", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, no light in the ground floor is exposed" + ) + + @pytest.mark.usefixtures("init_components") async def test_error_no_device_class(hass: HomeAssistant) -> None: """Test error message when no entities of a device class exist.""" @@ -777,6 +1015,54 @@ async def test_error_no_device_class(hass: HomeAssistant) -> None: ) +@pytest.mark.usefixtures("init_components") +async def test_error_no_device_class_exposed(hass: HomeAssistant) -> None: + """Test error message when entities of a device class exist but aren't exposed.""" + # Create a cover entity that is not a window. + # This ensures that the filtering below won't exit early because there are + # no entities in the cover domain. + hass.states.async_set( + "cover.garage_door", + STATE_CLOSED, + attributes={ATTR_DEVICE_CLASS: cover.CoverDeviceClass.GARAGE}, + ) + + # Create a window an ensure it's not exposed + hass.states.async_set( + "cover.test_window", + STATE_CLOSED, + attributes={ATTR_DEVICE_CLASS: cover.CoverDeviceClass.WINDOW}, + ) + expose_entity(hass, "cover.test_window", False) + + # We don't have a sentence for opening all windows + cover_domain = MatchEntity(name="domain", value="cover", text="cover") + window_class = MatchEntity(name="device_class", value="window", text="windows") + recognize_result = RecognizeResult( + intent=Intent("HassTurnOn"), + intent_data=IntentData([]), + entities={"domain": cover_domain, "device_class": window_class}, + entities_list=[cover_domain, window_class], + ) + + with patch( + "homeassistant.components.conversation.default_agent.recognize_all", + return_value=[recognize_result], + ): + result = await conversation.async_converse( + hass, "open all the windows", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + assert ( + result.response.speech["plain"]["speech"] == "Sorry, no window is exposed" + ) + + @pytest.mark.usefixtures("init_components") async def test_error_no_device_class_in_area( hass: HomeAssistant, area_registry: ar.AreaRegistry @@ -796,6 +1082,99 @@ async def test_error_no_device_class_in_area( ) +@pytest.mark.usefixtures("init_components") +async def test_error_no_device_class_in_area_exposed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test error message when entities of a device class exist in an area but are not exposed.""" + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom") + bedroom_window = entity_registry.async_get_or_create("cover", "demo", "1234") + bedroom_window = entity_registry.async_update_entity( + bedroom_window.entity_id, + name="test cover", + area_id=area_bedroom.id, + ) + hass.states.async_set( + bedroom_window.entity_id, + "off", + attributes={ATTR_DEVICE_CLASS: cover.CoverDeviceClass.WINDOW}, + ) + expose_entity(hass, bedroom_window.entity_id, False) + await hass.async_block_till_done() + + result = await conversation.async_converse( + hass, "open bedroom windows", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, no window in the bedroom area is exposed" + ) + + +@pytest.mark.usefixtures("init_components") +async def test_error_no_device_class_on_floor_exposed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test error message when entities of a device class exist in on a floor but are not exposed.""" + floor_ground = floor_registry.async_create("ground") + + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update( + area_bedroom.id, name="bedroom", floor_id=floor_ground.floor_id + ) + bedroom_window = entity_registry.async_get_or_create("cover", "demo", "1234") + bedroom_window = entity_registry.async_update_entity( + bedroom_window.entity_id, + name="test cover", + area_id=area_bedroom.id, + ) + hass.states.async_set( + bedroom_window.entity_id, + "off", + attributes={ATTR_DEVICE_CLASS: cover.CoverDeviceClass.WINDOW}, + ) + expose_entity(hass, bedroom_window.entity_id, False) + await hass.async_block_till_done() + + # We don't have a sentence for opening all windows on a floor + cover_domain = MatchEntity(name="domain", value="cover", text="cover") + window_class = MatchEntity(name="device_class", value="window", text="windows") + floor = MatchEntity(name="floor", value=floor_ground.name, text=floor_ground.name) + recognize_result = RecognizeResult( + intent=Intent("HassTurnOn"), + intent_data=IntentData([]), + entities={"domain": cover_domain, "device_class": window_class, "floor": floor}, + entities_list=[cover_domain, window_class, floor], + ) + + with patch( + "homeassistant.components.conversation.default_agent.recognize_all", + return_value=[recognize_result], + ): + result = await conversation.async_converse( + hass, "open ground floor windows", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, no window in the ground floor is exposed" + ) + + @pytest.mark.usefixtures("init_components") async def test_error_no_intent(hass: HomeAssistant) -> None: """Test response with an intent match failure.""" @@ -870,12 +1249,48 @@ async def test_error_duplicate_names( @pytest.mark.usefixtures("init_components") -async def test_error_duplicate_names_in_area( +async def test_duplicate_names_but_one_is_exposed( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test when multiple devices have the same name (or alias), but only one of them is exposed.""" + kitchen_light_1 = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light_2 = entity_registry.async_get_or_create("light", "demo", "5678") + + # Same name and alias + for light in (kitchen_light_1, kitchen_light_2): + light = entity_registry.async_update_entity( + light.entity_id, + name="kitchen light", + aliases={"overhead light"}, + ) + hass.states.async_set( + light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: light.name}, + ) + + # Only expose one + expose_entity(hass, kitchen_light_1.entity_id, True) + expose_entity(hass, kitchen_light_2.entity_id, False) + + # Check name and alias + async_mock_service(hass, "light", "turn_on") + for name in ("kitchen light", "overhead light"): + # command + result = await conversation.async_converse( + hass, f"turn on {name}", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.matched_states[0].entity_id == kitchen_light_1.entity_id + + +@pytest.mark.usefixtures("init_components") +async def test_error_duplicate_names_same_area( hass: HomeAssistant, area_registry: ar.AreaRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test error message when multiple devices have the same name (or alias).""" + """Test error message when multiple devices have the same name (or alias) in the same area.""" area_kitchen = area_registry.async_get_or_create("kitchen_id") area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") @@ -927,6 +1342,127 @@ async def test_error_duplicate_names_in_area( ) +@pytest.mark.usefixtures("init_components") +async def test_duplicate_names_same_area_but_one_is_exposed( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test when multiple devices have the same name (or alias) in the same area but only one is exposed.""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + + kitchen_light_1 = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light_2 = entity_registry.async_get_or_create("light", "demo", "5678") + + # Same name and alias + for light in (kitchen_light_1, kitchen_light_2): + light = entity_registry.async_update_entity( + light.entity_id, + name="kitchen light", + area_id=area_kitchen.id, + aliases={"overhead light"}, + ) + hass.states.async_set( + light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: light.name}, + ) + + # Only expose one + expose_entity(hass, kitchen_light_1.entity_id, True) + expose_entity(hass, kitchen_light_2.entity_id, False) + + # Check name and alias + async_mock_service(hass, "light", "turn_on") + for name in ("kitchen light", "overhead light"): + # command + result = await conversation.async_converse( + hass, f"turn on {name} in {area_kitchen.name}", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.matched_states[0].entity_id == kitchen_light_1.entity_id + + +@pytest.mark.usefixtures("init_components") +async def test_duplicate_names_different_areas( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test preferred area when multiple devices have the same name (or alias) in different areas.""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom") + + kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light = entity_registry.async_update_entity( + kitchen_light.entity_id, area_id=area_kitchen.id + ) + bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678") + bedroom_light = entity_registry.async_update_entity( + bedroom_light.entity_id, area_id=area_bedroom.id + ) + + # Same name and alias + for light in (kitchen_light, bedroom_light): + light = entity_registry.async_update_entity( + light.entity_id, + name="test light", + aliases={"overhead light"}, + ) + hass.states.async_set( + light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: light.name}, + ) + + # Add a satellite in the kitchen and bedroom + kitchen_entry = MockConfigEntry() + kitchen_entry.add_to_hass(hass) + device_kitchen = device_registry.async_get_or_create( + config_entry_id=kitchen_entry.entry_id, + connections=set(), + identifiers={("demo", "device-kitchen")}, + ) + device_registry.async_update_device(device_kitchen.id, area_id=area_kitchen.id) + + bedroom_entry = MockConfigEntry() + bedroom_entry.add_to_hass(hass) + device_bedroom = device_registry.async_get_or_create( + config_entry_id=bedroom_entry.entry_id, + connections=set(), + identifiers={("demo", "device-bedroom")}, + ) + device_registry.async_update_device(device_bedroom.id, area_id=area_bedroom.id) + + # Check name and alias + async_mock_service(hass, "light", "turn_on") + for name in ("test light", "overhead light"): + # Should fail without a preferred area + result = await conversation.async_converse( + hass, f"turn on {name}", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + + # Target kitchen light by using kitchen device + result = await conversation.async_converse( + hass, f"turn on {name}", None, Context(), None, device_id=device_kitchen.id + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.matched_states[0].entity_id == kitchen_light.entity_id + + # Target bedroom light by using bedroom device + result = await conversation.async_converse( + hass, f"turn on {name}", None, Context(), None, device_id=device_bedroom.id + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.matched_states[0].entity_id == bedroom_light.entity_id + + @pytest.mark.usefixtures("init_components") async def test_error_wrong_state(hass: HomeAssistant) -> None: """Test error message when no entities are in the correct state."""