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."""
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,52 +63,12 @@ class DefaultAgent(AbstractConversationAgent):
await setup.async_setup_component(self.hass, "intent", {})
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 (conf := intents.get(intent_type)) is None:
conf = intents[intent_type] = []
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)
if config:
_LOGGER.warning(
"Custom intent sentences have been moved to config/custom_sentences"
)
async def async_process(
self,
@ -118,25 +78,124 @@ class DefaultAgent(AbstractConversationAgent):
language: str | None = None,
) -> ConversationResult | None:
"""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():
for matcher in matchers:
if not (match := matcher.match(text)):
# 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,
DOMAIN,
result.intent.name,
{entity.name: {"value": entity.value} for entity in result.entities_list},
text,
context,
language,
)
return ConversationResult(
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
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
intent_response = await intent.async_handle(
self.hass,
DOMAIN,
intent_type,
{key: {"value": value} for key, value in match.groupdict().items()},
text,
context,
language,
)
if entry.aliases:
for alias in entry.aliases:
names.append((alias, state.entity_id))
return ConversationResult(
response=intent_response, conversation_id=conversation_id
)
# Default name
names.append((state.name, state.entity_id))
return None
return TextSlotList.from_tuples(names)

View File

@ -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",

View File

@ -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
response = intent_obj.create_response()
response.async_set_speech(self.speech.format(state.name))
response.async_set_results(
success_results=[
# 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.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

View File

@ -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

View File

@ -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

View File

@ -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,191 +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"}
)
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"}
"/api/conversation/process", json={"text": "turn on kitchen"}
)
assert resp.status == HTTPStatus.OK
@ -214,12 +37,19 @@ async def test_http_failed_action(hass, hass_client, hass_admin_user):
"response": {
"response_type": "action_done",
"card": {},
"speech": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Turned kitchen on",
}
},
"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,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."""
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": {
"response_type": "error",
"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()
assert data == {
"response": {
"response_type": "error",
"card": {},
"speech": {
"plain": {
"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,
}

View File

@ -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()

View File

@ -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"},
},

View File

@ -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