Hassil intents (#85156)

* Add hassil to requirements

* Add intent sentences

* Update sentences

* Use hassil to recognize intents in conversation

* Fix tests

* Bump hassil due to dependency conflict

* Add dataclasses-json package contraints

* Bump hassil (removes dataclasses-json dependency)

* Remove climate sentences until intents are supported

* Move I/O outside event loop

* Bump hassil to 0.2.3

* Fix light tests

* Handle areas in intents

* Clean up code according to suggestions

* Remove sentences from repo

* Use home-assistant-intents package

* Apply suggestions from code review

* Flake8

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Michael Hansen 2023-01-07 15:20:21 -06:00 committed by GitHub
parent 3a905f80df
commit ecaec0332d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 329 additions and 388 deletions

View File

@ -1,36 +1,26 @@
"""Standard conversation implementation for Home Assistant.""" """Standard conversation implementation for Home Assistant."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
import logging
import re 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 import core, setup
from homeassistant.components.cover.intent import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.helpers import area_registry, entity_registry, intent
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 .agent import AbstractConversationAgent, ConversationResult from .agent import AbstractConversationAgent, ConversationResult
from .const import DOMAIN from .const import DOMAIN
from .util import create_matcher from .util import create_matcher
REGEX_TURN_COMMAND = re.compile(r"turn (?P<name>(?: |\w)+) (?P<command>\w+)") _LOGGER = logging.getLogger(__name__)
REGEX_TYPE = type(re.compile(""))
UTTERANCES = { REGEX_TYPE = type(re.compile(""))
"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"],
},
}
@core.callback @core.callback
@ -50,12 +40,22 @@ def async_register(hass, intent_type, utterances):
conf.append(create_matcher(utterance)) 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): class DefaultAgent(AbstractConversationAgent):
"""Default agent for conversation agent.""" """Default agent for conversation agent."""
def __init__(self, hass: core.HomeAssistant) -> None: def __init__(self, hass: core.HomeAssistant) -> None:
"""Initialize the default agent.""" """Initialize the default agent."""
self.hass = hass self.hass = hass
self._lang_intents: dict[str, LanguageIntents] = {}
async def async_initialize(self, config): async def async_initialize(self, config):
"""Initialize the default agent.""" """Initialize the default agent."""
@ -63,52 +63,12 @@ class DefaultAgent(AbstractConversationAgent):
await setup.async_setup_component(self.hass, "intent", {}) await setup.async_setup_component(self.hass, "intent", {})
config = config.get(DOMAIN, {}) config = config.get(DOMAIN, {})
intents = self.hass.data.setdefault(DOMAIN, {}) self.hass.data.setdefault(DOMAIN, {})
for intent_type, utterances in config.get("intents", {}).items(): if config:
if (conf := intents.get(intent_type)) is None: _LOGGER.warning(
conf = intents[intent_type] = [] "Custom intent sentences have been moved to config/custom_sentences"
conf.extend(create_matcher(utterance) for utterance in utterances)
# 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(
self.hass,
intent.INTENT_TURN_OFF,
["Turn [the] [a] [an] {name}[s] off", "Turn off [the] [a] [an] {name}[s]"],
)
async_register(
self.hass,
intent.INTENT_TOGGLE,
["Toggle [the] [a] [an] {name}[s]", "[the] [a] [an] {name}[s] toggle"],
)
@callback
def component_loaded(event):
"""Handle a new component loaded."""
self.register_utterances(event.data[ATTR_COMPONENT])
self.hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded)
# Check already loaded components.
for component in self.hass.config.components:
self.register_utterances(component)
@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)
async def async_process( async def async_process(
self, self,
@ -118,18 +78,38 @@ class DefaultAgent(AbstractConversationAgent):
language: str | None = None, language: str | None = None,
) -> ConversationResult | None: ) -> ConversationResult | None:
"""Process a sentence.""" """Process a sentence."""
intents = self.hass.data[DOMAIN] language = language or self.hass.config.language
lang_intents = self._lang_intents.get(language)
for intent_type, matchers in intents.items(): # Reload intents if missing or new components
for matcher in matchers: if lang_intents is None or (
if not (match := matcher.match(text)): lang_intents.loaded_components - self.hass.config.components
continue ):
# 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( intent_response = await intent.async_handle(
self.hass, self.hass,
DOMAIN, DOMAIN,
intent_type, result.intent.name,
{key: {"value": value} for key, value in match.groupdict().items()}, {entity.name: {"value": entity.value} for entity in result.entities_list},
text, text,
context, context,
language, language,
@ -139,4 +119,83 @@ class DefaultAgent(AbstractConversationAgent):
response=intent_response, conversation_id=conversation_id response=intent_response, conversation_id=conversation_id
) )
def get_or_load_intents(self, language: str) -> LanguageIntents | None:
"""Load all intents for language."""
lang_intents = self._lang_intents.get(language)
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 if any new components have been loaded
intents_changed = False
for component in self.hass.config.components:
if component in loaded_components:
continue
# Don't check component again
loaded_components.add(component)
# 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 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)
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
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)

View File

@ -2,6 +2,7 @@
"domain": "conversation", "domain": "conversation",
"name": "Conversation", "name": "Conversation",
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"requirements": ["hassil==0.2.3", "home-assistant-intents==0.0.1"],
"dependencies": ["http"], "dependencies": ["http"],
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core"],
"quality_scale": "internal", "quality_scale": "internal",

View File

@ -1,12 +1,12 @@
"""Module to coordinate user intentions.""" """Module to coordinate user intentions."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Iterable import asyncio
from collections.abc import Iterable
import dataclasses import dataclasses
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
import logging import logging
import re
from typing import Any, TypeVar from typing import Any, TypeVar
import voluptuous as vol import voluptuous as vol
@ -16,7 +16,7 @@ from homeassistant.core import Context, HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.loader import bind_hass 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__) _LOGGER = logging.getLogger(__name__)
_SlotsType = dict[str, Any] _SlotsType = dict[str, Any]
@ -119,7 +119,25 @@ def async_match_state(
if states is None: if states is None:
states = hass.states.async_all() 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: if state is None:
raise IntentHandleError(f"Unable to find an entity called {name}") raise IntentHandleError(f"Unable to find an entity called {name}")
@ -127,6 +145,18 @@ def async_match_state(
return 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 @callback
def async_test_feature(state: State, feature: int, feature_name: str) -> None: def async_test_feature(state: State, feature: int, feature_name: str) -> None:
"""Test if state supports a feature.""" """Test if state supports a feature."""
@ -173,29 +203,17 @@ class IntentHandler:
return f"<{self.__class__.__name__} - {self.intent_type}>" 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): class ServiceIntentHandler(IntentHandler):
"""Service Intent handler registration. """Service Intent handler registration.
Service specific intent handler that calls a service by name/entity_id. 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__( def __init__(
self, intent_type: str, domain: str, service: str, speech: str self, intent_type: str, domain: str, service: str, speech: str
@ -210,6 +228,80 @@ class ServiceIntentHandler(IntentHandler):
"""Handle the hass intent.""" """Handle the hass intent."""
hass = intent_obj.hass hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
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"])
success_results = [
IntentResponseTarget(
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"]) state = async_match_state(hass, slots["name"]["value"])
await hass.services.async_call( await hass.services.async_call(
@ -230,6 +322,7 @@ class ServiceIntentHandler(IntentHandler):
), ),
], ],
) )
return response return response

View File

@ -867,6 +867,9 @@ hass-nabucasa==0.61.0
# homeassistant.components.splunk # homeassistant.components.splunk
hass_splunk==0.1.1 hass_splunk==0.1.1
# homeassistant.components.conversation
hassil==0.2.3
# homeassistant.components.tasmota # homeassistant.components.tasmota
hatasmota==0.6.2 hatasmota==0.6.2
@ -900,6 +903,9 @@ holidays==0.18.0
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20230104.0 home-assistant-frontend==20230104.0
# homeassistant.components.conversation
home-assistant-intents==0.0.1
# homeassistant.components.home_connect # homeassistant.components.home_connect
homeconnect==0.7.2 homeconnect==0.7.2

View File

@ -656,6 +656,9 @@ habitipy==0.2.0
# homeassistant.components.cloud # homeassistant.components.cloud
hass-nabucasa==0.61.0 hass-nabucasa==0.61.0
# homeassistant.components.conversation
hassil==0.2.3
# homeassistant.components.tasmota # homeassistant.components.tasmota
hatasmota==0.6.2 hatasmota==0.6.2
@ -680,6 +683,9 @@ holidays==0.18.0
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20230104.0 home-assistant-frontend==20230104.0
# homeassistant.components.conversation
home-assistant-intents==0.0.1
# homeassistant.components.home_connect # homeassistant.components.home_connect
homeconnect==0.7.2 homeconnect==0.7.2

View File

@ -5,11 +5,11 @@ from unittest.mock import ANY, patch
import pytest import pytest
from homeassistant.components import conversation 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.helpers import intent
from homeassistant.setup import async_setup_component 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 @pytest.fixture
@ -20,191 +20,14 @@ async def init_components(hass):
assert await async_setup_component(hass, "intent", {}) assert await async_setup_component(hass, "intent", {})
async def test_calling_intent(hass): async def test_http_processing_intent(
"""Test calling an intent from a conversation.""" hass, init_components, hass_client, hass_admin_user
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):
"""Test processing intent via HTTP API.""" """Test processing intent via HTTP API."""
hass.states.async_set("light.kitchen", "on")
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
client = await hass_client() client = await hass_client()
resp = await client.post( 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
data = await resp.json()
assert data == {
"response": {
"response_type": "action_done",
"card": {
"simple": {"content": "You chose a Grolsch.", "title": "Beer ordered"}
},
"speech": {
"plain": {
"extra_data": None,
"speech": "I've ordered a Grolsch!",
}
},
"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 assert resp.status == HTTPStatus.OK
@ -214,12 +37,19 @@ async def test_http_failed_action(hass, hass_client, hass_admin_user):
"response": { "response": {
"response_type": "action_done", "response_type": "action_done",
"card": {}, "card": {},
"speech": {}, "speech": {
"plain": {
"extra_data": None,
"speech": "Turned kitchen on",
}
},
"language": hass.config.language, "language": hass.config.language,
"data": { "data": {
"targets": [{"type": "area", "id": "kitchen", "name": "kitchen"}], "targets": [],
"success": [{"type": "entity", "id": "light.light1", "name": "light1"}], "success": [
"failed": [{"type": "entity", "id": "light.light2", "name": "light2"}], {"id": "light.kitchen", "name": "kitchen", "type": "entity"}
],
"failed": [],
}, },
}, },
"conversation_id": None, "conversation_id": None,
@ -262,24 +92,6 @@ async def test_turn_off_intent(hass, init_components, sentence):
assert call.data == {"entity_id": "light.kitchen"} 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): async def test_http_api(hass, init_components, hass_client):
"""Test the HTTP conversation API.""" """Test the HTTP conversation API."""
client = await hass_client() client = await hass_client()
@ -324,55 +136,24 @@ async def test_http_api_no_match(hass, init_components, hass_client):
"""Test the HTTP conversation API with an intent match failure.""" """Test the HTTP conversation API with an intent match failure."""
client = await hass_client() 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"}) resp = await client.post("/api/conversation/process", json={"text": "do something"})
assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK
data = await resp.json() data = await resp.json()
assert data == { assert data == {
"response": { "response": {
"response_type": "error",
"card": {}, "card": {},
"speech": { "speech": {
"plain": { "plain": {
"extra_data": None,
"speech": "Sorry, I didn't understand that", "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()
assert data == {
"response": {
"response_type": "error",
"card": {},
"speech": {
"plain": {
"extra_data": None, "extra_data": None,
"speech": "Unable to find an entity called kitchen",
}, },
}, },
"language": hass.config.language, "language": hass.config.language,
"data": { "data": {"code": "no_intent_match"},
"code": "no_valid_targets",
},
}, },
"conversation_id": None, "conversation_id": None,
} }

View File

@ -168,7 +168,7 @@ async def test_turn_on_multiple_intent(hass):
calls = async_mock_service(hass, "light", SERVICE_TURN_ON) calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
response = await intent.async_handle( 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() await hass.async_block_till_done()

View File

@ -20,7 +20,7 @@ async def test_intent_set_color(hass):
hass, hass,
"test", "test",
intent.INTENT_SET, intent.INTENT_SET,
{"name": {"value": "Hello"}, "color": {"value": "blue"}}, {"name": {"value": "Hello 2"}, "color": {"value": "blue"}},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -68,7 +68,7 @@ async def test_intent_set_color_and_brightness(hass):
"test", "test",
intent.INTENT_SET, intent.INTENT_SET,
{ {
"name": {"value": "Hello"}, "name": {"value": "Hello 2"},
"color": {"value": "blue"}, "color": {"value": "blue"},
"brightness": {"value": "20"}, "brightness": {"value": "20"},
}, },

View File

@ -3,8 +3,9 @@
import pytest import pytest
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import State 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): class MockIntentHandler(intent.IntentHandler):
@ -15,14 +16,26 @@ class MockIntentHandler(intent.IntentHandler):
self.slot_schema = slot_schema self.slot_schema = slot_schema
def test_async_match_state(): async def test_async_match_state(hass):
"""Test async_match_state helper.""" """Test async_match_state helper."""
state1 = State("light.kitchen", "on") state1 = State(
state2 = State("switch.kitchen", "on") "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 assert state is state1
state = intent.async_match_state(hass, "kill switch", [state1, state2])
assert state is state2
def test_async_validate_slots(): def test_async_validate_slots():
"""Test async_validate_slots of IntentHandler.""" """Test async_validate_slots of IntentHandler."""
@ -38,21 +51,3 @@ def test_async_validate_slots():
handler1.async_validate_slots( handler1.async_validate_slots(
{"name": {"value": "kitchen"}, "probability": {"value": "0.5"}} {"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