Remove language lock from default agent and move around tests (#121531)

* Remove language lock

* Remove unsub for change events

* Remove redundant check

* Simplify intent loading

* Cache intent loading error

* Revert "Remove unsub for change events"

This reverts commit 575266abcd.

* guard instead of assert

* Some more cleanup

* No need to warn during prepare

* Some more cleanup

* Add more timing logs

* Split up tests and rely less on http

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Michael Hansen 2024-07-09 09:05:43 -05:00 committed by GitHub
parent 4a22b95620
commit 4498bf9ec4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 2713 additions and 2779 deletions

View file

@ -3,14 +3,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections import defaultdict
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass from dataclasses import dataclass
import functools import functools
import logging import logging
from pathlib import Path from pathlib import Path
import re import re
from typing import IO, Any import time
from typing import IO, Any, cast
from hassil.expression import Expression, ListReference, Sequence from hassil.expression import Expression, ListReference, Sequence
from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList
@ -36,7 +36,7 @@ from homeassistant.helpers import (
entity_registry as er, entity_registry as er,
floor_registry as fr, floor_registry as fr,
intent, intent,
start, start as ha_start,
template, template,
translation, translation,
) )
@ -60,6 +60,7 @@ METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file" METADATA_CUSTOM_FILE = "hass_custom_file"
DATA_DEFAULT_ENTITY = "conversation_default_entity" DATA_DEFAULT_ENTITY = "conversation_default_entity"
ERROR_SENTINEL = object()
@core.callback @core.callback
@ -140,7 +141,7 @@ async def async_setup_default_agent(
async_should_expose(hass, DOMAIN, state.entity_id) async_should_expose(hass, DOMAIN, state.entity_id)
async_track_state_added_domain(hass, MATCH_ALL, async_entity_state_listener) async_track_state_added_domain(hass, MATCH_ALL, async_entity_state_listener)
start.async_at_started(hass, async_hass_started) ha_start.async_at_started(hass, async_hass_started)
class DefaultAgent(ConversationEntity): class DefaultAgent(ConversationEntity):
@ -154,8 +155,7 @@ class DefaultAgent(ConversationEntity):
) -> None: ) -> None:
"""Initialize the default agent.""" """Initialize the default agent."""
self.hass = hass self.hass = hass
self._lang_intents: dict[str, LanguageIntents] = {} self._lang_intents: dict[str, LanguageIntents | object] = {}
self._lang_lock: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
# intent -> [sentences] # intent -> [sentences]
self._config_intents: dict[str, Any] = config_intents self._config_intents: dict[str, Any] = config_intents
@ -220,11 +220,6 @@ class DefaultAgent(ConversationEntity):
return trigger_result return trigger_result
language = user_input.language or self.hass.config.language language = user_input.language or self.hass.config.language
lang_intents = self._lang_intents.get(language)
# Reload intents if missing or new components
if lang_intents is None:
# Load intents in executor
lang_intents = await self.async_get_or_load_intents(language) lang_intents = await self.async_get_or_load_intents(language)
if lang_intents is None: if lang_intents is None:
@ -235,7 +230,9 @@ class DefaultAgent(ConversationEntity):
slot_lists = self._make_slot_lists() slot_lists = self._make_slot_lists()
intent_context = self._make_intent_context(user_input) intent_context = self._make_intent_context(user_input)
return await self.hass.async_add_executor_job( start = time.monotonic()
result = await self.hass.async_add_executor_job(
self._recognize, self._recognize,
user_input, user_input,
lang_intents, lang_intents,
@ -244,6 +241,13 @@ class DefaultAgent(ConversationEntity):
language, language,
) )
_LOGGER.debug(
"Recognize done in %.2f seconds",
time.monotonic() - start,
)
return result
async def async_process(self, user_input: ConversationInput) -> ConversationResult: async def async_process(self, user_input: ConversationInput) -> ConversationResult:
"""Process a sentence.""" """Process a sentence."""
language = user_input.language or self.hass.config.language language = user_input.language or self.hass.config.language
@ -297,7 +301,7 @@ class DefaultAgent(ConversationEntity):
return ConversationResult(response=response) return ConversationResult(response=response)
# Intent match or failure # Intent match or failure
lang_intents = self._lang_intents.get(language) lang_intents = await self.async_get_or_load_intents(language)
if result is None: if result is None:
# Intent was not recognized # Intent was not recognized
@ -534,19 +538,16 @@ class DefaultAgent(ConversationEntity):
recognize_result: RecognizeResult, recognize_result: RecognizeResult,
) -> str: ) -> str:
# Make copies of the states here so we can add translated names for responses. # Make copies of the states here so we can add translated names for responses.
matched: list[core.State] = [] matched = [
state_copy
for state in intent_response.matched_states: for state in intent_response.matched_states
state_copy = core.State.from_dict(state.as_dict()) if (state_copy := core.State.from_dict(state.as_dict()))
if state_copy is not None: ]
matched.append(state_copy) unmatched = [
state_copy
unmatched: list[core.State] = [] for state in intent_response.unmatched_states
for state in intent_response.unmatched_states: if (state_copy := core.State.from_dict(state.as_dict()))
state_copy = core.State.from_dict(state.as_dict()) ]
if state_copy is not None:
unmatched.append(state_copy)
all_states = matched + unmatched all_states = matched + unmatched
domains = {state.domain for state in all_states} domains = {state.domain for state in all_states}
translations = await translation.async_get_translations( translations = await translation.async_get_translations(
@ -620,38 +621,44 @@ class DefaultAgent(ConversationEntity):
lang_intents = await self.async_get_or_load_intents(language) lang_intents = await self.async_get_or_load_intents(language)
if lang_intents is None:
# No intents loaded # No intents loaded
_LOGGER.warning("No intents were loaded for language: %s", language) if lang_intents is None:
return return
self._make_slot_lists() self._make_slot_lists()
async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None: async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None:
"""Load all intents of a language with lock.""" """Load all intents of a language with lock."""
hass_components = set(self.hass.config.components) if lang_intents := self._lang_intents.get(language):
async with self._lang_lock[language]: return (
return await self.hass.async_add_executor_job( None
self._get_or_load_intents, language, hass_components if lang_intents is ERROR_SENTINEL
else cast(LanguageIntents, lang_intents)
) )
def _get_or_load_intents( start = time.monotonic()
self, language: str, hass_components: set[str]
) -> LanguageIntents | None:
"""Load all intents for language (run inside executor)."""
lang_intents = self._lang_intents.get(language)
if lang_intents is None: result = await self.hass.async_add_executor_job(self._load_intents, language)
if result is None:
self._lang_intents[language] = ERROR_SENTINEL
else:
self._lang_intents[language] = result
_LOGGER.debug(
"Full intents load completed for language=%s in %.2f seconds",
language,
time.monotonic() - start,
)
return result
def _load_intents(self, language: str) -> LanguageIntents | None:
"""Load all intents for language (run inside executor)."""
intents_dict: dict[str, Any] = {} intents_dict: dict[str, Any] = {}
language_variant: str | None = None language_variant: str | None = None
else:
intents_dict = lang_intents.intents_dict
language_variant = lang_intents.language_variant
supported_langs = set(get_languages()) supported_langs = set(get_languages())
intents_changed = False
if not language_variant:
# Choose a language variant upfront and commit to it for custom # Choose a language variant upfront and commit to it for custom
# sentences, etc. # sentences, etc.
all_language_variants = {lang.lower(): lang for lang in supported_langs} all_language_variants = {lang.lower(): lang for lang in supported_langs}
@ -674,20 +681,16 @@ class DefaultAgent(ConversationEntity):
if lang_variant_intents: if lang_variant_intents:
# Merge sentences into existing dictionary # Merge sentences into existing dictionary
merge_dict(intents_dict, lang_variant_intents) # Overriding because source dict is empty
intents_dict = lang_variant_intents
# Will need to recreate graph
intents_changed = True
_LOGGER.debug( _LOGGER.debug(
"Loaded intents language=%s (%s)", "Loaded built-in intents for language=%s (%s)",
language, language,
language_variant, language_variant,
) )
# Check for custom sentences in <config>/custom_sentences/<language>/ # Check for custom sentences in <config>/custom_sentences/<language>/
if lang_intents is None:
# Only load custom sentences once, otherwise they will be re-loaded
# when components change.
custom_sentences_dir = Path( custom_sentences_dir = Path(
self.hass.config.path("custom_sentences", language_variant) self.hass.config.path("custom_sentences", language_variant)
) )
@ -697,16 +700,18 @@ class DefaultAgent(ConversationEntity):
encoding="utf-8" encoding="utf-8"
) as custom_sentences_file: ) as custom_sentences_file:
# Merge custom sentences # Merge custom sentences
if isinstance( if not isinstance(
custom_sentences_yaml := yaml.safe_load( custom_sentences_yaml := yaml.safe_load(custom_sentences_file),
custom_sentences_file
),
dict, dict,
): ):
# Add metadata so we can identify custom sentences in the debugger _LOGGER.warning(
custom_intents_dict = custom_sentences_yaml.get( "Custom sentences file does not match expected format path=%s",
"intents", {} custom_sentences_file.name,
) )
continue
# Add metadata so we can identify custom sentences in the debugger
custom_intents_dict = custom_sentences_yaml.get("intents", {})
for intent_dict in custom_intents_dict.values(): for intent_dict in custom_intents_dict.values():
intent_data_list = intent_dict.get("data", []) intent_data_list = intent_dict.get("data", [])
for intent_data in intent_data_list: for intent_data in intent_data_list:
@ -720,14 +725,7 @@ class DefaultAgent(ConversationEntity):
intent_data["metadata"] = sentence_metadata intent_data["metadata"] = sentence_metadata
merge_dict(intents_dict, custom_sentences_yaml) merge_dict(intents_dict, custom_sentences_yaml)
else:
_LOGGER.warning(
"Custom sentences file does not match expected format path=%s",
custom_sentences_file.name,
)
# Will need to recreate graph
intents_changed = True
_LOGGER.debug( _LOGGER.debug(
"Loaded custom sentences language=%s (%s), path=%s", "Loaded custom sentences language=%s (%s), path=%s",
language, language,
@ -759,7 +757,6 @@ class DefaultAgent(ConversationEntity):
} }
}, },
) )
intents_changed = True
_LOGGER.debug( _LOGGER.debug(
"Loaded intents from configuration.yaml", "Loaded intents from configuration.yaml",
) )
@ -767,12 +764,6 @@ class DefaultAgent(ConversationEntity):
if not intents_dict: if not intents_dict:
return None return None
if not intents_changed and lang_intents is not None:
return lang_intents
# This can be made faster by not re-parsing existing sentences.
# But it will likely only be called once anyways, unless new
# components with sentences are often being loaded.
intents = Intents.from_dict(intents_dict) intents = Intents.from_dict(intents_dict)
# Load responses # Load responses
@ -780,27 +771,22 @@ class DefaultAgent(ConversationEntity):
intent_responses = responses_dict.get("intents", {}) intent_responses = responses_dict.get("intents", {})
error_responses = responses_dict.get("errors", {}) error_responses = responses_dict.get("errors", {})
if lang_intents is None: return LanguageIntents(
lang_intents = LanguageIntents(
intents, intents,
intents_dict, intents_dict,
intent_responses, intent_responses,
error_responses, error_responses,
language_variant, language_variant,
) )
self._lang_intents[language] = lang_intents
else:
lang_intents.intents = intents
lang_intents.intent_responses = intent_responses
lang_intents.error_responses = error_responses
return lang_intents
@core.callback @core.callback
def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None: def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None:
"""Clear slot lists when a registry has changed.""" """Clear slot lists when a registry has changed."""
# Two subscribers can be scheduled at same time
_LOGGER.debug("Clearing slot lists")
if self._unsub_clear_slot_list is None:
return
self._slot_lists = None self._slot_lists = None
assert self._unsub_clear_slot_list is not None
for unsub in self._unsub_clear_slot_list: for unsub in self._unsub_clear_slot_list:
unsub() unsub()
self._unsub_clear_slot_list = None self._unsub_clear_slot_list = None
@ -811,6 +797,8 @@ class DefaultAgent(ConversationEntity):
if self._slot_lists is not None: if self._slot_lists is not None:
return self._slot_lists return self._slot_lists
start = time.monotonic()
entity_registry = er.async_get(self.hass) entity_registry = er.async_get(self.hass)
states = [ states = [
state state
@ -891,6 +879,12 @@ class DefaultAgent(ConversationEntity):
} }
self._listen_clear_slot_list() self._listen_clear_slot_list()
_LOGGER.debug(
"Created slot lists in %.2f seconds",
time.monotonic() - start,
)
return self._slot_lists return self._slot_lists
def _make_intent_context( def _make_intent_context(
@ -934,6 +928,7 @@ class DefaultAgent(ConversationEntity):
return response_template.async_render(response_args) return response_template.async_render(response_args)
@core.callback
def register_trigger( def register_trigger(
self, self,
sentences: list[str], sentences: list[str],
@ -948,6 +943,7 @@ class DefaultAgent(ConversationEntity):
return functools.partial(self._unregister_trigger, trigger_data) return functools.partial(self._unregister_trigger, trigger_data)
@core.callback
def _rebuild_trigger_intents(self) -> None: def _rebuild_trigger_intents(self) -> None:
"""Rebuild the HassIL intents object from the current trigger sentences.""" """Rebuild the HassIL intents object from the current trigger sentences."""
intents_dict = { intents_dict = {
@ -961,20 +957,23 @@ class DefaultAgent(ConversationEntity):
}, },
} }
self._trigger_intents = Intents.from_dict(intents_dict) trigger_intents = Intents.from_dict(intents_dict)
# Assume slot list references are wildcards # Assume slot list references are wildcards
wildcard_names: set[str] = set() wildcard_names: set[str] = set()
for trigger_intent in self._trigger_intents.intents.values(): for trigger_intent in trigger_intents.intents.values():
for intent_data in trigger_intent.data: for intent_data in trigger_intent.data:
for sentence in intent_data.sentences: for sentence in intent_data.sentences:
_collect_list_references(sentence, wildcard_names) _collect_list_references(sentence, wildcard_names)
for wildcard_name in wildcard_names: for wildcard_name in wildcard_names:
self._trigger_intents.slot_lists[wildcard_name] = WildcardSlotList() trigger_intents.slot_lists[wildcard_name] = WildcardSlotList()
self._trigger_intents = trigger_intents
_LOGGER.debug("Rebuilt trigger intents: %s", intents_dict) _LOGGER.debug("Rebuilt trigger intents: %s", intents_dict)
@core.callback
def _unregister_trigger(self, trigger_data: TriggerData) -> None: def _unregister_trigger(self, trigger_data: TriggerData) -> None:
"""Unregister a set of trigger sentences.""" """Unregister a set of trigger sentences."""
self._trigger_sentences.remove(trigger_data) self._trigger_sentences.remove(trigger_data)

View file

@ -0,0 +1,606 @@
# serializer version: 1
# name: test_custom_sentences
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en-us',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'You ordered a stout',
}),
}),
}),
})
# ---
# name: test_custom_sentences.1
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en-us',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'You ordered a lager',
}),
}),
}),
})
# ---
# name: test_custom_sentences_config
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Stealth mode engaged',
}),
}),
}),
})
# ---
# name: test_intent_alias_added_removed
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_alias_added_removed.1
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_alias_added_removed.2
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_valid_targets',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any device called late added alias',
}),
}),
}),
})
# ---
# name: test_intent_conversion_not_expose_new
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_valid_targets',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any device called kitchen light',
}),
}),
}),
})
# ---
# name: test_intent_conversion_not_expose_new.1
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_entity_added_removed
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_entity_added_removed.1
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.late',
'name': 'friendly light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_entity_added_removed.2
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.late',
'name': 'friendly light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_entity_added_removed.3
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_valid_targets',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any device called late added light',
}),
}),
}),
})
# ---
# name: test_intent_entity_exposed
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_entity_exposed.1
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_entity_exposed.2
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_valid_targets',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any device called kitchen light',
}),
}),
}),
})
# ---
# name: test_intent_entity_exposed.3
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_valid_targets',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any device called my cool light',
}),
}),
}),
})
# ---
# name: test_intent_entity_exposed.4
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_entity_exposed.5
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_entity_renamed
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_entity_renamed.1
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'renamed light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_entity_renamed.2
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_valid_targets',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any device called kitchen light',
}),
}),
}),
})
# ---
# name: test_intent_entity_renamed.3
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen light',
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_intent_entity_renamed.4
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_valid_targets',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any device called renamed light',
}),
}),
}),
})
# ---

