diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 2e8e78d6f38a641fcb4f5f3694489108a6b3a1a6..aba5aafd3782f0dc5a0e966d1ab6498b26eaf54f 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -1,36 +1,26 @@ """Standard conversation implementation for Home Assistant.""" from __future__ import annotations +from dataclasses import dataclass +import logging import re +from typing import Any + +from hassil.intents import Intents, SlotList, TextSlotList +from hassil.recognize import recognize +from hassil.util import merge_dict +from home_assistant_intents import get_intents from homeassistant import core, setup -from homeassistant.components.cover.intent import INTENT_CLOSE_COVER, INTENT_OPEN_COVER -from homeassistant.components.shopping_list.intent import ( - INTENT_ADD_ITEM, - INTENT_LAST_ITEMS, -) -from homeassistant.const import EVENT_COMPONENT_LOADED -from homeassistant.core import callback -from homeassistant.helpers import intent -from homeassistant.setup import ATTR_COMPONENT +from homeassistant.helpers import area_registry, entity_registry, intent from .agent import AbstractConversationAgent, ConversationResult from .const import DOMAIN from .util import create_matcher -REGEX_TURN_COMMAND = re.compile(r"turn (?P<name>(?: |\w)+) (?P<command>\w+)") -REGEX_TYPE = type(re.compile("")) +_LOGGER = logging.getLogger(__name__) -UTTERANCES = { - "cover": { - INTENT_OPEN_COVER: ["Open [the] [a] [an] {name}[s]"], - INTENT_CLOSE_COVER: ["Close [the] [a] [an] {name}[s]"], - }, - "shopping_list": { - INTENT_ADD_ITEM: ["Add [the] [a] [an] {item} to my shopping list"], - INTENT_LAST_ITEMS: ["What is on my shopping list"], - }, -} +REGEX_TYPE = type(re.compile("")) @core.callback @@ -50,12 +40,22 @@ def async_register(hass, intent_type, utterances): conf.append(create_matcher(utterance)) +@dataclass +class LanguageIntents: + """Loaded intents for a language.""" + + intents: Intents + intents_dict: dict[str, Any] + loaded_components: set[str] + + class DefaultAgent(AbstractConversationAgent): """Default agent for conversation agent.""" def __init__(self, hass: core.HomeAssistant) -> None: """Initialize the default agent.""" self.hass = hass + self._lang_intents: dict[str, LanguageIntents] = {} async def async_initialize(self, config): """Initialize the default agent.""" @@ -63,80 +63,139 @@ class DefaultAgent(AbstractConversationAgent): await setup.async_setup_component(self.hass, "intent", {}) config = config.get(DOMAIN, {}) - intents = self.hass.data.setdefault(DOMAIN, {}) - - for intent_type, utterances in config.get("intents", {}).items(): - if (conf := intents.get(intent_type)) is None: - conf = intents[intent_type] = [] + self.hass.data.setdefault(DOMAIN, {}) - conf.extend(create_matcher(utterance) for utterance in utterances) + if config: + _LOGGER.warning( + "Custom intent sentences have been moved to config/custom_sentences" + ) - # We strip trailing 's' from name because our state matcher will fail - # if a letter is not there. By removing 's' we can match singular and - # plural names. - - async_register( - self.hass, - intent.INTENT_TURN_ON, - ["Turn [the] [a] {name}[s] on", "Turn on [the] [a] [an] {name}[s]"], - ) - async_register( + async def async_process( + self, + text: str, + context: core.Context, + conversation_id: str | None = None, + language: str | None = None, + ) -> ConversationResult | None: + """Process a sentence.""" + language = 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 or ( + lang_intents.loaded_components - self.hass.config.components + ): + # Load intents in executor + lang_intents = await self.hass.async_add_executor_job( + self.get_or_load_intents, + language, + ) + + if lang_intents is None: + # No intents loaded + _LOGGER.warning("No intents were loaded for language: %s", language) + return None + + slot_lists: dict[str, SlotList] = { + "area": self._make_areas_list(), + "name": self._make_names_list(), + } + + result = recognize(text, lang_intents.intents, slot_lists=slot_lists) + if result is None: + return None + + intent_response = await intent.async_handle( self.hass, - intent.INTENT_TURN_OFF, - ["Turn [the] [a] [an] {name}[s] off", "Turn off [the] [a] [an] {name}[s]"], + DOMAIN, + result.intent.name, + {entity.name: {"value": entity.value} for entity in result.entities_list}, + text, + context, + language, ) - async_register( - self.hass, - intent.INTENT_TOGGLE, - ["Toggle [the] [a] [an] {name}[s]", "[the] [a] [an] {name}[s] toggle"], + + return ConversationResult( + response=intent_response, conversation_id=conversation_id ) - @callback - def component_loaded(event): - """Handle a new component loaded.""" - self.register_utterances(event.data[ATTR_COMPONENT]) + def get_or_load_intents(self, language: str) -> LanguageIntents | None: + """Load all intents for language.""" + lang_intents = self._lang_intents.get(language) - self.hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) + if lang_intents is None: + intents_dict: dict[str, Any] = {} + loaded_components: set[str] = set() + else: + intents_dict = lang_intents.intents_dict + loaded_components = lang_intents.loaded_components - # Check already loaded components. + # Check if any new components have been loaded + intents_changed = False for component in self.hass.config.components: - self.register_utterances(component) + if component in loaded_components: + continue - @callback - def register_utterances(self, component): - """Register utterances for a component.""" - if component not in UTTERANCES: - return - for intent_type, sentences in UTTERANCES[component].items(): - async_register(self.hass, intent_type, sentences) + # Don't check component again + loaded_components.add(component) - async def async_process( - self, - text: str, - context: core.Context, - conversation_id: str | None = None, - language: str | None = None, - ) -> ConversationResult | None: - """Process a sentence.""" - intents = self.hass.data[DOMAIN] + # Check for intents for this component with the target language + component_intents = get_intents(component, language) + if component_intents: + # Merge sentences into existing dictionary + merge_dict(intents_dict, component_intents) + + # Will need to recreate graph + intents_changed = True + + if not intents_dict: + return None + + if not intents_changed and lang_intents is not None: + return lang_intents - for intent_type, matchers in intents.items(): - for matcher in matchers: - if not (match := matcher.match(text)): + # 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) + + if lang_intents is None: + lang_intents = LanguageIntents(intents, intents_dict, loaded_components) + self._lang_intents[language] = lang_intents + else: + lang_intents.intents = intents + + return lang_intents + + def _make_areas_list(self) -> TextSlotList: + """Create slot list mapping area names/aliases to area ids.""" + registry = area_registry.async_get(self.hass) + areas = [] + for entry in registry.async_list_areas(): + areas.append((entry.name, entry.id)) + if entry.aliases: + for alias in entry.aliases: + areas.append((alias, entry.id)) + + return TextSlotList.from_tuples(areas) + + def _make_names_list(self) -> TextSlotList: + """Create slot list mapping entity names/aliases to entity ids.""" + states = self.hass.states.async_all() + registry = entity_registry.async_get(self.hass) + names = [] + for state in states: + entry = registry.async_get(state.entity_id) + if entry is not None: + if entry.entity_category: + # Skip configuration/diagnostic entities continue - intent_response = await intent.async_handle( - self.hass, - DOMAIN, - intent_type, - {key: {"value": value} for key, value in match.groupdict().items()}, - text, - context, - language, - ) - - return ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - return None + if entry.aliases: + for alias in entry.aliases: + names.append((alias, state.entity_id)) + + # Default name + names.append((state.name, state.entity_id)) + + return TextSlotList.from_tuples(names) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 54265bfcb833966dc624c1693e2915a113abf0d7..b83dfe431d595ba39a0ba8c42ce6334bbab6bf0f 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -2,6 +2,7 @@ "domain": "conversation", "name": "Conversation", "documentation": "https://www.home-assistant.io/integrations/conversation", + "requirements": ["hassil==0.2.3", "home-assistant-intents==0.0.1"], "dependencies": ["http"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 100d64c8fb64539bd157e28724e278ac1674f435..ba6461e1d604330be9e9bcc39dbbc52b5b307913 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,12 +1,12 @@ """Module to coordinate user intentions.""" from __future__ import annotations -from collections.abc import Callable, Iterable +import asyncio +from collections.abc import Iterable import dataclasses from dataclasses import dataclass from enum import Enum import logging -import re from typing import Any, TypeVar import voluptuous as vol @@ -16,7 +16,7 @@ from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from . import config_validation as cv +from . import area_registry, config_validation as cv, entity_registry _LOGGER = logging.getLogger(__name__) _SlotsType = dict[str, Any] @@ -119,7 +119,25 @@ def async_match_state( if states is None: states = hass.states.async_all() - state = _fuzzymatch(name, states, lambda state: state.name) + name = name.casefold() + state: State | None = None + registry = entity_registry.async_get(hass) + + for maybe_state in states: + # Check entity id and name + if name in (maybe_state.entity_id, maybe_state.name.casefold()): + state = maybe_state + else: + # Check aliases + entry = registry.async_get(maybe_state.entity_id) + if (entry is not None) and entry.aliases: + for alias in entry.aliases: + if name == alias.casefold(): + state = maybe_state + break + + if state is not None: + break if state is None: raise IntentHandleError(f"Unable to find an entity called {name}") @@ -127,6 +145,18 @@ def async_match_state( return state +@callback +@bind_hass +def async_match_area( + hass: HomeAssistant, area_name: str +) -> area_registry.AreaEntry | None: + """Find an area that matches the name.""" + registry = area_registry.async_get(hass) + return registry.async_get_area(area_name) or registry.async_get_area_by_name( + area_name + ) + + @callback def async_test_feature(state: State, feature: int, feature_name: str) -> None: """Test if state supports a feature.""" @@ -173,29 +203,17 @@ class IntentHandler: return f"<{self.__class__.__name__} - {self.intent_type}>" -def _fuzzymatch(name: str, items: Iterable[_T], key: Callable[[_T], str]) -> _T | None: - """Fuzzy matching function.""" - matches = [] - pattern = ".*?".join(name) - regex = re.compile(pattern, re.IGNORECASE) - for idx, item in enumerate(items): - if match := regex.search(key(item)): - # Add key length so we prefer shorter keys with the same group and start. - # Add index so we pick first match in case same group, start, and key length. - matches.append( - (len(match.group()), match.start(), len(key(item)), idx, item) - ) - - return sorted(matches)[0][4] if matches else None - - class ServiceIntentHandler(IntentHandler): """Service Intent handler registration. Service specific intent handler that calls a service by name/entity_id. """ - slot_schema = {vol.Required("name"): cv.string} + slot_schema = { + vol.Any("name", "area"): cv.string, + vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]), + } def __init__( self, intent_type: str, domain: str, service: str, speech: str @@ -210,26 +228,101 @@ class ServiceIntentHandler(IntentHandler): """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - state = async_match_state(hass, slots["name"]["value"]) - await hass.services.async_call( - self.domain, - self.service, - {ATTR_ENTITY_ID: state.entity_id}, - context=intent_obj.context, - ) + if "area" in slots: + # Entities in an area + area_name = slots["area"]["value"] + area = async_match_area(hass, area_name) + assert area is not None + assert area.id is not None + + # Optional domain filter + domains: set[str] | None = None + if "domain" in slots: + domains = set(slots["domain"]["value"]) + + # Optional device class filter + device_classes: set[str] | None = None + if "device_class" in slots: + device_classes = set(slots["device_class"]["value"]) - response = intent_obj.create_response() - response.async_set_speech(self.speech.format(state.name)) - response.async_set_results( - success_results=[ + success_results = [ IntentResponseTarget( - type=IntentResponseTargetType.ENTITY, - name=state.name, - id=state.entity_id, - ), - ], - ) + type=IntentResponseTargetType.AREA, name=area.name, id=area.id + ) + ] + service_coros = [] + registry = entity_registry.async_get(hass) + for entity_entry in entity_registry.async_entries_for_area( + registry, area.id + ): + if entity_entry.entity_category: + # Skip diagnostic entities + continue + + if domains and (entity_entry.domain not in domains): + # Skip entity not in the domain + continue + + if device_classes and (entity_entry.device_class not in device_classes): + # Skip entity with wrong device class + continue + + service_coros.append( + hass.services.async_call( + self.domain, + self.service, + {ATTR_ENTITY_ID: entity_entry.entity_id}, + context=intent_obj.context, + ) + ) + + state = hass.states.get(entity_entry.entity_id) + assert state is not None + + success_results.append( + IntentResponseTarget( + type=IntentResponseTargetType.ENTITY, + name=state.name, + id=entity_entry.entity_id, + ), + ) + + if not service_coros: + raise IntentHandleError("No entities matched") + + # Handle service calls in parallel. + # We will need to handle partial failures here. + await asyncio.gather(*service_coros) + + response = intent_obj.create_response() + response.async_set_speech(self.speech.format(area.name)) + response.async_set_results( + success_results=success_results, + ) + else: + # Single entity + state = async_match_state(hass, slots["name"]["value"]) + + await hass.services.async_call( + self.domain, + self.service, + {ATTR_ENTITY_ID: state.entity_id}, + context=intent_obj.context, + ) + + response = intent_obj.create_response() + response.async_set_speech(self.speech.format(state.name)) + response.async_set_results( + success_results=[ + IntentResponseTarget( + type=IntentResponseTargetType.ENTITY, + name=state.name, + id=state.entity_id, + ), + ], + ) + return response diff --git a/requirements_all.txt b/requirements_all.txt index f19a630aa18b8ea552a0da91d5bb9a2bb604e9bf..db802fdd5175e0b62de7d98f4d918566ca77c977 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -867,6 +867,9 @@ hass-nabucasa==0.61.0 # homeassistant.components.splunk hass_splunk==0.1.1 +# homeassistant.components.conversation +hassil==0.2.3 + # homeassistant.components.tasmota hatasmota==0.6.2 @@ -900,6 +903,9 @@ holidays==0.18.0 # homeassistant.components.frontend home-assistant-frontend==20230104.0 +# homeassistant.components.conversation +home-assistant-intents==0.0.1 + # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 52ac74d670cbe17cbc01138640939635016bf886..ab0b26afb9df5849be6c90c0cc14f3aaf53f6ecf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -656,6 +656,9 @@ habitipy==0.2.0 # homeassistant.components.cloud hass-nabucasa==0.61.0 +# homeassistant.components.conversation +hassil==0.2.3 + # homeassistant.components.tasmota hatasmota==0.6.2 @@ -680,6 +683,9 @@ holidays==0.18.0 # homeassistant.components.frontend home-assistant-frontend==20230104.0 +# homeassistant.components.conversation +home-assistant-intents==0.0.1 + # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index c25df45ce783f81d78bdb3109ab29fb829636609..ffb7894cd00acfee5b6d9aa4a79e3ba8e3a2d032 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -5,11 +5,11 @@ from unittest.mock import ANY, patch import pytest from homeassistant.components import conversation -from homeassistant.core import DOMAIN as HASS_DOMAIN, Context +from homeassistant.core import DOMAIN as HASS_DOMAIN from homeassistant.helpers import intent from homeassistant.setup import async_setup_component -from tests.common import async_mock_intent, async_mock_service +from tests.common import async_mock_service @pytest.fixture @@ -20,111 +20,14 @@ async def init_components(hass): assert await async_setup_component(hass, "intent", {}) -async def test_calling_intent(hass): - """Test calling an intent from a conversation.""" - intents = async_mock_intent(hass, "OrderBeer") - - result = await async_setup_component(hass, "homeassistant", {}) - assert result - - result = await async_setup_component( - hass, - "conversation", - {"conversation": {"intents": {"OrderBeer": ["I would like the {type} beer"]}}}, - ) - assert result - - context = Context() - - await hass.services.async_call( - "conversation", - "process", - {conversation.ATTR_TEXT: "I would like the Grolsch beer"}, - context=context, - ) - await hass.async_block_till_done() - - assert len(intents) == 1 - intent = intents[0] - assert intent.platform == "conversation" - assert intent.intent_type == "OrderBeer" - assert intent.slots == {"type": {"value": "Grolsch"}} - assert intent.text_input == "I would like the Grolsch beer" - assert intent.context is context - - -async def test_register_before_setup(hass): - """Test calling an intent from a conversation.""" - intents = async_mock_intent(hass, "OrderBeer") - - hass.components.conversation.async_register("OrderBeer", ["A {type} beer, please"]) - - result = await async_setup_component( - hass, - "conversation", - {"conversation": {"intents": {"OrderBeer": ["I would like the {type} beer"]}}}, - ) - assert result - - await hass.services.async_call( - "conversation", "process", {conversation.ATTR_TEXT: "A Grolsch beer, please"} - ) - await hass.async_block_till_done() - - assert len(intents) == 1 - intent = intents[0] - assert intent.platform == "conversation" - assert intent.intent_type == "OrderBeer" - assert intent.slots == {"type": {"value": "Grolsch"}} - assert intent.text_input == "A Grolsch beer, please" - - await hass.services.async_call( - "conversation", - "process", - {conversation.ATTR_TEXT: "I would like the Grolsch beer"}, - ) - await hass.async_block_till_done() - - assert len(intents) == 2 - intent = intents[1] - assert intent.platform == "conversation" - assert intent.intent_type == "OrderBeer" - assert intent.slots == {"type": {"value": "Grolsch"}} - assert intent.text_input == "I would like the Grolsch beer" - - -async def test_http_processing_intent(hass, hass_client, hass_admin_user): +async def test_http_processing_intent( + hass, init_components, hass_client, hass_admin_user +): """Test processing intent via HTTP API.""" - - class TestIntentHandler(intent.IntentHandler): - """Test Intent Handler.""" - - intent_type = "OrderBeer" - - async def async_handle(self, intent): - """Handle the intent.""" - assert intent.context.user_id == hass_admin_user.id - response = intent.create_response() - response.async_set_speech( - "I've ordered a {}!".format(intent.slots["type"]["value"]) - ) - response.async_set_card( - "Beer ordered", "You chose a {}.".format(intent.slots["type"]["value"]) - ) - return response - - intent.async_register(hass, TestIntentHandler()) - - result = await async_setup_component( - hass, - "conversation", - {"conversation": {"intents": {"OrderBeer": ["I would like the {type} beer"]}}}, - ) - assert result - + hass.states.async_set("light.kitchen", "on") client = await hass_client() resp = await client.post( - "/api/conversation/process", json={"text": "I would like the Grolsch beer"} + "/api/conversation/process", json={"text": "turn on kitchen"} ) assert resp.status == HTTPStatus.OK @@ -133,93 +36,20 @@ async def test_http_processing_intent(hass, hass_client, hass_admin_user): assert data == { "response": { "response_type": "action_done", - "card": { - "simple": {"content": "You chose a Grolsch.", "title": "Beer ordered"} - }, + "card": {}, "speech": { "plain": { "extra_data": None, - "speech": "I've ordered a Grolsch!", + "speech": "Turned kitchen on", } }, "language": hass.config.language, - "data": {"targets": [], "success": [], "failed": []}, - }, - "conversation_id": None, - } - - -async def test_http_failed_action(hass, hass_client, hass_admin_user): - """Test processing intent via HTTP API with a partial completion.""" - - class TestIntentHandler(intent.IntentHandler): - """Test Intent Handler.""" - - intent_type = "TurnOffLights" - - async def async_handle(self, handle_intent: intent.Intent): - """Handle the intent.""" - response = handle_intent.create_response() - area = handle_intent.slots["area"]["value"] - - # Mark some targets as successful, others as failed - response.async_set_targets( - intent_targets=[ - intent.IntentResponseTarget( - type=intent.IntentResponseTargetType.AREA, name=area, id=area - ) - ] - ) - response.async_set_results( - success_results=[ - intent.IntentResponseTarget( - type=intent.IntentResponseTargetType.ENTITY, - name="light1", - id="light.light1", - ) - ], - failed_results=[ - intent.IntentResponseTarget( - type=intent.IntentResponseTargetType.ENTITY, - name="light2", - id="light.light2", - ) - ], - ) - - return response - - intent.async_register(hass, TestIntentHandler()) - - result = await async_setup_component( - hass, - "conversation", - { - "conversation": { - "intents": {"TurnOffLights": ["turn off the lights in the {area}"]} - } - }, - ) - assert result - - client = await hass_client() - resp = await client.post( - "/api/conversation/process", json={"text": "Turn off the lights in the kitchen"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": {}, - "language": hass.config.language, "data": { - "targets": [{"type": "area", "id": "kitchen", "name": "kitchen"}], - "success": [{"type": "entity", "id": "light.light1", "name": "light1"}], - "failed": [{"type": "entity", "id": "light.light2", "name": "light2"}], + "targets": [], + "success": [ + {"id": "light.kitchen", "name": "kitchen", "type": "entity"} + ], + "failed": [], }, }, "conversation_id": None, @@ -262,24 +92,6 @@ async def test_turn_off_intent(hass, init_components, sentence): assert call.data == {"entity_id": "light.kitchen"} -@pytest.mark.parametrize("sentence", ("toggle kitchen", "kitchen toggle")) -async def test_toggle_intent(hass, init_components, sentence): - """Test calling the turn on intent.""" - hass.states.async_set("light.kitchen", "on") - calls = async_mock_service(hass, HASS_DOMAIN, "toggle") - - await hass.services.async_call( - "conversation", "process", {conversation.ATTR_TEXT: sentence} - ) - await hass.async_block_till_done() - - assert len(calls) == 1 - call = calls[0] - assert call.domain == HASS_DOMAIN - assert call.service == "toggle" - assert call.data == {"entity_id": "light.kitchen"} - - async def test_http_api(hass, init_components, hass_client): """Test the HTTP conversation API.""" client = await hass_client() @@ -324,38 +136,9 @@ async def test_http_api_no_match(hass, init_components, hass_client): """Test the HTTP conversation API with an intent match failure.""" client = await hass_client() - # Sentence should not match any intents + # 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 == { - "response": { - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I didn't understand that", - }, - }, - "language": hass.config.language, - "response_type": "error", - "data": { - "code": "no_intent_match", - }, - }, - "conversation_id": None, - } - - -async def test_http_api_no_valid_targets(hass, init_components, hass_client): - """Test the HTTP conversation API with no valid targets.""" - client = await hass_client() - - # No kitchen light - resp = await client.post( - "/api/conversation/process", json={"text": "turn on the kitchen"} - ) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -365,14 +148,12 @@ async def test_http_api_no_valid_targets(hass, init_components, hass_client): "card": {}, "speech": { "plain": { + "speech": "Sorry, I didn't understand that", "extra_data": None, - "speech": "Unable to find an entity called kitchen", }, }, "language": hass.config.language, - "data": { - "code": "no_valid_targets", - }, + "data": {"code": "no_intent_match"}, }, "conversation_id": None, } diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index a936c11d0fafcb63564ff9e50e8d0ebe5e53a16e..40bf79e1c45657c7bc3888abbe38d0390734e9e3 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -168,7 +168,7 @@ async def test_turn_on_multiple_intent(hass): calls = async_mock_service(hass, "light", SERVICE_TURN_ON) response = await intent.async_handle( - hass, "test", "HassTurnOn", {"name": {"value": "test lights"}} + hass, "test", "HassTurnOn", {"name": {"value": "test lights 2"}} ) await hass.async_block_till_done() diff --git a/tests/components/light/test_intent.py b/tests/components/light/test_intent.py index 0c837a49c42d94b29b0893fd3c21dc789f34e0ef..458e27bc6c6976d26db2702bc3cfd3bb0cdee09f 100644 --- a/tests/components/light/test_intent.py +++ b/tests/components/light/test_intent.py @@ -20,7 +20,7 @@ async def test_intent_set_color(hass): hass, "test", intent.INTENT_SET, - {"name": {"value": "Hello"}, "color": {"value": "blue"}}, + {"name": {"value": "Hello 2"}, "color": {"value": "blue"}}, ) await hass.async_block_till_done() @@ -68,7 +68,7 @@ async def test_intent_set_color_and_brightness(hass): "test", intent.INTENT_SET, { - "name": {"value": "Hello"}, + "name": {"value": "Hello 2"}, "color": {"value": "blue"}, "brightness": {"value": "20"}, }, diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index e328d30ab7ad0f29f687735bc6268402ebd280b5..1d7aaeba3667b0754e7e6e6bafafbba520c1868b 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -3,8 +3,9 @@ import pytest import voluptuous as vol +from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import State -from homeassistant.helpers import config_validation as cv, intent +from homeassistant.helpers import config_validation as cv, entity_registry, intent class MockIntentHandler(intent.IntentHandler): @@ -15,14 +16,26 @@ class MockIntentHandler(intent.IntentHandler): self.slot_schema = slot_schema -def test_async_match_state(): +async def test_async_match_state(hass): """Test async_match_state helper.""" - state1 = State("light.kitchen", "on") - state2 = State("switch.kitchen", "on") + state1 = State( + "light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} + ) + state2 = State( + "switch.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen switch"} + ) + registry = entity_registry.async_get(hass) + registry.async_get_or_create( + "switch", "demo", "1234", suggested_object_id="kitchen" + ) + registry.async_update_entity(state2.entity_id, aliases={"kill switch"}) - state = intent.async_match_state(None, "kitch", [state1, state2]) + state = intent.async_match_state(hass, "kitchen light", [state1, state2]) assert state is state1 + state = intent.async_match_state(hass, "kill switch", [state1, state2]) + assert state is state2 + def test_async_validate_slots(): """Test async_validate_slots of IntentHandler.""" @@ -38,21 +51,3 @@ def test_async_validate_slots(): handler1.async_validate_slots( {"name": {"value": "kitchen"}, "probability": {"value": "0.5"}} ) - - -def test_fuzzy_match(): - """Test _fuzzymatch.""" - state1 = State("light.living_room_northwest", "off") - state2 = State("light.living_room_north", "off") - state3 = State("light.living_room_northeast", "off") - state4 = State("light.living_room_west", "off") - state5 = State("light.living_room", "off") - states = [state1, state2, state3, state4, state5] - - state = intent._fuzzymatch("Living Room", states, lambda state: state.name) - assert state == state5 - - state = intent._fuzzymatch( - "Living Room Northwest", states, lambda state: state.name - ) - assert state == state1