2023-02-01 16:48:04 +00:00
|
|
|
"""Test for the default agent."""
|
2023-11-17 13:34:14 +00:00
|
|
|
from collections import defaultdict
|
2023-06-22 23:29:34 +00:00
|
|
|
from unittest.mock import AsyncMock, patch
|
2023-02-17 21:19:22 +00:00
|
|
|
|
2023-02-01 16:48:04 +00:00
|
|
|
import pytest
|
|
|
|
|
|
|
|
from homeassistant.components import conversation
|
2023-04-28 13:59:21 +00:00
|
|
|
from homeassistant.components.homeassistant.exposed_entities import (
|
|
|
|
async_get_assistant_settings,
|
|
|
|
)
|
2023-02-17 21:19:22 +00:00
|
|
|
from homeassistant.const import ATTR_FRIENDLY_NAME
|
2023-02-11 07:26:13 +00:00
|
|
|
from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant
|
2023-02-17 21:19:22 +00:00
|
|
|
from homeassistant.helpers import (
|
2023-03-01 02:56:18 +00:00
|
|
|
area_registry as ar,
|
|
|
|
device_registry as dr,
|
2023-02-17 21:19:22 +00:00
|
|
|
entity,
|
2023-03-01 02:56:18 +00:00
|
|
|
entity_registry as er,
|
2023-02-17 21:19:22 +00:00
|
|
|
intent,
|
|
|
|
)
|
2023-02-01 16:48:04 +00:00
|
|
|
from homeassistant.setup import async_setup_component
|
|
|
|
|
2023-04-12 02:39:40 +00:00
|
|
|
from . import expose_entity
|
|
|
|
|
2023-08-10 16:22:17 +00:00
|
|
|
from tests.common import MockConfigEntry, async_mock_service
|
2023-02-01 16:48:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
async def init_components(hass):
|
|
|
|
"""Initialize relevant components with empty configs."""
|
|
|
|
assert await async_setup_component(hass, "homeassistant", {})
|
|
|
|
assert await async_setup_component(hass, "conversation", {})
|
|
|
|
assert await async_setup_component(hass, "intent", {})
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
"er_kwargs",
|
|
|
|
[
|
2023-03-01 02:56:18 +00:00
|
|
|
{"hidden_by": er.RegistryEntryHider.USER},
|
|
|
|
{"hidden_by": er.RegistryEntryHider.INTEGRATION},
|
2023-02-01 16:48:04 +00:00
|
|
|
{"entity_category": entity.EntityCategory.CONFIG},
|
|
|
|
{"entity_category": entity.EntityCategory.DIAGNOSTIC},
|
|
|
|
],
|
|
|
|
)
|
2023-02-11 07:26:13 +00:00
|
|
|
async def test_hidden_entities_skipped(
|
2023-03-01 02:56:18 +00:00
|
|
|
hass: HomeAssistant, init_components, er_kwargs, entity_registry: er.EntityRegistry
|
2023-02-11 07:26:13 +00:00
|
|
|
) -> None:
|
2023-02-01 16:48:04 +00:00
|
|
|
"""Test we skip hidden entities."""
|
|
|
|
|
2023-03-01 02:56:18 +00:00
|
|
|
entity_registry.async_get_or_create(
|
2023-02-01 16:48:04 +00:00
|
|
|
"light", "demo", "1234", suggested_object_id="Test light", **er_kwargs
|
|
|
|
)
|
|
|
|
hass.states.async_set("light.test_light", "off")
|
|
|
|
calls = async_mock_service(hass, HASS_DOMAIN, "turn_on")
|
|
|
|
result = await conversation.async_converse(
|
|
|
|
hass, "turn on test light", None, Context(), None
|
|
|
|
)
|
|
|
|
|
|
|
|
assert len(calls) == 0
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ERROR
|
|
|
|
assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH
|
2023-02-17 21:19:22 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def test_exposed_domains(hass: HomeAssistant, init_components) -> None:
|
|
|
|
"""Test that we can't interact with entities that aren't exposed."""
|
|
|
|
hass.states.async_set(
|
|
|
|
"media_player.test", "off", attributes={ATTR_FRIENDLY_NAME: "Test Media Player"}
|
|
|
|
)
|
|
|
|
|
|
|
|
result = await conversation.async_converse(
|
|
|
|
hass, "turn on test media player", None, Context(), None
|
|
|
|
)
|
|
|
|
|
|
|
|
# This is an intent match failure instead of a handle failure because the
|
|
|
|
# media player domain is not exposed.
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ERROR
|
|
|
|
assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH
|
|
|
|
|
|
|
|
|
2023-03-01 02:56:18 +00:00
|
|
|
async def test_exposed_areas(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
init_components,
|
|
|
|
area_registry: ar.AreaRegistry,
|
|
|
|
device_registry: dr.DeviceRegistry,
|
|
|
|
entity_registry: er.EntityRegistry,
|
|
|
|
) -> None:
|
2023-02-17 21:19:22 +00:00
|
|
|
"""Test that only expose areas with an exposed entity/device."""
|
2023-03-01 02:56:18 +00:00
|
|
|
area_kitchen = area_registry.async_get_or_create("kitchen")
|
|
|
|
area_bedroom = area_registry.async_get_or_create("bedroom")
|
2023-02-17 21:19:22 +00:00
|
|
|
|
2023-08-10 16:22:17 +00:00
|
|
|
entry = MockConfigEntry()
|
|
|
|
entry.add_to_hass(hass)
|
2023-03-01 02:56:18 +00:00
|
|
|
kitchen_device = device_registry.async_get_or_create(
|
2023-08-10 16:22:17 +00:00
|
|
|
config_entry_id=entry.entry_id,
|
|
|
|
connections=set(),
|
|
|
|
identifiers={("demo", "id-1234")},
|
2023-02-17 21:19:22 +00:00
|
|
|
)
|
2023-03-01 02:56:18 +00:00
|
|
|
device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id)
|
2023-02-17 21:19:22 +00:00
|
|
|
|
2023-05-03 13:45:54 +00:00
|
|
|
kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234")
|
2023-03-01 02:56:18 +00:00
|
|
|
entity_registry.async_update_entity(
|
|
|
|
kitchen_light.entity_id, device_id=kitchen_device.id
|
|
|
|
)
|
2023-05-03 13:45:54 +00:00
|
|
|
hass.states.async_set(
|
|
|
|
kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"}
|
2023-04-12 02:39:40 +00:00
|
|
|
)
|
2023-05-03 13:45:54 +00:00
|
|
|
|
|
|
|
bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678")
|
2023-03-01 02:56:18 +00:00
|
|
|
entity_registry.async_update_entity(
|
|
|
|
bedroom_light.entity_id, area_id=area_bedroom.id
|
|
|
|
)
|
2023-05-03 13:45:54 +00:00
|
|
|
hass.states.async_set(
|
|
|
|
bedroom_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"}
|
|
|
|
)
|
2023-04-12 02:39:40 +00:00
|
|
|
|
|
|
|
# Hide the bedroom light
|
2023-05-03 10:39:22 +00:00
|
|
|
expose_entity(hass, bedroom_light.entity_id, False)
|
2023-04-12 02:39:40 +00:00
|
|
|
|
|
|
|
result = await conversation.async_converse(
|
|
|
|
hass, "turn on lights in the kitchen", None, Context(), None
|
2023-02-17 21:19:22 +00:00
|
|
|
)
|
|
|
|
|
2023-04-12 02:39:40 +00:00
|
|
|
# All is well for the exposed kitchen light
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
|
|
|
|
|
|
|
# Bedroom is not exposed because it has no exposed entities
|
|
|
|
result = await conversation.async_converse(
|
|
|
|
hass, "turn on lights in the bedroom", None, Context(), None
|
|
|
|
)
|
|
|
|
|
|
|
|
# This should be an intent match failure because the area isn't in the slot list
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ERROR
|
|
|
|
assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH
|
2023-04-18 20:11:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def test_conversation_agent(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
init_components,
|
|
|
|
) -> None:
|
|
|
|
"""Test DefaultAgent."""
|
|
|
|
agent = await conversation._get_agent_manager(hass).async_get_agent(
|
|
|
|
conversation.HOME_ASSISTANT_AGENT
|
|
|
|
)
|
|
|
|
with patch(
|
|
|
|
"homeassistant.components.conversation.default_agent.get_domains_and_languages",
|
|
|
|
return_value={"homeassistant": ["dwarvish", "elvish", "entish"]},
|
|
|
|
):
|
|
|
|
assert agent.supported_languages == ["dwarvish", "elvish", "entish"]
|
2023-04-28 13:59:21 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def test_expose_flag_automatically_set(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
entity_registry: er.EntityRegistry,
|
|
|
|
) -> None:
|
|
|
|
"""Test DefaultAgent sets the expose flag on all entities automatically."""
|
|
|
|
assert await async_setup_component(hass, "homeassistant", {})
|
|
|
|
|
|
|
|
light = entity_registry.async_get_or_create("light", "demo", "1234")
|
|
|
|
test = entity_registry.async_get_or_create("test", "demo", "1234")
|
|
|
|
|
|
|
|
assert async_get_assistant_settings(hass, conversation.DOMAIN) == {}
|
|
|
|
|
|
|
|
assert await async_setup_component(hass, "conversation", {})
|
|
|
|
await hass.async_block_till_done()
|
2023-05-03 13:45:54 +00:00
|
|
|
with patch("homeassistant.components.http.start_http_server_and_save_config"):
|
|
|
|
await hass.async_start()
|
2023-04-28 13:59:21 +00:00
|
|
|
|
|
|
|
# After setting up conversation, the expose flag should now be set on all entities
|
|
|
|
assert async_get_assistant_settings(hass, conversation.DOMAIN) == {
|
|
|
|
light.entity_id: {"should_expose": True},
|
|
|
|
test.entity_id: {"should_expose": False},
|
|
|
|
}
|
|
|
|
|
|
|
|
# New entities will automatically have the expose flag set
|
2023-05-03 13:45:54 +00:00
|
|
|
new_light = "light.demo_2345"
|
|
|
|
hass.states.async_set(new_light, "test")
|
2023-04-28 13:59:21 +00:00
|
|
|
await hass.async_block_till_done()
|
|
|
|
assert async_get_assistant_settings(hass, conversation.DOMAIN) == {
|
|
|
|
light.entity_id: {"should_expose": True},
|
2023-05-03 13:45:54 +00:00
|
|
|
new_light: {"should_expose": True},
|
2023-04-28 13:59:21 +00:00
|
|
|
test.entity_id: {"should_expose": False},
|
|
|
|
}
|
2023-05-03 16:18:31 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def test_unexposed_entities_skipped(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
init_components,
|
|
|
|
area_registry: ar.AreaRegistry,
|
|
|
|
entity_registry: er.EntityRegistry,
|
|
|
|
) -> None:
|
|
|
|
"""Test that unexposed entities are skipped in exposed areas."""
|
|
|
|
area_kitchen = area_registry.async_get_or_create("kitchen")
|
|
|
|
|
|
|
|
# Both lights are in the kitchen
|
|
|
|
exposed_light = entity_registry.async_get_or_create("light", "demo", "1234")
|
|
|
|
entity_registry.async_update_entity(
|
|
|
|
exposed_light.entity_id,
|
|
|
|
area_id=area_kitchen.id,
|
|
|
|
)
|
|
|
|
hass.states.async_set(exposed_light.entity_id, "off")
|
|
|
|
|
|
|
|
unexposed_light = entity_registry.async_get_or_create("light", "demo", "5678")
|
|
|
|
entity_registry.async_update_entity(
|
|
|
|
unexposed_light.entity_id,
|
|
|
|
area_id=area_kitchen.id,
|
|
|
|
)
|
|
|
|
hass.states.async_set(unexposed_light.entity_id, "off")
|
|
|
|
|
|
|
|
# On light is exposed, the other is not
|
|
|
|
expose_entity(hass, exposed_light.entity_id, True)
|
|
|
|
expose_entity(hass, unexposed_light.entity_id, False)
|
|
|
|
|
|
|
|
# Only one light should be turned on
|
|
|
|
calls = async_mock_service(hass, "light", "turn_on")
|
|
|
|
result = await conversation.async_converse(
|
|
|
|
hass, "turn on kitchen lights", None, Context(), None
|
|
|
|
)
|
|
|
|
|
|
|
|
assert len(calls) == 1
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
|
|
|
|
|
|
|
# Only one light should be returned
|
|
|
|
hass.states.async_set(exposed_light.entity_id, "on")
|
|
|
|
hass.states.async_set(unexposed_light.entity_id, "on")
|
|
|
|
result = await conversation.async_converse(
|
|
|
|
hass, "how many lights are on in the kitchen", None, Context(), None
|
|
|
|
)
|
|
|
|
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
|
|
|
assert len(result.response.matched_states) == 1
|
|
|
|
assert result.response.matched_states[0].entity_id == exposed_light.entity_id
|
2023-06-22 23:29:34 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def test_trigger_sentences(hass: HomeAssistant, init_components) -> None:
|
|
|
|
"""Test registering/unregistering/matching a few trigger sentences."""
|
|
|
|
trigger_sentences = ["It's party time", "It is time to party"]
|
|
|
|
trigger_response = "Cowabunga!"
|
|
|
|
|
|
|
|
agent = await conversation._get_agent_manager(hass).async_get_agent(
|
|
|
|
conversation.HOME_ASSISTANT_AGENT
|
|
|
|
)
|
|
|
|
assert isinstance(agent, conversation.DefaultAgent)
|
|
|
|
|
|
|
|
callback = AsyncMock(return_value=trigger_response)
|
|
|
|
unregister = agent.register_trigger(trigger_sentences, callback)
|
|
|
|
|
|
|
|
result = await conversation.async_converse(hass, "Not the trigger", None, Context())
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ERROR
|
|
|
|
|
|
|
|
# Using different case and including punctuation
|
|
|
|
test_sentences = ["it's party time!", "IT IS TIME TO PARTY."]
|
|
|
|
for sentence in test_sentences:
|
|
|
|
callback.reset_mock()
|
|
|
|
result = await conversation.async_converse(hass, sentence, None, Context())
|
2023-07-27 18:30:42 +00:00
|
|
|
assert callback.call_count == 1
|
|
|
|
assert callback.call_args[0][0] == sentence
|
2023-06-22 23:29:34 +00:00
|
|
|
assert (
|
|
|
|
result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
|
|
|
), sentence
|
|
|
|
assert result.response.speech == {
|
|
|
|
"plain": {"speech": trigger_response, "extra_data": None}
|
|
|
|
}
|
|
|
|
|
|
|
|
unregister()
|
|
|
|
|
|
|
|
# Should produce errors now
|
|
|
|
callback.reset_mock()
|
|
|
|
for sentence in test_sentences:
|
|
|
|
result = await conversation.async_converse(hass, sentence, None, Context())
|
|
|
|
assert (
|
|
|
|
result.response.response_type == intent.IntentResponseType.ERROR
|
|
|
|
), sentence
|
|
|
|
|
|
|
|
assert len(callback.mock_calls) == 0
|
2023-07-26 02:19:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def test_shopping_list_add_item(
|
|
|
|
hass: HomeAssistant, init_components, sl_setup
|
|
|
|
) -> None:
|
|
|
|
"""Test adding an item to the shopping list through the default agent."""
|
|
|
|
result = await conversation.async_converse(
|
|
|
|
hass, "add apples to my shopping list", None, Context()
|
|
|
|
)
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
|
|
|
assert result.response.speech == {
|
|
|
|
"plain": {"speech": "Added apples", "extra_data": None}
|
|
|
|
}
|
2023-10-17 00:13:26 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def test_nevermind_item(hass: HomeAssistant, init_components) -> None:
|
|
|
|
"""Test HassNevermind intent through the default agent."""
|
|
|
|
result = await conversation.async_converse(hass, "nevermind", None, Context())
|
|
|
|
assert result.response.intent is not None
|
|
|
|
assert result.response.intent.intent_type == intent.INTENT_NEVERMIND
|
|
|
|
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
|
|
|
assert not result.response.speech
|
2023-11-17 13:34:14 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def test_device_area_context(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
init_components,
|
|
|
|
area_registry: ar.AreaRegistry,
|
|
|
|
device_registry: dr.DeviceRegistry,
|
|
|
|
entity_registry: er.EntityRegistry,
|
|
|
|
) -> None:
|
|
|
|
"""Test that including a device_id will target a specific area."""
|
|
|
|
turn_on_calls = async_mock_service(hass, "light", "turn_on")
|
|
|
|
turn_off_calls = async_mock_service(hass, "light", "turn_off")
|
|
|
|
|
|
|
|
area_kitchen = area_registry.async_get_or_create("kitchen")
|
|
|
|
area_bedroom = area_registry.async_get_or_create("bedroom")
|
|
|
|
|
|
|
|
# Create 2 lights in each area
|
|
|
|
area_lights = defaultdict(list)
|
|
|
|
for area in (area_kitchen, area_bedroom):
|
|
|
|
for i in range(2):
|
|
|
|
light_entity = entity_registry.async_get_or_create(
|
|
|
|
"light", "demo", f"{area.name}-light-{i}"
|
|
|
|
)
|
|
|
|
entity_registry.async_update_entity(light_entity.entity_id, area_id=area.id)
|
|
|
|
hass.states.async_set(
|
|
|
|
light_entity.entity_id,
|
|
|
|
"off",
|
|
|
|
attributes={ATTR_FRIENDLY_NAME: f"{area.name} light {i}"},
|
|
|
|
)
|
|
|
|
area_lights[area.name].append(light_entity)
|
|
|
|
|
|
|
|
# Create voice satellites in each area
|
|
|
|
entry = MockConfigEntry()
|
|
|
|
entry.add_to_hass(hass)
|
|
|
|
|
|
|
|
kitchen_satellite = device_registry.async_get_or_create(
|
|
|
|
config_entry_id=entry.entry_id,
|
|
|
|
connections=set(),
|
|
|
|
identifiers={("demo", "id-satellite-kitchen")},
|
|
|
|
)
|
|
|
|
device_registry.async_update_device(kitchen_satellite.id, area_id=area_kitchen.id)
|
|
|
|
|
|
|
|
bedroom_satellite = device_registry.async_get_or_create(
|
|
|
|
config_entry_id=entry.entry_id,
|
|
|
|
connections=set(),
|
|
|
|
identifiers={("demo", "id-satellite-bedroom")},
|
|
|
|
)
|
|
|
|
device_registry.async_update_device(bedroom_satellite.id, area_id=area_bedroom.id)
|
|
|
|
|
2023-11-17 17:07:08 +00:00
|
|
|
# Turn on lights in the area of a device
|
2023-11-17 13:34:14 +00:00
|
|
|
result = await conversation.async_converse(
|
|
|
|
hass,
|
2023-11-17 17:07:08 +00:00
|
|
|
"turn on the lights",
|
2023-11-17 13:34:14 +00:00
|
|
|
None,
|
|
|
|
Context(),
|
|
|
|
None,
|
|
|
|
device_id=kitchen_satellite.id,
|
|
|
|
)
|
|
|
|
await hass.async_block_till_done()
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
|
|
|
|
|
|
|
# Verify only kitchen lights were targeted
|
|
|
|
assert {s.entity_id for s in result.response.matched_states} == {
|
|
|
|
e.entity_id for e in area_lights["kitchen"]
|
|
|
|
}
|
|
|
|
assert {c.data["entity_id"][0] for c in turn_on_calls} == {
|
|
|
|
e.entity_id for e in area_lights["kitchen"]
|
|
|
|
}
|
|
|
|
turn_on_calls.clear()
|
|
|
|
|
|
|
|
# Ensure we can still target other areas by name
|
|
|
|
result = await conversation.async_converse(
|
|
|
|
hass,
|
2023-11-17 17:07:08 +00:00
|
|
|
"turn on lights in the bedroom",
|
2023-11-17 13:34:14 +00:00
|
|
|
None,
|
|
|
|
Context(),
|
|
|
|
None,
|
|
|
|
device_id=kitchen_satellite.id,
|
|
|
|
)
|
|
|
|
await hass.async_block_till_done()
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
|
|
|
|
|
|
|
# Verify only bedroom lights were targeted
|
|
|
|
assert {s.entity_id for s in result.response.matched_states} == {
|
|
|
|
e.entity_id for e in area_lights["bedroom"]
|
|
|
|
}
|
|
|
|
assert {c.data["entity_id"][0] for c in turn_on_calls} == {
|
|
|
|
e.entity_id for e in area_lights["bedroom"]
|
|
|
|
}
|
|
|
|
turn_on_calls.clear()
|
|
|
|
|
|
|
|
# Turn off all lights in the area of the otherkj device
|
|
|
|
result = await conversation.async_converse(
|
|
|
|
hass,
|
2023-11-17 17:07:08 +00:00
|
|
|
"turn lights off",
|
2023-11-17 13:34:14 +00:00
|
|
|
None,
|
|
|
|
Context(),
|
|
|
|
None,
|
|
|
|
device_id=bedroom_satellite.id,
|
|
|
|
)
|
|
|
|
await hass.async_block_till_done()
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
|
|
|
|
|
|
|
# Verify only bedroom lights were targeted
|
|
|
|
assert {s.entity_id for s in result.response.matched_states} == {
|
|
|
|
e.entity_id for e in area_lights["bedroom"]
|
|
|
|
}
|
|
|
|
assert {c.data["entity_id"][0] for c in turn_off_calls} == {
|
|
|
|
e.entity_id for e in area_lights["bedroom"]
|
|
|
|
}
|
|
|
|
turn_off_calls.clear()
|
|
|
|
|
|
|
|
# Not providing a device id should not match
|
|
|
|
for command in ("on", "off"):
|
|
|
|
result = await conversation.async_converse(
|
|
|
|
hass, f"turn {command} all lights", None, Context(), None
|
|
|
|
)
|
|
|
|
assert result.response.response_type == intent.IntentResponseType.ERROR
|
|
|
|
assert (
|
|
|
|
result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH
|
|
|
|
)
|