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:
parent
ba6bcf86ca
commit
67e0ccf677
5 changed files with 780 additions and 93 deletions
|
@ -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, {}
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
Loading…
Add table
Reference in a new issue