Allow UI configuration of entities exposed to voice_assistant (#91233)

* Allow UI configuration of entities exposed to voice_assistant

* Invalidate cache when settings change

* Add tests

* Expose entities to conversation by default

* Update tests
This commit is contained in:
Erik Montnemery 2023-04-12 04:39:40 +02:00 committed by GitHub
parent e40a373c4b
commit 2c9e9d0fde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 473 additions and 107 deletions

View file

@ -1,21 +1,3 @@
"""Const for conversation integration."""
DOMAIN = "conversation"
DEFAULT_EXPOSED_DOMAINS = {
"binary_sensor",
"climate",
"cover",
"fan",
"humidifier",
"light",
"lock",
"scene",
"script",
"sensor",
"switch",
"vacuum",
"water_heater",
}
DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}

View file

@ -17,6 +17,11 @@ from home_assistant_intents import get_intents
import yaml
from homeassistant import core, setup
from homeassistant.components.homeassistant.exposed_entities import (
async_listen_entity_updates,
async_should_expose,
)
from homeassistant.const import ATTR_DEVICE_CLASS
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
@ -28,7 +33,7 @@ from homeassistant.helpers import (
from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
from .const import DEFAULT_EXPOSED_ATTRIBUTES, DEFAULT_EXPOSED_DOMAINS, DOMAIN
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
@ -37,11 +42,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
REGEX_TYPE = type(re.compile(""))
def is_entity_exposed(state: core.State) -> bool:
"""Return true if entity belongs to exposed domain list."""
return state.domain in DEFAULT_EXPOSED_DOMAINS
def json_load(fp: IO[str]) -> JsonObjectType:
"""Wrap json_loads for get_intents."""
return json_loads_object(fp.read())
@ -105,10 +105,8 @@ class DefaultAgent(AbstractConversationAgent):
self._async_handle_entity_registry_changed,
run_immediately=True,
)
self.hass.bus.async_listen(
core.EVENT_STATE_CHANGED,
self._async_handle_state_changed,
run_immediately=True,
async_listen_entity_updates(
self.hass, DOMAIN, self._async_exposed_entities_updated
)
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
@ -459,10 +457,8 @@ class DefaultAgent(AbstractConversationAgent):
self._slot_lists = None
@core.callback
def _async_handle_state_changed(self, event: core.Event) -> None:
"""Clear names list cache when a state is added or removed from the state machine."""
if event.data.get("old_state") and event.data.get("new_state"):
return
def _async_exposed_entities_updated(self) -> None:
"""Handle updated preferences."""
self._slot_lists = None
def _make_slot_lists(self) -> dict[str, SlotList]:
@ -471,36 +467,31 @@ class DefaultAgent(AbstractConversationAgent):
return self._slot_lists
area_ids_with_entities: set[str] = set()
states = [
state for state in self.hass.states.async_all() if is_entity_exposed(state)
all_entities = er.async_get(self.hass)
entities = [
entity
for entity in all_entities.entities.values()
if async_should_expose(self.hass, DOMAIN, entity.entity_id)
]
entities = er.async_get(self.hass)
devices = dr.async_get(self.hass)
# Gather exposed entity names
entity_names = []
for state in states:
for entity in entities:
# Checked against "requires_context" and "excludes_context" in hassil
context = {"domain": state.domain}
if state.attributes:
# Include some attributes
for attr_key, attr_value in state.attributes.items():
if attr_key not in DEFAULT_EXPOSED_ATTRIBUTES:
continue
context[attr_key] = attr_value
entity = entities.async_get(state.entity_id)
if entity is not None:
if entity.entity_category or entity.hidden:
# Skip configuration/diagnostic/hidden entities
continue
context = {"domain": entity.domain}
if entity.device_class:
context[ATTR_DEVICE_CLASS] = entity.device_class
if entity.aliases:
for alias in entity.aliases:
entity_names.append((alias, alias, context))
# Default name
entity_names.append((state.name, state.name, context))
name = entity.async_friendly_name(self.hass) or entity.entity_id.replace(
"_", " "
)
entity_names.append((name, name, context))
if entity.area_id:
# Expose area too
@ -510,9 +501,6 @@ class DefaultAgent(AbstractConversationAgent):
device = devices.async_get(entity.device_id)
if (device is not None) and device.area_id:
area_ids_with_entities.add(device.area_id)
else:
# Default name
entity_names.append((state.name, state.name, context))
# Gather areas from exposed entities
areas = ar.async_get(self.hass)

View file

@ -2,7 +2,7 @@
"domain": "conversation",
"name": "Conversation",
"codeowners": ["@home-assistant/core", "@synesthesiam"],
"dependencies": ["http"],
"dependencies": ["homeassistant", "http"],
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"iot_class": "local_push",

View file

@ -19,7 +19,7 @@ from homeassistant.helpers.storage import Store
from .const import DATA_EXPOSED_ENTITIES, DOMAIN
KNOWN_ASSISTANTS = ("cloud.alexa", "cloud.google_assistant")
KNOWN_ASSISTANTS = ("cloud.alexa", "cloud.google_assistant", "conversation")
STORAGE_KEY = f"{DOMAIN}.exposed_entities"
STORAGE_VERSION = 1
@ -61,6 +61,10 @@ DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = {
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
}
DEFAULT_EXPOSED_ASSISTANT = {
"conversation": True,
}
@dataclasses.dataclass(frozen=True)
class AssistantPreferences:
@ -130,7 +134,7 @@ class ExposedEntities:
"""Check if new entities are exposed to an assistant."""
if prefs := self._assistants.get(assistant):
return prefs.expose_new
return False
return DEFAULT_EXPOSED_ASSISTANT.get(assistant, False)
@callback
def async_set_expose_new_entities(self, assistant: str, expose_new: bool) -> None:
@ -170,7 +174,7 @@ class ExposedEntities:
should_expose = registry_entry.options[assistant]["should_expose"]
return should_expose
if (prefs := self._assistants.get(assistant)) and prefs.expose_new:
if self.async_get_expose_new_entities(assistant):
should_expose = self._is_default_exposed(entity_id, registry_entry)
else:
should_expose = False

View file

@ -309,6 +309,26 @@ class RegistryEntry:
hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs)
def async_friendly_name(self, hass: HomeAssistant) -> str | None:
"""Return the friendly name.
If self.name is not None, this returns self.name
If has_entity_name is False, self.original_name
If has_entity_name is True, this returns device.name + self.original_name
"""
if not self.has_entity_name or self.name is not None:
return self.name or self.original_name
device_registry = dr.async_get(hass)
if not (device_id := self.device_id) or not (
device_entry := device_registry.async_get(device_id)
):
return self.original_name
if not (original_name := self.original_name):
return device_entry.name_by_user or device_entry.name
return f"{device_entry.name_by_user or device_entry.name} {original_name}"
class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
"""Store entity registry data."""

View file

@ -2,6 +2,10 @@
from __future__ import annotations
from homeassistant.components import conversation
from homeassistant.components.homeassistant.exposed_entities import (
DATA_EXPOSED_ENTITIES,
ExposedEntities,
)
from homeassistant.helpers import intent
@ -24,3 +28,15 @@ class MockAgent(conversation.AbstractConversationAgent):
return conversation.ConversationResult(
response=response, conversation_id=user_input.conversation_id
)
def expose_new(hass, expose_new):
"""Enable exposing new entities to the default agent."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_set_expose_new_entities(conversation.DOMAIN, expose_new)
def expose_entity(hass, entity_id, should_expose):
"""Expose an entity to the default agent."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_expose_entity(conversation.DOMAIN, entity_id, should_expose)

View file

@ -1,5 +1,4 @@
"""Test for the default agent."""
from unittest.mock import patch
import pytest
@ -15,6 +14,8 @@ from homeassistant.helpers import (
)
from homeassistant.setup import async_setup_component
from . import expose_entity
from tests.common import async_mock_service
@ -86,29 +87,25 @@ async def test_exposed_areas(
)
device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id)
kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234")
kitchen_light = entity_registry.async_get_or_create(
"light", "demo", "1234", original_name="kitchen light"
)
entity_registry.async_update_entity(
kitchen_light.entity_id, device_id=kitchen_device.id
)
hass.states.async_set(
kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"}
)
hass.states.async_set(kitchen_light.entity_id, "on")
bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678")
bedroom_light = entity_registry.async_get_or_create(
"light", "demo", "5678", original_name="bedroom light"
)
entity_registry.async_update_entity(
bedroom_light.entity_id, area_id=area_bedroom.id
)
hass.states.async_set(
bedroom_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"}
)
hass.states.async_set(bedroom_light.entity_id, "on")
def is_entity_exposed(state):
return state.entity_id != bedroom_light.entity_id
# Hide the bedroom light
expose_entity(hass, bedroom_light.entity_id, False)
with patch(
"homeassistant.components.conversation.default_agent.is_entity_exposed",
is_entity_exposed,
):
result = await conversation.async_converse(
hass, "turn on lights in the kitchen", None, Context(), None
)
@ -123,6 +120,4 @@ async def test_exposed_areas(
# 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
)
assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH

View file

@ -20,6 +20,8 @@ from homeassistant.helpers import (
)
from homeassistant.setup import async_setup_component
from . import expose_entity, expose_new
from tests.common import MockConfigEntry, MockUser, async_mock_service
from tests.typing import ClientSessionGenerator, WebSocketGenerator
@ -200,7 +202,11 @@ async def test_http_processing_intent_entity_added_removed(
# Add an entity
entity_registry.async_get_or_create(
"light", "demo", "5678", suggested_object_id="late"
"light",
"demo",
"5678",
suggested_object_id="late",
original_name="friendly light",
)
hass.states.async_set("light.late", "off", {"friendly_name": "friendly light"})
@ -307,7 +313,11 @@ async def test_http_processing_intent_alias_added_removed(
so that the new alias is available.
"""
entity_registry.async_get_or_create(
"light", "demo", "1234", suggested_object_id="kitchen"
"light",
"demo",
"1234",
suggested_object_id="kitchen",
original_name="kitchen light",
)
hass.states.async_set("light.kitchen", "off", {"friendly_name": "kitchen light"})
@ -428,6 +438,7 @@ async def test_http_processing_intent_entity_renamed(
LIGHT_DOMAIN,
{LIGHT_DOMAIN: [{"platform": "test"}]},
)
await hass.async_block_till_done()
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
client = await hass_client()
@ -576,12 +587,315 @@ async def test_http_processing_intent_entity_renamed(
}
async def test_http_processing_intent_entity_exposed(
hass: HomeAssistant,
init_components,
hass_client: ClientSessionGenerator,
hass_admin_user: MockUser,
entity_registry: er.EntityRegistry,
enable_custom_integrations: None,
) -> None:
"""Test processing intent via HTTP API with manual expose.
We want to ensure that manually exposing an entity later busts the cache
so that the new setting is used.
"""
platform = getattr(hass.components, "test.light")
platform.init(empty=True)
entity = platform.MockLight("kitchen light", "on")
entity._attr_unique_id = "1234"
entity.entity_id = "light.kitchen"
platform.ENTITIES.append(entity)
assert await async_setup_component(
hass,
LIGHT_DOMAIN,
{LIGHT_DOMAIN: [{"platform": "test"}]},
)
await hass.async_block_till_done()
entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"})
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
client = await hass_client()
resp = await client.post(
"/api/conversation/process", json={"text": "turn on kitchen light"}
)
assert resp.status == HTTPStatus.OK
assert len(calls) == 1
data = await resp.json()
assert data == {
"response": {
"response_type": "action_done",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Turned on light",
}
},
"language": hass.config.language,
"data": {
"targets": [],
"success": [
{"id": "light.kitchen", "name": "kitchen light", "type": "entity"}
],
"failed": [],
},
},
"conversation_id": None,
}
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
client = await hass_client()
resp = await client.post(
"/api/conversation/process", json={"text": "turn on my cool light"}
)
assert resp.status == HTTPStatus.OK
assert len(calls) == 1
data = await resp.json()
assert data == {
"response": {
"response_type": "action_done",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Turned on light",
}
},
"language": hass.config.language,
"data": {
"targets": [],
"success": [
{"id": "light.kitchen", "name": "kitchen light", "type": "entity"}
],
"failed": [],
},
},
"conversation_id": None,
}
# Unexpose the entity
expose_entity(hass, "light.kitchen", False)
await hass.async_block_till_done()
client = await hass_client()
resp = await client.post(
"/api/conversation/process", json={"text": "turn on kitchen light"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == {
"conversation_id": None,
"response": {
"card": {},
"data": {"code": "no_intent_match"},
"language": hass.config.language,
"response_type": "error",
"speech": {
"plain": {
"extra_data": None,
"speech": "Sorry, I couldn't understand that",
}
},
},
}
client = await hass_client()
resp = await client.post(
"/api/conversation/process", json={"text": "turn on my cool light"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == {
"conversation_id": None,
"response": {
"card": {},
"data": {"code": "no_intent_match"},
"language": hass.config.language,
"response_type": "error",
"speech": {
"plain": {
"extra_data": None,
"speech": "Sorry, I couldn't understand that",
}
},
},
}
# Now expose the entity
expose_entity(hass, "light.kitchen", True)
await hass.async_block_till_done()
client = await hass_client()
resp = await client.post(
"/api/conversation/process", json={"text": "turn on kitchen light"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == {
"response": {
"response_type": "action_done",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Turned on light",
}
},
"language": hass.config.language,
"data": {
"targets": [],
"success": [
{"id": "light.kitchen", "name": "kitchen light", "type": "entity"}
],
"failed": [],
},
},
"conversation_id": None,
}
client = await hass_client()
resp = await client.post(
"/api/conversation/process", json={"text": "turn on my cool light"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == {
"response": {
"response_type": "action_done",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Turned on light",
}
},
"language": hass.config.language,
"data": {
"targets": [],
"success": [
{"id": "light.kitchen", "name": "kitchen light", "type": "entity"}
],
"failed": [],
},
},
"conversation_id": None,
}
async def test_http_processing_intent_conversion_not_expose_new(
hass: HomeAssistant,
init_components,
hass_client: ClientSessionGenerator,
hass_admin_user: MockUser,
entity_registry: er.EntityRegistry,
enable_custom_integrations: None,
) -> None:
"""Test processing intent via HTTP API when not exposing new entities."""
# Disable exposing new entities to the default agent
expose_new(hass, False)
platform = getattr(hass.components, "test.light")
platform.init(empty=True)
entity = platform.MockLight("kitchen light", "on")
entity._attr_unique_id = "1234"
entity.entity_id = "light.kitchen"
platform.ENTITIES.append(entity)
assert await async_setup_component(
hass,
LIGHT_DOMAIN,
{LIGHT_DOMAIN: [{"platform": "test"}]},
)
await hass.async_block_till_done()
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
client = await hass_client()
resp = await client.post(
"/api/conversation/process", json={"text": "turn on kitchen light"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == {
"conversation_id": None,
"response": {
"card": {},
"data": {"code": "no_intent_match"},
"language": hass.config.language,
"response_type": "error",
"speech": {
"plain": {
"extra_data": None,
"speech": "Sorry, I couldn't understand that",
}
},
},
}
# Expose the entity
expose_entity(hass, "light.kitchen", True)
await hass.async_block_till_done()
resp = await client.post(
"/api/conversation/process", json={"text": "turn on kitchen light"}
)
assert resp.status == HTTPStatus.OK
assert len(calls) == 1
data = await resp.json()
assert data == {
"response": {
"response_type": "action_done",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Turned on light",
}
},
"language": hass.config.language,
"data": {
"targets": [],
"success": [
{"id": "light.kitchen", "name": "kitchen light", "type": "entity"}
],
"failed": [],
},
},
"conversation_id": None,
}
@pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS)
@pytest.mark.parametrize("sentence", ("turn on kitchen", "turn kitchen on"))
async def test_turn_on_intent(
hass: HomeAssistant, init_components, sentence, agent_id
hass: HomeAssistant,
init_components,
entity_registry: er.EntityRegistry,
sentence,
agent_id,
) -> None:
"""Test calling the turn on intent."""
entity_registry.async_get_or_create(
"light",
"demo",
"1234",
suggested_object_id="kitchen",
original_name="kitchen",
)
hass.states.async_set("light.kitchen", "off")
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
@ -599,8 +913,17 @@ async def test_turn_on_intent(
@pytest.mark.parametrize("sentence", ("turn off kitchen", "turn kitchen off"))
async def test_turn_off_intent(hass: HomeAssistant, init_components, sentence) -> None:
async def test_turn_off_intent(
hass: HomeAssistant, init_components, entity_registry: er.EntityRegistry, sentence
) -> None:
"""Test calling the turn on intent."""
entity_registry.async_get_or_create(
"light",
"demo",
"1234",
suggested_object_id="kitchen",
original_name="kitchen",
)
hass.states.async_set("light.kitchen", "on")
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off")
@ -646,11 +969,21 @@ async def test_http_api_no_match(
async def test_http_api_handle_failure(
hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator
hass: HomeAssistant,
init_components,
entity_registry: er.EntityRegistry,
hass_client: ClientSessionGenerator,
) -> None:
"""Test the HTTP conversation API with an error during handling."""
client = await hass_client()
entity_registry.async_get_or_create(
"light",
"demo",
"1234",
suggested_object_id="kitchen",
original_name="kitchen",
)
hass.states.async_set("light.kitchen", "off")
# Raise an error during intent handling
@ -685,11 +1018,21 @@ async def test_http_api_handle_failure(
async def test_http_api_unexpected_failure(
hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator
hass: HomeAssistant,
init_components,
entity_registry: er.EntityRegistry,
hass_client: ClientSessionGenerator,
) -> None:
"""Test the HTTP conversation API with an unexpected error during handling."""
client = await hass_client()
entity_registry.async_get_or_create(
"light",
"demo",
"1234",
suggested_object_id="kitchen",
original_name="kitchen",
)
hass.states.async_set("light.kitchen", "off")
# Raise an "unexpected" error during intent handling
@ -1008,8 +1351,17 @@ async def test_prepare_fail(hass: HomeAssistant) -> None:
assert not agent._lang_intents.get("not-a-language")
async def test_language_region(hass: HomeAssistant, init_components) -> None:
async def test_language_region(
hass: HomeAssistant, init_components, entity_registry: er.EntityRegistry
) -> None:
"""Test calling the turn on intent."""
entity_registry.async_get_or_create(
"light",
"demo",
"1234",
suggested_object_id="kitchen",
original_name="kitchen",
)
hass.states.async_set("light.kitchen", "off")
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
@ -1057,8 +1409,17 @@ async def test_reload_on_new_component(hass: HomeAssistant) -> None:
assert {"light"} == (lang_intents.loaded_components - loaded_components)
async def test_non_default_response(hass: HomeAssistant, init_components) -> None:
async def test_non_default_response(
hass: HomeAssistant, init_components, entity_registry: er.EntityRegistry
) -> None:
"""Test intent response that is not the default."""
entity_registry.async_get_or_create(
"cover",
"demo",
"1234",
suggested_object_id="front_door",
original_name="front door",
)
hass.states.async_set("cover.front_door", "closed")
calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER)