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:
parent
e40a373c4b
commit
2c9e9d0fde
8 changed files with 473 additions and 107 deletions
|
@ -1,21 +1,3 @@
|
||||||
"""Const for conversation integration."""
|
"""Const for conversation integration."""
|
||||||
|
|
||||||
DOMAIN = "conversation"
|
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"}
|
|
||||||
|
|
|
@ -17,6 +17,11 @@ from home_assistant_intents import get_intents
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from homeassistant import core, setup
|
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 (
|
from homeassistant.helpers import (
|
||||||
area_registry as ar,
|
area_registry as ar,
|
||||||
device_registry as dr,
|
device_registry as dr,
|
||||||
|
@ -28,7 +33,7 @@ from homeassistant.helpers import (
|
||||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||||
|
|
||||||
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
|
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
|
||||||
from .const import DEFAULT_EXPOSED_ATTRIBUTES, DEFAULT_EXPOSED_DOMAINS, DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
_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(""))
|
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:
|
def json_load(fp: IO[str]) -> JsonObjectType:
|
||||||
"""Wrap json_loads for get_intents."""
|
"""Wrap json_loads for get_intents."""
|
||||||
return json_loads_object(fp.read())
|
return json_loads_object(fp.read())
|
||||||
|
@ -105,10 +105,8 @@ class DefaultAgent(AbstractConversationAgent):
|
||||||
self._async_handle_entity_registry_changed,
|
self._async_handle_entity_registry_changed,
|
||||||
run_immediately=True,
|
run_immediately=True,
|
||||||
)
|
)
|
||||||
self.hass.bus.async_listen(
|
async_listen_entity_updates(
|
||||||
core.EVENT_STATE_CHANGED,
|
self.hass, DOMAIN, self._async_exposed_entities_updated
|
||||||
self._async_handle_state_changed,
|
|
||||||
run_immediately=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
|
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
|
||||||
|
@ -459,10 +457,8 @@ class DefaultAgent(AbstractConversationAgent):
|
||||||
self._slot_lists = None
|
self._slot_lists = None
|
||||||
|
|
||||||
@core.callback
|
@core.callback
|
||||||
def _async_handle_state_changed(self, event: core.Event) -> None:
|
def _async_exposed_entities_updated(self) -> None:
|
||||||
"""Clear names list cache when a state is added or removed from the state machine."""
|
"""Handle updated preferences."""
|
||||||
if event.data.get("old_state") and event.data.get("new_state"):
|
|
||||||
return
|
|
||||||
self._slot_lists = None
|
self._slot_lists = None
|
||||||
|
|
||||||
def _make_slot_lists(self) -> dict[str, SlotList]:
|
def _make_slot_lists(self) -> dict[str, SlotList]:
|
||||||
|
@ -471,48 +467,40 @@ class DefaultAgent(AbstractConversationAgent):
|
||||||
return self._slot_lists
|
return self._slot_lists
|
||||||
|
|
||||||
area_ids_with_entities: set[str] = set()
|
area_ids_with_entities: set[str] = set()
|
||||||
states = [
|
all_entities = er.async_get(self.hass)
|
||||||
state for state in self.hass.states.async_all() if is_entity_exposed(state)
|
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)
|
devices = dr.async_get(self.hass)
|
||||||
|
|
||||||
# Gather exposed entity names
|
# Gather exposed entity names
|
||||||
entity_names = []
|
entity_names = []
|
||||||
for state in states:
|
for entity in entities:
|
||||||
# Checked against "requires_context" and "excludes_context" in hassil
|
# Checked against "requires_context" and "excludes_context" in hassil
|
||||||
context = {"domain": state.domain}
|
context = {"domain": entity.domain}
|
||||||
if state.attributes:
|
if entity.device_class:
|
||||||
# Include some attributes
|
context[ATTR_DEVICE_CLASS] = entity.device_class
|
||||||
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.aliases:
|
||||||
if entity is not None:
|
for alias in entity.aliases:
|
||||||
if entity.entity_category or entity.hidden:
|
entity_names.append((alias, alias, context))
|
||||||
# Skip configuration/diagnostic/hidden entities
|
|
||||||
continue
|
|
||||||
|
|
||||||
if entity.aliases:
|
# Default name
|
||||||
for alias in entity.aliases:
|
name = entity.async_friendly_name(self.hass) or entity.entity_id.replace(
|
||||||
entity_names.append((alias, alias, context))
|
"_", " "
|
||||||
|
)
|
||||||
|
entity_names.append((name, name, context))
|
||||||
|
|
||||||
# Default name
|
if entity.area_id:
|
||||||
entity_names.append((state.name, state.name, context))
|
# Expose area too
|
||||||
|
area_ids_with_entities.add(entity.area_id)
|
||||||
if entity.area_id:
|
elif entity.device_id:
|
||||||
# Expose area too
|
# Check device for area as well
|
||||||
area_ids_with_entities.add(entity.area_id)
|
device = devices.async_get(entity.device_id)
|
||||||
elif entity.device_id:
|
if (device is not None) and device.area_id:
|
||||||
# Check device for area as well
|
area_ids_with_entities.add(device.area_id)
|
||||||
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
|
# Gather areas from exposed entities
|
||||||
areas = ar.async_get(self.hass)
|
areas = ar.async_get(self.hass)
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
"domain": "conversation",
|
"domain": "conversation",
|
||||||
"name": "Conversation",
|
"name": "Conversation",
|
||||||
"codeowners": ["@home-assistant/core", "@synesthesiam"],
|
"codeowners": ["@home-assistant/core", "@synesthesiam"],
|
||||||
"dependencies": ["http"],
|
"dependencies": ["homeassistant", "http"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
|
|
|
@ -19,7 +19,7 @@ from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
from .const import DATA_EXPOSED_ENTITIES, DOMAIN
|
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_KEY = f"{DOMAIN}.exposed_entities"
|
||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
|
@ -61,6 +61,10 @@ DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = {
|
||||||
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DEFAULT_EXPOSED_ASSISTANT = {
|
||||||
|
"conversation": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
class AssistantPreferences:
|
class AssistantPreferences:
|
||||||
|
@ -130,7 +134,7 @@ class ExposedEntities:
|
||||||
"""Check if new entities are exposed to an assistant."""
|
"""Check if new entities are exposed to an assistant."""
|
||||||
if prefs := self._assistants.get(assistant):
|
if prefs := self._assistants.get(assistant):
|
||||||
return prefs.expose_new
|
return prefs.expose_new
|
||||||
return False
|
return DEFAULT_EXPOSED_ASSISTANT.get(assistant, False)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_set_expose_new_entities(self, assistant: str, expose_new: bool) -> None:
|
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"]
|
should_expose = registry_entry.options[assistant]["should_expose"]
|
||||||
return 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)
|
should_expose = self._is_default_exposed(entity_id, registry_entry)
|
||||||
else:
|
else:
|
||||||
should_expose = False
|
should_expose = False
|
||||||
|
|
|
@ -309,6 +309,26 @@ class RegistryEntry:
|
||||||
|
|
||||||
hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs)
|
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]]]]):
|
class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
|
||||||
"""Store entity registry data."""
|
"""Store entity registry data."""
|
||||||
|
|
|
@ -2,6 +2,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components import conversation
|
from homeassistant.components import conversation
|
||||||
|
from homeassistant.components.homeassistant.exposed_entities import (
|
||||||
|
DATA_EXPOSED_ENTITIES,
|
||||||
|
ExposedEntities,
|
||||||
|
)
|
||||||
from homeassistant.helpers import intent
|
from homeassistant.helpers import intent
|
||||||
|
|
||||||
|
|
||||||
|
@ -24,3 +28,15 @@ class MockAgent(conversation.AbstractConversationAgent):
|
||||||
return conversation.ConversationResult(
|
return conversation.ConversationResult(
|
||||||
response=response, conversation_id=user_input.conversation_id
|
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)
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
"""Test for the default agent."""
|
"""Test for the default agent."""
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
@ -15,6 +14,8 @@ from homeassistant.helpers import (
|
||||||
)
|
)
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from . import expose_entity
|
||||||
|
|
||||||
from tests.common import async_mock_service
|
from tests.common import async_mock_service
|
||||||
|
|
||||||
|
|
||||||
|
@ -86,43 +87,37 @@ async def test_exposed_areas(
|
||||||
)
|
)
|
||||||
device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id)
|
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(
|
entity_registry.async_update_entity(
|
||||||
kitchen_light.entity_id, device_id=kitchen_device.id
|
kitchen_light.entity_id, device_id=kitchen_device.id
|
||||||
)
|
)
|
||||||
hass.states.async_set(
|
hass.states.async_set(kitchen_light.entity_id, "on")
|
||||||
kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"}
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
entity_registry.async_update_entity(
|
||||||
bedroom_light.entity_id, area_id=area_bedroom.id
|
bedroom_light.entity_id, area_id=area_bedroom.id
|
||||||
)
|
)
|
||||||
hass.states.async_set(
|
hass.states.async_set(bedroom_light.entity_id, "on")
|
||||||
bedroom_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"}
|
|
||||||
|
# Hide the bedroom light
|
||||||
|
expose_entity(hass, bedroom_light.entity_id, False)
|
||||||
|
|
||||||
|
result = await conversation.async_converse(
|
||||||
|
hass, "turn on lights in the kitchen", None, Context(), None
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_entity_exposed(state):
|
# All is well for the exposed kitchen light
|
||||||
return state.entity_id != bedroom_light.entity_id
|
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
|
||||||
with patch(
|
# Bedroom is not exposed because it has no exposed entities
|
||||||
"homeassistant.components.conversation.default_agent.is_entity_exposed",
|
result = await conversation.async_converse(
|
||||||
is_entity_exposed,
|
hass, "turn on lights in the bedroom", None, Context(), None
|
||||||
):
|
)
|
||||||
result = await conversation.async_converse(
|
|
||||||
hass, "turn on lights in the kitchen", None, Context(), None
|
|
||||||
)
|
|
||||||
|
|
||||||
# All is well for the exposed kitchen light
|
# This should be an intent match failure because the area isn't in the slot list
|
||||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||||
|
assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH
|
||||||
# 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
|
|
||||||
)
|
|
||||||
|
|
|
@ -20,6 +20,8 @@ from homeassistant.helpers import (
|
||||||
)
|
)
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from . import expose_entity, expose_new
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, MockUser, async_mock_service
|
from tests.common import MockConfigEntry, MockUser, async_mock_service
|
||||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||||
|
|
||||||
|
@ -200,7 +202,11 @@ async def test_http_processing_intent_entity_added_removed(
|
||||||
|
|
||||||
# Add an entity
|
# Add an entity
|
||||||
entity_registry.async_get_or_create(
|
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"})
|
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.
|
so that the new alias is available.
|
||||||
"""
|
"""
|
||||||
entity_registry.async_get_or_create(
|
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"})
|
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,
|
||||||
{LIGHT_DOMAIN: [{"platform": "test"}]},
|
{LIGHT_DOMAIN: [{"platform": "test"}]},
|
||||||
)
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
|
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
|
||||||
client = await hass_client()
|
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("agent_id", AGENT_ID_OPTIONS)
|
||||||
@pytest.mark.parametrize("sentence", ("turn on kitchen", "turn kitchen on"))
|
@pytest.mark.parametrize("sentence", ("turn on kitchen", "turn kitchen on"))
|
||||||
async def test_turn_on_intent(
|
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:
|
) -> None:
|
||||||
"""Test calling the turn on intent."""
|
"""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")
|
hass.states.async_set("light.kitchen", "off")
|
||||||
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
|
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"))
|
@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."""
|
"""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")
|
hass.states.async_set("light.kitchen", "on")
|
||||||
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off")
|
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(
|
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:
|
) -> None:
|
||||||
"""Test the HTTP conversation API with an error during handling."""
|
"""Test the HTTP conversation API with an error during handling."""
|
||||||
client = await hass_client()
|
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")
|
hass.states.async_set("light.kitchen", "off")
|
||||||
|
|
||||||
# Raise an error during intent handling
|
# Raise an error during intent handling
|
||||||
|
@ -685,11 +1018,21 @@ async def test_http_api_handle_failure(
|
||||||
|
|
||||||
|
|
||||||
async def test_http_api_unexpected_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:
|
) -> None:
|
||||||
"""Test the HTTP conversation API with an unexpected error during handling."""
|
"""Test the HTTP conversation API with an unexpected error during handling."""
|
||||||
client = await hass_client()
|
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")
|
hass.states.async_set("light.kitchen", "off")
|
||||||
|
|
||||||
# Raise an "unexpected" error during intent handling
|
# 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")
|
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."""
|
"""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")
|
hass.states.async_set("light.kitchen", "off")
|
||||||
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
|
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)
|
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."""
|
"""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")
|
hass.states.async_set("cover.front_door", "closed")
|
||||||
calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
|
calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue