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
This commit is contained in:
Michael Hansen 2024-10-11 12:06:03 -05:00 committed by GitHub
parent ba6bcf86ca
commit 67e0ccf677
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 780 additions and 93 deletions

View file

@ -437,6 +437,130 @@ class DefaultAgent(ConversationEntity):
language: str, language: str,
) -> RecognizeResult | None: ) -> RecognizeResult | None:
"""Search intents for a match to user input.""" """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 custom_result: RecognizeResult | None = None
name_result: RecognizeResult | None = None name_result: RecognizeResult | None = None
best_results: list[RecognizeResult] = [] best_results: list[RecognizeResult] = []
@ -498,49 +622,6 @@ class DefaultAgent(ConversationEntity):
# Successful strict match # Successful strict match
return best_results[0] 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 return None
async def _build_speech( async def _build_speech(
@ -824,20 +905,18 @@ class DefaultAgent(ConversationEntity):
start = time.monotonic() start = time.monotonic()
entity_registry = er.async_get(self.hass) 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 # 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 # 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 # values for a list, just the first. So we will need to match by name no
# matter what. # matter what.
entity_names = [] exposed_entity_names = []
for state in states: 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 # Checked against "requires_context" and "excludes_context" in hassil
context = {"domain": state.domain} context = {"domain": state.domain}
if state.attributes: if state.attributes:
@ -847,24 +926,23 @@ class DefaultAgent(ConversationEntity):
continue continue
context[attr] = state.attributes[attr] context[attr] = state.attributes[attr]
entity = entity_registry.async_get(state.entity_id) if (
entity := entity_registry.async_get(state.entity_id)
if not entity: ) and entity.aliases:
# Default name
entity_names.append((state.name, state.name, context))
continue
if entity.aliases:
for alias in entity.aliases: for alias in entity.aliases:
if not alias.strip(): if not alias.strip():
continue continue
entity_names.append((alias, alias, context)) name_tuple = (alias, alias, context)
if is_exposed:
exposed_entity_names.append(name_tuple)
# Default name # 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. # Expose all areas.
areas = ar.async_get(self.hass) areas = ar.async_get(self.hass)
@ -898,7 +976,9 @@ class DefaultAgent(ConversationEntity):
self._slot_lists = { self._slot_lists = {
"area": TextSlotList.from_tuples(area_names, allow_template=False), "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), "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"): if matched_area_entity := result.entities.get("area"):
matched_area = matched_area_entity.text.strip() 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 unmatched_name := unmatched_text.get("name"):
if matched_area: if matched_area:
# device in area # device in area
@ -1099,6 +1183,12 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str
"entity": unmatched_name, "entity": unmatched_name,
"area": matched_area, "area": matched_area,
} }
if matched_floor:
# device on floor
return ErrorKey.NO_ENTITY_IN_FLOOR, {
"entity": unmatched_name,
"floor": matched_floor,
}
# device only # device only
return ErrorKey.NO_ENTITY, {"entity": unmatched_name} return ErrorKey.NO_ENTITY, {"entity": unmatched_name}
@ -1181,17 +1271,62 @@ def _get_match_error_response(
if reason == intent.MatchFailedReason.STATE: if reason == intent.MatchFailedReason.STATE:
# Entity is not in correct state # Entity is not in correct state
assert match_error.constraints.states assert constraints.states
state = next(iter(match_error.constraints.states)) state = next(iter(constraints.states))
if match_error.constraints.domains: if constraints.domains:
# Translate if domain is available # Translate if domain is available
domain = next(iter(match_error.constraints.domains)) domain = next(iter(constraints.domains))
state = translation.async_translate_state( state = translation.async_translate_state(
hass, state, domain, None, None, None hass, state, domain, None, None, None
) )
return ErrorKey.ENTITY_WRONG_STATE, {"state": state} 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 # Default error
return ErrorKey.NO_INTENT, {} return ErrorKey.NO_INTENT, {}

View file

@ -351,6 +351,7 @@ class MatchTargetsCandidate:
"""Candidate for async_match_targets.""" """Candidate for async_match_targets."""
state: State state: State
is_exposed: bool
entity: entity_registry.RegistryEntry | None = None entity: entity_registry.RegistryEntry | None = None
area: area_registry.AreaEntry | None = None area: area_registry.AreaEntry | None = None
floor: floor_registry.FloorEntry | None = None floor: floor_registry.FloorEntry | None = None
@ -514,29 +515,31 @@ def async_match_targets( # noqa: C901
if not states: if not states:
return MatchTargetsResult(False, MatchFailedReason.DOMAIN) return MatchTargetsResult(False, MatchFailedReason.DOMAIN)
if constraints.assistant: candidates = [
# Filter by exposure MatchTargetsCandidate(
states = [ state=state,
s is_exposed=(
for s in states async_should_expose(hass, constraints.assistant, state.entity_id)
if async_should_expose(hass, constraints.assistant, s.entity_id) if constraints.assistant
] else True
if not states: ),
return MatchTargetsResult(False, MatchFailedReason.ASSISTANT) )
for state in states
]
if constraints.domains and (not filtered_by_domain): if constraints.domains and (not filtered_by_domain):
# Filter by domain (if we didn't already do it) # Filter by domain (if we didn't already do it)
states = [s for s in states if s.domain in constraints.domains] candidates = [c for c in candidates if c.state.domain in constraints.domains]
if not states: if not candidates:
return MatchTargetsResult(False, MatchFailedReason.DOMAIN) return MatchTargetsResult(False, MatchFailedReason.DOMAIN)
if constraints.states: if constraints.states:
# Filter by state # Filter by state
states = [s for s in states if s.state in constraints.states] candidates = [c for c in candidates if c.state.state in constraints.states]
if not states: if not candidates:
return MatchTargetsResult(False, MatchFailedReason.STATE) 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 ( if not (
constraints.name constraints.name
or constraints.features or constraints.features
@ -544,11 +547,18 @@ def async_match_targets( # noqa: C901
or constraints.area_name or constraints.area_name
or constraints.floor_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 # We need entity registry entries now
er = entity_registry.async_get(hass) 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: if constraints.name:
# Filter by entity name or alias # Filter by entity name or alias
@ -637,6 +647,12 @@ def async_match_targets( # noqa: C901
False, MatchFailedReason.AREA, areas=targeted_areas 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): if constraints.name and (not constraints.allow_duplicate_names):
# Check for duplicates # Check for duplicates
if not areas_added: if not areas_added:

View file

@ -371,7 +371,7 @@ async def test_not_exposed(
{"name": {"value": climate_1.name}}, {"name": {"value": climate_1.name}},
assistant=conversation.DOMAIN, 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 # Expose first, hide second
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True)

View file

@ -168,7 +168,7 @@
'speech': dict({ 'speech': dict({
'plain': dict({ 'plain': dict({
'extra_data': None, '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({ 'speech': dict({
'plain': dict({ 'plain': dict({
'extra_data': None, 'extra_data': None,
'speech': 'Sorry, I am not aware of any device called kitchen light', 'speech': 'Sorry, kitchen light is not exposed',
}), }),
}), }),
}), }),

View file

@ -581,7 +581,7 @@ async def test_device_area_context(
@pytest.mark.usefixtures("init_components") @pytest.mark.usefixtures("init_components")
async def test_error_no_device(hass: HomeAssistant) -> None: 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( result = await conversation.async_converse(
hass, "turn on missing entity", None, Context(), None 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") @pytest.mark.usefixtures("init_components")
async def test_error_no_area(hass: HomeAssistant) -> None: 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( result = await conversation.async_converse(
hass, "turn on the lights in missing area", None, Context(), None 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") @pytest.mark.usefixtures("init_components")
async def test_error_no_floor(hass: HomeAssistant) -> None: 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( result = await conversation.async_converse(
hass, "turn on all the lights on missing floor", None, Context(), None 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( async def test_error_no_device_in_area(
hass: HomeAssistant, area_registry: ar.AreaRegistry hass: HomeAssistant, area_registry: ar.AreaRegistry
) -> None: ) -> 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_get_or_create("kitchen_id")
area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
result = await conversation.async_converse( 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") @pytest.mark.usefixtures("init_components")
async def test_error_no_domain(hass: HomeAssistant) -> None: async def test_error_no_domain(hass: HomeAssistant) -> None:
"""Test error message when no devices/entities exist for a domain.""" """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") @pytest.mark.usefixtures("init_components")
async def test_error_no_domain_in_area( async def test_error_no_domain_in_area(
hass: HomeAssistant, area_registry: ar.AreaRegistry hass: HomeAssistant, area_registry: ar.AreaRegistry
@ -695,7 +858,43 @@ async def test_error_no_domain_in_area(
@pytest.mark.usefixtures("init_components") @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, hass: HomeAssistant,
area_registry: ar.AreaRegistry, area_registry: ar.AreaRegistry,
floor_registry: fr.FloorRegistry, 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") @pytest.mark.usefixtures("init_components")
async def test_error_no_device_class(hass: HomeAssistant) -> None: async def test_error_no_device_class(hass: HomeAssistant) -> None:
"""Test error message when no entities of a device class exist.""" """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") @pytest.mark.usefixtures("init_components")
async def test_error_no_device_class_in_area( async def test_error_no_device_class_in_area(
hass: HomeAssistant, area_registry: ar.AreaRegistry 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") @pytest.mark.usefixtures("init_components")
async def test_error_no_intent(hass: HomeAssistant) -> None: async def test_error_no_intent(hass: HomeAssistant) -> None:
"""Test response with an intent match failure.""" """Test response with an intent match failure."""
@ -870,12 +1249,48 @@ async def test_error_duplicate_names(
@pytest.mark.usefixtures("init_components") @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, hass: HomeAssistant,
area_registry: ar.AreaRegistry, area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
) -> None: ) -> 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_get_or_create("kitchen_id")
area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") 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") @pytest.mark.usefixtures("init_components")
async def test_error_wrong_state(hass: HomeAssistant) -> None: async def test_error_wrong_state(hass: HomeAssistant) -> None:
"""Test error message when no entities are in the correct state.""" """Test error message when no entities are in the correct state."""