View file

@ -0,0 +1,711 @@
# serializer version: 1
# name: test_get_agent_list
dict({
'agents': list([
dict({
'id': 'conversation.home_assistant',
'name': 'Home Assistant',
'supported_languages': list([
'af',
'ar',
'bg',
'bn',
'ca',
'cs',
'da',
'de',
'de-CH',
'el',
'en',
'es',
'et',
'eu',
'fa',
'fi',
'fr',
'fr-CA',
'gl',
'gu',
'he',
'hi',
'hr',
'hu',
'id',
'is',
'it',
'ka',
'kn',
'ko',
'lb',
'lt',
'lv',
'ml',
'mn',
'ms',
'nb',
'nl',
'pl',
'pt',
'pt-br',
'ro',
'ru',
'sk',
'sl',
'sr',
'sv',
'sw',
'te',
'tr',
'uk',
'ur',
'vi',
'zh-cn',
'zh-hk',
'zh-tw',
]),
}),
dict({
'id': 'mock-entry',
'name': 'Mock Title',
'supported_languages': list([
'smurfish',
]),
}),
dict({
'id': 'mock-entry-support-all',
'name': 'Mock Title',
'supported_languages': '*',
}),
]),
})
# ---
# name: test_get_agent_list.1
dict({
'agents': list([
dict({
'id': 'conversation.home_assistant',
'name': 'Home Assistant',
'supported_languages': list([
]),
}),
dict({
'id': 'mock-entry',
'name': 'Mock Title',
'supported_languages': list([
'smurfish',
]),
}),
dict({
'id': 'mock-entry-support-all',
'name': 'Mock Title',
'supported_languages': '*',
}),
]),
})
# ---
# name: test_get_agent_list.2
dict({
'agents': list([
dict({
'id': 'conversation.home_assistant',
'name': 'Home Assistant',
'supported_languages': list([
'en',
]),
}),
dict({
'id': 'mock-entry',
'name': 'Mock Title',
'supported_languages': list([
]),
}),
dict({
'id': 'mock-entry-support-all',
'name': 'Mock Title',
'supported_languages': '*',
}),
]),
})
# ---
# name: test_get_agent_list.3
dict({
'agents': list([
dict({
'id': 'conversation.home_assistant',
'name': 'Home Assistant',
'supported_languages': list([
'en',
]),
}),
dict({
'id': 'mock-entry',
'name': 'Mock Title',
'supported_languages': list([
]),
}),
dict({
'id': 'mock-entry-support-all',
'name': 'Mock Title',
'supported_languages': '*',
}),
]),
})
# ---
# name: test_get_agent_list.4
dict({
'agents': list([
dict({
'id': 'conversation.home_assistant',
'name': 'Home Assistant',
'supported_languages': list([
'de',
'de-CH',
]),
}),
dict({
'id': 'mock-entry',
'name': 'Mock Title',
'supported_languages': list([
]),
}),
dict({
'id': 'mock-entry-support-all',
'name': 'Mock Title',
'supported_languages': '*',
}),
]),
})
# ---
# name: test_get_agent_list.5
dict({
'agents': list([
dict({
'id': 'conversation.home_assistant',
'name': 'Home Assistant',
'supported_languages': list([
'de-CH',
'de',
]),
}),
dict({
'id': 'mock-entry',
'name': 'Mock Title',
'supported_languages': list([
]),
}),
dict({
'id': 'mock-entry-support-all',
'name': 'Mock Title',
'supported_languages': '*',
}),
]),
})
# ---
# name: test_http_api_handle_failure
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'failed_to_handle',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'An unexpected error occurred',
}),
}),
}),
})
# ---
# name: test_http_api_no_match
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_intent_match',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': "Sorry, I couldn't understand that",
}),
}),
}),
})
# ---
# name: test_http_api_unexpected_failure
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'unknown',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'An unexpected error occurred',
}),
}),
}),
})
# ---
# name: test_http_processing_intent[None]
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen',
'type': 'entity',
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_http_processing_intent[conversation.home_assistant]
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen',
'type': 'entity',
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_http_processing_intent[homeassistant]
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
dict({
'id': 'light.kitchen',
'name': 'kitchen',
'type': 'entity',
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Turned on the light',
}),
}),
}),
})
# ---
# name: test_ws_api[payload0]
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_intent_match',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': "Sorry, I couldn't understand that",
}),
}),
}),
})
# ---
# name: test_ws_api[payload1]
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_intent_match',
}),
'language': 'test-language',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': "Sorry, I couldn't understand that",
}),
}),
}),
})
# ---
# name: test_ws_api[payload2]
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_intent_match',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': "Sorry, I couldn't understand that",
}),
}),
}),
})
# ---
# name: test_ws_api[payload3]
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_intent_match',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': "Sorry, I couldn't understand that",
}),
}),
}),
})
# ---
# name: test_ws_api[payload4]
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_intent_match',
}),
'language': 'test-language',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': "Sorry, I couldn't understand that",
}),
}),
}),
})
# ---
# name: test_ws_api[payload5]
dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'code': 'no_intent_match',
}),
'language': 'en',
'response_type': 'error',
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': "Sorry, I couldn't understand that",
}),
}),
}),
})
# ---
# name: test_ws_hass_agent_debug
dict({
'results': list([
dict({
'details': dict({
'name': dict({
'name': 'name',
'text': 'my cool light',
'value': 'my cool light',
}),
}),
'intent': dict({
'name': 'HassTurnOn',
}),
'match': True,
'sentence_template': '<turn> on (<area> <name>|<name> [in <area>])',
'slots': dict({
'name': 'my cool light',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': True,
}),
}),
'unmatched_slots': dict({
}),
}),
dict({
'details': dict({
'name': dict({
'name': 'name',
'text': 'my cool light',
'value': 'my cool light',
}),
}),
'intent': dict({
'name': 'HassTurnOff',
}),
'match': True,
'sentence_template': '[<turn>] (<area> <name>|<name> [in <area>]) [to] off',
'slots': dict({
'name': 'my cool light',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': True,
}),
}),
'unmatched_slots': dict({
}),
}),
dict({
'details': dict({
'area': dict({
'name': 'area',
'text': 'kitchen',
'value': 'kitchen',
}),
'domain': dict({
'name': 'domain',
'text': '',
'value': 'light',
}),
}),
'intent': dict({
'name': 'HassTurnOn',
}),
'match': True,
'sentence_template': '<turn> on [all] <light> in <area>',
'slots': dict({
'area': 'kitchen',
'domain': 'light',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': True,
}),
}),
'unmatched_slots': dict({
}),
}),
dict({
'details': dict({
'area': dict({
'name': 'area',
'text': 'kitchen',
'value': 'kitchen',
}),
'domain': dict({
'name': 'domain',
'text': 'lights',
'value': 'light',
}),
'state': dict({
'name': 'state',
'text': 'on',
'value': 'on',
}),
}),
'intent': dict({
'name': 'HassGetState',
}),
'match': True,
'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [in <area>]',
'slots': dict({
'area': 'kitchen',
'domain': 'lights',
'state': 'on',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': False,
}),
}),
'unmatched_slots': dict({
}),
}),
None,
]),
})
# ---
# name: test_ws_hass_agent_debug_custom_sentence
dict({
'results': list([
dict({
'details': dict({
'beer_style': dict({
'name': 'beer_style',
'text': 'lager',
'value': 'lager',
}),
}),
'file': 'en/beer.yaml',
'intent': dict({
'name': 'OrderBeer',
}),
'match': True,
'sentence_template': "I'd like to order a {beer_style} [please]",
'slots': dict({
'beer_style': 'lager',
}),
'source': 'custom',
'targets': dict({
}),
'unmatched_slots': dict({
}),
}),
]),
})
# ---
# name: test_ws_hass_agent_debug_null_result
dict({
'results': list([
None,
]),
})
# ---
# name: test_ws_hass_agent_debug_out_of_range
dict({
'results': list([
dict({
'details': dict({
'brightness': dict({
'name': 'brightness',
'text': '100%',
'value': 100,
}),
'name': dict({
'name': 'name',
'text': 'test light',
'value': 'test light',
}),
}),
'intent': dict({
'name': 'HassLightSet',
}),
'match': True,
'sentence_template': '[<numeric_value_set>] <name> brightness [to] <brightness>',
'slots': dict({
'brightness': '100%',
'name': 'test light',
}),
'source': 'builtin',
'targets': dict({
'light.demo_1234': dict({
'matched': True,
}),
}),
'unmatched_slots': dict({
}),
}),
]),
})
# ---
# name: test_ws_hass_agent_debug_out_of_range.1
dict({
'results': list([
dict({
'details': dict({
'name': dict({
'name': 'name',
'text': 'test light',
'value': 'test light',
}),
}),
'intent': dict({
'name': 'HassLightSet',
}),
'match': False,
'sentence_template': '[<numeric_value_set>] <name> brightness [to] <brightness>',
'slots': dict({
'name': 'test light',
}),
'source': 'builtin',
'targets': dict({
}),
'unmatched_slots': dict({
'brightness': 1001,
}),
}),
]),
})
# ---
# name: test_ws_hass_agent_debug_sentence_trigger
dict({
'results': list([
dict({
'match': True,
'sentence_template': 'hello[ world]',
'source': 'trigger',
}),
]),
})
# ---

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,20 @@
"""Test for the default agent.""" """Test for the default agent."""
from collections import defaultdict from collections import defaultdict
import os
import tempfile
from typing import Any from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult
import pytest import pytest
from syrupy import SnapshotAssertion
import yaml
from homeassistant.components import conversation, cover, media_player from homeassistant.components import conversation, cover, media_player
from homeassistant.components.conversation import default_agent from homeassistant.components.conversation import default_agent
from homeassistant.components.conversation.models import ConversationInput
from homeassistant.components.cover import SERVICE_OPEN_COVER
from homeassistant.components.homeassistant.exposed_entities import ( from homeassistant.components.homeassistant.exposed_entities import (
async_get_assistant_settings, async_get_assistant_settings,
) )
@ -17,10 +23,12 @@ from homeassistant.components.intent import (
TimerInfo, TimerInfo,
async_register_timer_handler, async_register_timer_handler,
) )
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.const import ( from homeassistant.const import (
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
ATTR_FRIENDLY_NAME, ATTR_FRIENDLY_NAME,
STATE_CLOSED, STATE_CLOSED,
STATE_ON,
STATE_UNKNOWN, STATE_UNKNOWN,
) )
from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant, callback from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant, callback
@ -34,9 +42,28 @@ from homeassistant.helpers import (
) )
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from . import expose_entity from . import expose_entity, expose_new
from tests.common import MockConfigEntry, async_mock_service from tests.common import (
MockConfigEntry,
MockUser,
async_mock_service,
setup_test_component_platform,
)
from tests.components.light.common import MockLight
class OrderBeerIntentHandler(intent.IntentHandler):
"""Handle OrderBeer intent."""
intent_type = "OrderBeer"
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Return speech response."""
beer_style = intent_obj.slots["beer_style"]["value"]
response = intent_obj.create_response()
response.async_set_speech(f"You ordered a {beer_style}")
return response
@pytest.fixture @pytest.fixture
@ -1363,3 +1390,671 @@ async def test_name_wildcard_lower_priority(hass: HomeAssistant) -> None:
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert not beer_handler.triggered assert not beer_handler.triggered
assert food_handler.triggered assert food_handler.triggered
async def test_intent_entity_added_removed(
hass: HomeAssistant,
init_components,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test processing intent via HTTP API with entities added later.
We want to ensure that adding an entity later busts the cache
so that the new entity is available as well as any aliases.
"""
context = Context()
entity_registry.async_get_or_create(
"light", "demo", "1234", suggested_object_id="kitchen"
)
entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"})
await hass.async_block_till_done()
hass.states.async_set("light.kitchen", "off")
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
result = await conversation.async_converse(
hass, "turn on my cool light", None, context
)
assert len(calls) == 1
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
# Add an entity
entity_registry.async_get_or_create(
"light", "demo", "5678", suggested_object_id="late"
)
hass.states.async_set("light.late", "off", {"friendly_name": "friendly light"})
result = await conversation.async_converse(
hass, "turn on friendly light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
# Now add an alias
entity_registry.async_update_entity("light.late", aliases={"late added light"})
result = await conversation.async_converse(
hass, "turn on late added light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
# Now delete the entity
hass.states.async_remove("light.late")
result = await conversation.async_converse(
hass, "turn on late added light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "error"
async def test_intent_alias_added_removed(
hass: HomeAssistant,
init_components,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test processing intent via HTTP API with aliases added later.
We want to ensure that adding an alias later busts the cache
so that the new alias is available.
"""
context = Context()
entity_registry.async_get_or_create(
"light", "demo", "1234", suggested_object_id="kitchen"
)
hass.states.async_set("light.kitchen", "off", {"friendly_name": "kitchen light"})
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
result = await conversation.async_converse(
hass, "turn on kitchen light", None, context
)
assert len(calls) == 1
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
# Add an alias
entity_registry.async_update_entity("light.kitchen", aliases={"late added alias"})
result = await conversation.async_converse(
hass, "turn on late added alias", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
# Now remove the alieas
entity_registry.async_update_entity("light.kitchen", aliases={})
result = await conversation.async_converse(
hass, "turn on late added alias", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "error"
async def test_intent_entity_renamed(
hass: HomeAssistant,
init_components,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test processing intent via HTTP API with entities renamed later.
We want to ensure that renaming an entity later busts the cache
so that the new name is used.
"""
context = Context()
entity = MockLight("kitchen light", STATE_ON)
entity._attr_unique_id = "1234"
entity.entity_id = "light.kitchen"
setup_test_component_platform(hass, LIGHT_DOMAIN, [entity])
assert await async_setup_component(
hass,
LIGHT_DOMAIN,
{LIGHT_DOMAIN: [{"platform": "test"}]},
)
await hass.async_block_till_done()
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
result = await conversation.async_converse(
hass, "turn on kitchen light", None, context
)
assert len(calls) == 1
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
# Rename the entity
entity_registry.async_update_entity("light.kitchen", name="renamed light")
await hass.async_block_till_done()
result = await conversation.async_converse(
hass, "turn on renamed light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
result = await conversation.async_converse(
hass, "turn on kitchen light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "error"
# Now clear the custom name
entity_registry.async_update_entity("light.kitchen", name=None)
await hass.async_block_till_done()
result = await conversation.async_converse(
hass, "turn on kitchen light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
result = await conversation.async_converse(
hass, "turn on renamed light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "error"
async def test_intent_entity_exposed(
hass: HomeAssistant,
init_components,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test processing intent via HTTP API with manual expose.
We want to ensure that manually exposing an entity later busts the cache
so that the new setting is used.
"""
context = Context()
entity = MockLight("kitchen light", STATE_ON)
entity._attr_unique_id = "1234"
entity.entity_id = "light.kitchen"
setup_test_component_platform(hass, LIGHT_DOMAIN, [entity])
assert await async_setup_component(
hass,
LIGHT_DOMAIN,
{LIGHT_DOMAIN: [{"platform": "test"}]},
)
await hass.async_block_till_done()
entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"})
await hass.async_block_till_done()
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
result = await conversation.async_converse(
hass, "turn on kitchen light", None, context
)
assert len(calls) == 1
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
calls.clear()
result = await conversation.async_converse(
hass, "turn on my cool light", None, context
)
assert len(calls) == 1
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
# Unexpose the entity
expose_entity(hass, "light.kitchen", False)
await hass.async_block_till_done(wait_background_tasks=True)
result = await conversation.async_converse(
hass, "turn on kitchen light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "error"
result = await conversation.async_converse(
hass, "turn on my cool light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "error"
# Now expose the entity
expose_entity(hass, "light.kitchen", True)
await hass.async_block_till_done()
result = await conversation.async_converse(
hass, "turn on kitchen light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
result = await conversation.async_converse(
hass, "turn on my cool light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
async def test_intent_conversion_not_expose_new(
hass: HomeAssistant,
init_components,
hass_admin_user: MockUser,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test processing intent via HTTP API when not exposing new entities."""
# Disable exposing new entities to the default agent
expose_new(hass, False)
context = Context()
entity = MockLight("kitchen light", STATE_ON)
entity._attr_unique_id = "1234"
entity.entity_id = "light.kitchen"
setup_test_component_platform(hass, LIGHT_DOMAIN, [entity])
assert await async_setup_component(
hass,
LIGHT_DOMAIN,
{LIGHT_DOMAIN: [{"platform": "test"}]},
)
await hass.async_block_till_done()
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
result = await conversation.async_converse(
hass, "turn on kitchen light", None, context
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "error"
# Expose the entity
expose_entity(hass, "light.kitchen", True)
await hass.async_block_till_done()
result = await conversation.async_converse(
hass, "turn on kitchen light", None, context
)
assert len(calls) == 1
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
async def test_custom_sentences(
hass: HomeAssistant,
init_components,
snapshot: SnapshotAssertion,
) -> None:
"""Test custom sentences with a custom intent."""
# Expecting testing_config/custom_sentences/en/beer.yaml
intent.async_register(hass, OrderBeerIntentHandler())
# Don't use "en" to test loading custom sentences with language variants.
language = "en-us"
# Invoke intent via HTTP API
for beer_style in ("stout", "lager"):
result = await conversation.async_converse(
hass,
f"I'd like to order a {beer_style}, please",
None,
Context(),
language=language,
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
assert (
data["response"]["speech"]["plain"]["speech"]
== f"You ordered a {beer_style}"
)
async def test_custom_sentences_config(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
) -> None:
"""Test custom sentences with a custom intent in config."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(
hass,
"conversation",
{"conversation": {"intents": {"StealthMode": ["engage stealth mode"]}}},
)
assert await async_setup_component(hass, "intent", {})
assert await async_setup_component(
hass,
"intent_script",
{
"intent_script": {
"StealthMode": {"speech": {"text": "Stealth mode engaged"}}
}
},
)
# Invoke intent via HTTP API
result = await conversation.async_converse(
hass, "engage stealth mode", None, Context(), None
)
data = result.as_dict()
assert data == snapshot
assert data["response"]["response_type"] == "action_done"
assert data["response"]["speech"]["plain"]["speech"] == "Stealth mode engaged"
async def test_language_region(hass: HomeAssistant, init_components) -> None:
"""Test regional languages."""
hass.states.async_set("light.kitchen", "off")
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
# Add fake region
language = f"{hass.config.language}-YZ"
await hass.services.async_call(
"conversation",
"process",
{
conversation.ATTR_TEXT: "turn on the kitchen",
conversation.ATTR_LANGUAGE: language,
},
)
await hass.async_block_till_done()
assert len(calls) == 1
call = calls[0]
assert call.domain == LIGHT_DOMAIN
assert call.service == "turn_on"
assert call.data == {"entity_id": ["light.kitchen"]}
async def test_non_default_response(hass: HomeAssistant, init_components) -> None:
"""Test intent response that is not the default."""
hass.states.async_set("cover.front_door", "closed")
calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
agent = default_agent.async_get_default_agent(hass)
assert isinstance(agent, default_agent.DefaultAgent)
result = await agent.async_process(
ConversationInput(
text="open the front door",
context=Context(),
conversation_id=None,
device_id=None,
language=hass.config.language,
agent_id=None,
)
)
assert len(calls) == 1
assert result.response.speech["plain"]["speech"] == "Opened"
async def test_turn_on_area(
hass: HomeAssistant,
init_components,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test turning on an area."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
kitchen_area = area_registry.async_create("kitchen")
device_registry.async_update_device(device.id, area_id=kitchen_area.id)
entity_registry.async_get_or_create(
"light", "demo", "1234", suggested_object_id="stove"
)
entity_registry.async_update_entity(
"light.stove", aliases={"my stove light"}, area_id=kitchen_area.id
)
hass.states.async_set("light.stove", "off")
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
await hass.services.async_call(
"conversation",
"process",
{conversation.ATTR_TEXT: "turn on lights in the kitchen"},
)
await hass.async_block_till_done()
assert len(calls) == 1
call = calls[0]
assert call.domain == LIGHT_DOMAIN
assert call.service == "turn_on"
assert call.data == {"entity_id": ["light.stove"]}
basement_area = area_registry.async_create("basement")
device_registry.async_update_device(device.id, area_id=basement_area.id)
entity_registry.async_update_entity("light.stove", area_id=basement_area.id)
calls.clear()
# Test that the area is updated
await hass.services.async_call(
"conversation",
"process",
{conversation.ATTR_TEXT: "turn on lights in the kitchen"},
)
await hass.async_block_till_done()
assert len(calls) == 0
# Test the new area works
await hass.services.async_call(
"conversation",
"process",
{conversation.ATTR_TEXT: "turn on lights in the basement"},
)
await hass.async_block_till_done()
assert len(calls) == 1
call = calls[0]
assert call.domain == LIGHT_DOMAIN
assert call.service == "turn_on"
assert call.data == {"entity_id": ["light.stove"]}
async def test_light_area_same_name(
hass: HomeAssistant,
init_components,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test turning on a light with the same name as an area."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
kitchen_area = area_registry.async_create("kitchen")
device_registry.async_update_device(device.id, area_id=kitchen_area.id)
kitchen_light = entity_registry.async_get_or_create(
"light", "demo", "1234", original_name="kitchen light"
)
entity_registry.async_update_entity(
kitchen_light.entity_id, area_id=kitchen_area.id
)
hass.states.async_set(
kitchen_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "kitchen light"}
)
ceiling_light = entity_registry.async_get_or_create(
"light", "demo", "5678", original_name="ceiling light"
)
entity_registry.async_update_entity(
ceiling_light.entity_id, area_id=kitchen_area.id
)
hass.states.async_set(
ceiling_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "ceiling light"}
)
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
await hass.services.async_call(
"conversation",
"process",
{conversation.ATTR_TEXT: "turn on kitchen light"},
)
await hass.async_block_till_done()
# Should only turn on one light instead of all lights in the kitchen
assert len(calls) == 1
call = calls[0]
assert call.domain == LIGHT_DOMAIN
assert call.service == "turn_on"
assert call.data == {"entity_id": [kitchen_light.entity_id]}
async def test_custom_sentences_priority(
hass: HomeAssistant,
hass_admin_user: MockUser,
snapshot: SnapshotAssertion,
) -> None:
"""Test that user intents from custom_sentences have priority over builtin intents/sentences."""
with tempfile.NamedTemporaryFile(
mode="w+",
encoding="utf-8",
suffix=".yaml",
dir=os.path.join(hass.config.config_dir, "custom_sentences", "en"),
) as custom_sentences_file:
# Add a custom sentence that would match a builtin sentence.
# Custom sentences have priority.
yaml.dump(
{
"language": "en",
"intents": {
"CustomIntent": {"data": [{"sentences": ["turn on the lamp"]}]}
},
},
custom_sentences_file,
)
custom_sentences_file.flush()
custom_sentences_file.seek(0)
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "conversation", {})
assert await async_setup_component(hass, "light", {})
assert await async_setup_component(hass, "intent", {})
assert await async_setup_component(
hass,
"intent_script",
{
"intent_script": {
"CustomIntent": {"speech": {"text": "custom response"}}
}
},
)
# Ensure that a "lamp" exists so that we can verify the custom intent
# overrides the builtin sentence.
hass.states.async_set("light.lamp", "off")
result = await conversation.async_converse(
hass,
"turn on the lamp",
None,
Context(),
language=hass.config.language,
)
data = result.as_dict()
assert data["response"]["response_type"] == "action_done"
assert data["response"]["speech"]["plain"]["speech"] == "custom response"
async def test_config_sentences_priority(
hass: HomeAssistant,
hass_admin_user: MockUser,
snapshot: SnapshotAssertion,
) -> None:
"""Test that user intents from configuration.yaml have priority over builtin intents/sentences."""
# Add a custom sentence that would match a builtin sentence.
# Custom sentences have priority.
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "intent", {})
assert await async_setup_component(
hass,
"conversation",
{"conversation": {"intents": {"CustomIntent": ["turn on the lamp"]}}},
)
assert await async_setup_component(hass, "light", {})
assert await async_setup_component(
hass,
"intent_script",
{"intent_script": {"CustomIntent": {"speech": {"text": "custom response"}}}},
)
# Ensure that a "lamp" exists so that we can verify the custom intent
# overrides the builtin sentence.
hass.states.async_set("light.lamp", "off")
result = await conversation.async_converse(
hass,
"turn on the lamp",
None,
Context(),
language=hass.config.language,
)
data = result.as_dict()
assert data["response"]["response_type"] == "action_done"
assert data["response"]["speech"]["plain"]["speech"] == "custom response"

View file

@ -0,0 +1,524 @@
"""The tests for the HTTP API of the Conversation component."""
from http import HTTPStatus
from typing import Any
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.conversation import default_agent
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar, entity_registry as er, intent
from homeassistant.setup import async_setup_component
from . import MockAgent
from tests.common import async_mock_service
from tests.typing import ClientSessionGenerator, WebSocketGenerator
AGENT_ID_OPTIONS = [
None,
# Old value of conversation.HOME_ASSISTANT_AGENT,
"homeassistant",
# Current value of conversation.HOME_ASSISTANT_AGENT,
"conversation.home_assistant",
]
class OrderBeerIntentHandler(intent.IntentHandler):
"""Handle OrderBeer intent."""
intent_type = "OrderBeer"
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Return speech response."""
beer_style = intent_obj.slots["beer_style"]["value"]
response = intent_obj.create_response()
response.async_set_speech(f"You ordered a {beer_style}")
return response
@pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS)
async def test_http_processing_intent(
hass: HomeAssistant,
init_components,
hass_client: ClientSessionGenerator,
agent_id,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test processing intent via HTTP API."""
# Add an alias
entity_registry.async_get_or_create(
"light", "demo", "1234", suggested_object_id="kitchen"
)
entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"})
hass.states.async_set("light.kitchen", "off")
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
client = await hass_client()
data: dict[str, Any] = {"text": "turn on my cool light"}
if agent_id:
data["agent_id"] = agent_id
resp = await client.post("/api/conversation/process", json=data)
assert resp.status == HTTPStatus.OK
assert len(calls) == 1
data = await resp.json()
assert data == snapshot
async def test_http_api_no_match(
hass: HomeAssistant,
init_components,
hass_client: ClientSessionGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test the HTTP conversation API with an intent match failure."""
client = await hass_client()
# Shouldn't match any intents
resp = await client.post("/api/conversation/process", json={"text": "do something"})
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == snapshot
assert data["response"]["response_type"] == "error"
assert data["response"]["data"]["code"] == "no_intent_match"
async def test_http_api_handle_failure(
hass: HomeAssistant,
init_components,
hass_client: ClientSessionGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test the HTTP conversation API with an error during handling."""
client = await hass_client()
hass.states.async_set("light.kitchen", "off")
# Raise an error during intent handling
def async_handle_error(*args, **kwargs):
raise intent.IntentHandleError
with patch("homeassistant.helpers.intent.async_handle", new=async_handle_error):
resp = await client.post(
"/api/conversation/process", json={"text": "turn on the kitchen"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == snapshot
assert data["response"]["response_type"] == "error"
assert data["response"]["data"]["code"] == "failed_to_handle"
async def test_http_api_unexpected_failure(
hass: HomeAssistant,
init_components,
hass_client: ClientSessionGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test the HTTP conversation API with an unexpected error during handling."""
client = await hass_client()
hass.states.async_set("light.kitchen", "off")
# Raise an "unexpected" error during intent handling
def async_handle_error(*args, **kwargs):
raise intent.IntentUnexpectedError
with patch("homeassistant.helpers.intent.async_handle", new=async_handle_error):
resp = await client.post(
"/api/conversation/process", json={"text": "turn on the kitchen"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == snapshot
assert data["response"]["response_type"] == "error"
assert data["response"]["data"]["code"] == "unknown"
async def test_http_api_wrong_data(
hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator
) -> None:
"""Test the HTTP conversation API."""
client = await hass_client()
resp = await client.post("/api/conversation/process", json={"text": 123})
assert resp.status == HTTPStatus.BAD_REQUEST
resp = await client.post("/api/conversation/process", json={})
assert resp.status == HTTPStatus.BAD_REQUEST
@pytest.mark.parametrize(
"payload",
[
{
"text": "Test Text",
},
{
"text": "Test Text",
"language": "test-language",
},
{
"text": "Test Text",
"conversation_id": "test-conv-id",
},
{
"text": "Test Text",
"conversation_id": None,
},
{
"text": "Test Text",
"conversation_id": "test-conv-id",
"language": "test-language",
},
{
"text": "Test Text",
"agent_id": "homeassistant",
},
],
)
async def test_ws_api(
hass: HomeAssistant,
init_components,
hass_ws_client: WebSocketGenerator,
payload,
snapshot: SnapshotAssertion,
) -> None:
"""Test the Websocket conversation API."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "conversation/process", **payload})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == snapshot
assert msg["result"]["response"]["data"]["code"] == "no_intent_match"
@pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS)
async def test_ws_prepare(
hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator, agent_id
) -> None:
"""Test the Websocket prepare conversation API."""
agent = default_agent.async_get_default_agent(hass)
assert isinstance(agent, default_agent.DefaultAgent)
# No intents should be loaded yet
assert not agent._lang_intents.get(hass.config.language)
client = await hass_ws_client(hass)
msg = {"type": "conversation/prepare"}
if agent_id is not None:
msg["agent_id"] = agent_id
await client.send_json_auto_id(msg)
msg = await client.receive_json()
assert msg["success"]
# Intents should now be load
assert agent._lang_intents.get(hass.config.language)
async def test_get_agent_list(
hass: HomeAssistant,
init_components,
mock_conversation_agent: MockAgent,
mock_agent_support_all: MockAgent,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test getting agent info."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "conversation/agent/list"})
msg = await client.receive_json()
assert msg["type"] == "result"
assert msg["success"]
assert msg["result"] == snapshot
await client.send_json_auto_id(
{"type": "conversation/agent/list", "language": "smurfish"}
)
msg = await client.receive_json()
assert msg["type"] == "result"
assert msg["success"]
assert msg["result"] == snapshot
await client.send_json_auto_id(
{"type": "conversation/agent/list", "language": "en"}
)
msg = await client.receive_json()
assert msg["type"] == "result"
assert msg["success"]
assert msg["result"] == snapshot
await client.send_json_auto_id(
{"type": "conversation/agent/list", "language": "en-UK"}
)
msg = await client.receive_json()
assert msg["type"] == "result"
assert msg["success"]
assert msg["result"] == snapshot
await client.send_json_auto_id(
{"type": "conversation/agent/list", "language": "de"}
)
msg = await client.receive_json()
assert msg["type"] == "result"
assert msg["success"]
assert msg["result"] == snapshot
await client.send_json_auto_id(
{"type": "conversation/agent/list", "language": "de", "country": "ch"}
)
msg = await client.receive_json()
assert msg["type"] == "result"
assert msg["success"]
assert msg["result"] == snapshot
async def test_ws_hass_agent_debug(
hass: HomeAssistant,
init_components,
hass_ws_client: WebSocketGenerator,
area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test homeassistant agent debug websocket command."""
client = await hass_ws_client(hass)
kitchen_area = area_registry.async_create("kitchen")
entity_registry.async_get_or_create(
"light", "demo", "1234", suggested_object_id="kitchen"
)
entity_registry.async_update_entity(
"light.kitchen",
aliases={"my cool light"},
area_id=kitchen_area.id,
)
await hass.async_block_till_done()
hass.states.async_set("light.kitchen", "off")
on_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
off_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off")
await client.send_json_auto_id(
{
"type": "conversation/agent/homeassistant/debug",
"sentences": [
"turn on my cool light",
"turn my cool light off",
"turn on all lights in the kitchen",
"how many lights are on in the kitchen?",
"this will not match anything", # None in results
],
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == snapshot
# Last sentence should be a failed match
assert msg["result"]["results"][-1] is None
# Light state should not have been changed
assert len(on_calls) == 0
assert len(off_calls) == 0
async def test_ws_hass_agent_debug_null_result(
hass: HomeAssistant,
init_components,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test homeassistant agent debug websocket command with a null result."""
client = await hass_ws_client(hass)
async def async_recognize(self, user_input, *args, **kwargs):
if user_input.text == "bad sentence":
return None
return await self.async_recognize(user_input, *args, **kwargs)
with patch(
"homeassistant.components.conversation.default_agent.DefaultAgent.async_recognize",
async_recognize,
):
await client.send_json_auto_id(
{
"type": "conversation/agent/homeassistant/debug",
"sentences": [
"bad sentence",
],
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == snapshot
assert msg["result"]["results"] == [None]
async def test_ws_hass_agent_debug_out_of_range(
hass: HomeAssistant,
init_components,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test homeassistant agent debug websocket command with an out of range entity."""
test_light = entity_registry.async_get_or_create("light", "demo", "1234")
hass.states.async_set(
test_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "test light"}
)
client = await hass_ws_client(hass)
# Brightness is in range (0-100)
await client.send_json_auto_id(
{
"type": "conversation/agent/homeassistant/debug",
"sentences": [
"set test light brightness to 100%",
],
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == snapshot
results = msg["result"]["results"]
assert len(results) == 1
assert results[0]["match"]
# Brightness is out of range
await client.send_json_auto_id(
{
"type": "conversation/agent/homeassistant/debug",
"sentences": [
"set test light brightness to 1001%",
],
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == snapshot
results = msg["result"]["results"]
assert len(results) == 1
assert not results[0]["match"]
# Name matched, but brightness didn't
assert results[0]["slots"] == {"name": "test light"}
assert results[0]["unmatched_slots"] == {"brightness": 1001}
async def test_ws_hass_agent_debug_custom_sentence(
hass: HomeAssistant,
init_components,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test homeassistant agent debug websocket command with a custom sentence."""
# Expecting testing_config/custom_sentences/en/beer.yaml
intent.async_register(hass, OrderBeerIntentHandler())
client = await hass_ws_client(hass)
# Brightness is in range (0-100)
await client.send_json_auto_id(
{
"type": "conversation/agent/homeassistant/debug",
"sentences": [
"I'd like to order a lager, please.",
],
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == snapshot
debug_results = msg["result"].get("results", [])
assert len(debug_results) == 1
assert debug_results[0].get("match")
assert debug_results[0].get("source") == "custom"
assert debug_results[0].get("file") == "en/beer.yaml"
async def test_ws_hass_agent_debug_sentence_trigger(
hass: HomeAssistant,
init_components,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test homeassistant agent debug websocket command with a sentence trigger."""
calls = async_mock_service(hass, "test", "automation")
assert await async_setup_component(
hass,
"automation",
{
"automation": {
"trigger": {
"platform": "conversation",
"command": ["hello", "hello[ world]"],
},
"action": {
"service": "test.automation",
"data_template": {"data": "{{ trigger }}"},
},
}
},
)
client = await hass_ws_client(hass)
# Use trigger sentence
await client.send_json_auto_id(
{
"type": "conversation/agent/homeassistant/debug",
"sentences": ["hello world"],
}
)
await hass.async_block_till_done()
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == snapshot
debug_results = msg["result"].get("results", [])
assert len(debug_results) == 1
assert debug_results[0].get("match")
assert debug_results[0].get("source") == "trigger"
assert debug_results[0].get("sentence_template") == "hello[ world]"
# Trigger should not have been executed
assert len(calls) == 0

File diff suppressed because it is too large Load diff