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."""
|
||||
|
||||
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
|
||||
|
||||
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,48 +467,40 @@ 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
|
||||
context = {"domain": entity.domain}
|
||||
if entity.device_class:
|
||||
context[ATTR_DEVICE_CLASS] = entity.device_class
|
||||
|
||||
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
|
||||
if entity.aliases:
|
||||
for alias in entity.aliases:
|
||||
entity_names.append((alias, alias, context))
|
||||
|
||||
if entity.aliases:
|
||||
for alias in entity.aliases:
|
||||
entity_names.append((alias, alias, context))
|
||||
# Default name
|
||||
name = entity.async_friendly_name(self.hass) or entity.entity_id.replace(
|
||||
"_", " "
|
||||
)
|
||||
entity_names.append((name, name, context))
|
||||
|
||||
# Default name
|
||||
entity_names.append((state.name, state.name, context))
|
||||
|
||||
if entity.area_id:
|
||||
# Expose area too
|
||||
area_ids_with_entities.add(entity.area_id)
|
||||
elif entity.device_id:
|
||||
# Check device for area as well
|
||||
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))
|
||||
if entity.area_id:
|
||||
# Expose area too
|
||||
area_ids_with_entities.add(entity.area_id)
|
||||
elif entity.device_id:
|
||||
# Check device for area as well
|
||||
device = devices.async_get(entity.device_id)
|
||||
if (device is not None) and device.area_id:
|
||||
area_ids_with_entities.add(device.area_id)
|
||||
|
||||
# Gather areas from exposed entities
|
||||
areas = ar.async_get(self.hass)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,43 +87,37 @@ 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")
|
||||
|
||||
# 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):
|
||||
return state.entity_id != bedroom_light.entity_id
|
||||
# All is well for the exposed kitchen light
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
|
||||
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
|
||||
)
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
# 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 . 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)
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue