Use blocking in intent service calls and verify results (#88035)

* Use blocking in service calls and verify result

* Block for 2 seconds and update states after

* Small timeout in service call to allow exceptions

* Move sun test
This commit is contained in:
Michael Hansen 2023-02-16 13:01:41 -06:00 committed by GitHub
parent 5fe8829cf6
commit dc30210237
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 89 additions and 35 deletions

View File

@ -75,8 +75,15 @@ class OnOffIntentHandler(intent.ServiceIntentHandler):
else SERVICE_CLOSE_COVER, else SERVICE_CLOSE_COVER,
{ATTR_ENTITY_ID: state.entity_id}, {ATTR_ENTITY_ID: state.entity_id},
context=intent_obj.context, context=intent_obj.context,
blocking=True,
limit=self.service_timeout,
) )
else:
elif not hass.services.has_service(state.domain, self.service):
raise intent.IntentHandleError(
f"Service {self.service} does not support entity {state.entity_id}"
)
# Fall back to homeassistant.turn_on/off # Fall back to homeassistant.turn_on/off
await super().async_call_service(intent_obj, state) await super().async_call_service(intent_obj, state)

View File

@ -335,6 +335,10 @@ class ServiceIntentHandler(IntentHandler):
vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]), vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]),
} }
# We use a small timeout in service calls to (hopefully) pass validation
# checks, but not try to wait for the call to fully complete.
service_timeout: float = 0.2
def __init__( def __init__(
self, intent_type: str, domain: str, service: str, speech: str | None = None self, intent_type: str, domain: str, service: str, speech: str | None = None
) -> None: ) -> None:
@ -402,7 +406,8 @@ class ServiceIntentHandler(IntentHandler):
area: area_registry.AreaEntry | None = None, area: area_registry.AreaEntry | None = None,
) -> IntentResponse: ) -> IntentResponse:
"""Complete action on matched entity states.""" """Complete action on matched entity states."""
assert states assert states, "No states"
hass = intent_obj.hass
success_results: list[IntentResponseTarget] = [] success_results: list[IntentResponseTarget] = []
response = intent_obj.create_response() response = intent_obj.create_response()
@ -419,21 +424,36 @@ class ServiceIntentHandler(IntentHandler):
service_coros = [] service_coros = []
for state in states: for state in states:
service_coros.append(self.async_call_service(intent_obj, state)) service_coros.append(self.async_call_service(intent_obj, state))
success_results.append(
IntentResponseTarget( # Handle service calls in parallel, noting failures as they occur.
failed_results: list[IntentResponseTarget] = []
for state, service_coro in zip(states, asyncio.as_completed(service_coros)):
target = IntentResponseTarget(
type=IntentResponseTargetType.ENTITY, type=IntentResponseTargetType.ENTITY,
name=state.name, name=state.name,
id=state.entity_id, id=state.entity_id,
),
) )
# Handle service calls in parallel. try:
# We will need to handle partial failures here. await service_coro
await asyncio.gather(*service_coros) success_results.append(target)
except Exception: # pylint: disable=broad-except
failed_results.append(target)
_LOGGER.exception("Service call failed for %s", state.entity_id)
if not success_results:
# If no entities succeeded, raise an error.
failed_entity_ids = [target.id for target in failed_results]
raise IntentHandleError(
f"Failed to call {self.service} for: {failed_entity_ids}"
)
response.async_set_results( response.async_set_results(
success_results=success_results, success_results=success_results, failed_results=failed_results
) )
# Update all states
states = [hass.states.get(state.entity_id) or state for state in states]
response.async_set_states(states) response.async_set_states(states)
if self.speech is not None: if self.speech is not None:
@ -449,6 +469,8 @@ class ServiceIntentHandler(IntentHandler):
self.service, self.service,
{ATTR_ENTITY_ID: state.entity_id}, {ATTR_ENTITY_ID: state.entity_id},
context=intent_obj.context, context=intent_obj.context,
blocking=True,
limit=self.service_timeout,
) )

View File

@ -1,5 +1,6 @@
"""The tests for the Conversation component.""" """The tests for the Conversation component."""
from http import HTTPStatus from http import HTTPStatus
from typing import Any
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -7,8 +8,9 @@ import voluptuous as vol
from homeassistant.components import conversation from homeassistant.components import conversation
from homeassistant.components.cover import SERVICE_OPEN_COVER from homeassistant.components.cover import SERVICE_OPEN_COVER
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import ( from homeassistant.helpers import (
area_registry, area_registry,
device_registry, device_registry,
@ -59,13 +61,15 @@ async def test_http_processing_intent(
entities.async_update_entity("light.kitchen", aliases={"my cool light"}) entities.async_update_entity("light.kitchen", aliases={"my cool light"})
hass.states.async_set("light.kitchen", "off") hass.states.async_set("light.kitchen", "off")
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
client = await hass_client() client = await hass_client()
data = {"text": "turn on my cool light"} data: dict[str, Any] = {"text": "turn on my cool light"}
if agent_id: if agent_id:
data["agent_id"] = agent_id data["agent_id"] = agent_id
resp = await client.post("/api/conversation/process", json=data) resp = await client.post("/api/conversation/process", json=data)
assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK
assert len(calls) == 1
data = await resp.json() data = await resp.json()
assert data == { assert data == {
@ -105,6 +109,7 @@ async def test_http_processing_intent_target_ha_agent(
entities.async_update_entity("light.kitchen", aliases={"my cool light"}) entities.async_update_entity("light.kitchen", aliases={"my cool light"})
hass.states.async_set("light.kitchen", "off") hass.states.async_set("light.kitchen", "off")
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
client = await hass_client() client = await hass_client()
resp = await client.post( resp = await client.post(
"/api/conversation/process", "/api/conversation/process",
@ -112,6 +117,7 @@ async def test_http_processing_intent_target_ha_agent(
) )
assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK
assert len(calls) == 1
data = await resp.json() data = await resp.json()
assert data == { assert data == {
@ -153,12 +159,14 @@ async def test_http_processing_intent_entity_added(
er.async_update_entity("light.kitchen", aliases={"my cool light"}) er.async_update_entity("light.kitchen", aliases={"my cool light"})
hass.states.async_set("light.kitchen", "off") hass.states.async_set("light.kitchen", "off")
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
client = await hass_client() client = await hass_client()
resp = await client.post( resp = await client.post(
"/api/conversation/process", json={"text": "turn on my cool light"} "/api/conversation/process", json={"text": "turn on my cool light"}
) )
assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK
assert len(calls) == 1
data = await resp.json() data = await resp.json()
assert data == { assert data == {
@ -284,7 +292,7 @@ async def test_turn_on_intent(
) -> None: ) -> None:
"""Test calling the turn on intent.""" """Test calling the turn on intent."""
hass.states.async_set("light.kitchen", "off") hass.states.async_set("light.kitchen", "off")
calls = async_mock_service(hass, HASS_DOMAIN, "turn_on") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
data = {conversation.ATTR_TEXT: sentence} data = {conversation.ATTR_TEXT: sentence}
if agent_id is not None: if agent_id is not None:
@ -294,16 +302,16 @@ async def test_turn_on_intent(
assert len(calls) == 1 assert len(calls) == 1
call = calls[0] call = calls[0]
assert call.domain == HASS_DOMAIN assert call.domain == LIGHT_DOMAIN
assert call.service == "turn_on" assert call.service == "turn_on"
assert call.data == {"entity_id": "light.kitchen"} assert call.data == {"entity_id": ["light.kitchen"]}
@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, sentence) -> None:
"""Test calling the turn on intent.""" """Test calling the turn on intent."""
hass.states.async_set("light.kitchen", "on") hass.states.async_set("light.kitchen", "on")
calls = async_mock_service(hass, HASS_DOMAIN, "turn_off") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off")
await hass.services.async_call( await hass.services.async_call(
"conversation", "process", {conversation.ATTR_TEXT: sentence} "conversation", "process", {conversation.ATTR_TEXT: sentence}
@ -312,9 +320,9 @@ async def test_turn_off_intent(hass: HomeAssistant, init_components, sentence) -
assert len(calls) == 1 assert len(calls) == 1
call = calls[0] call = calls[0]
assert call.domain == HASS_DOMAIN assert call.domain == LIGHT_DOMAIN
assert call.service == "turn_off" assert call.service == "turn_off"
assert call.data == {"entity_id": "light.kitchen"} assert call.data == {"entity_id": ["light.kitchen"]}
async def test_http_api_no_match( async def test_http_api_no_match(
@ -706,7 +714,7 @@ async def test_prepare_fail(hass: HomeAssistant) -> None:
async def test_language_region(hass: HomeAssistant, init_components) -> None: async def test_language_region(hass: HomeAssistant, init_components) -> None:
"""Test calling the turn on intent.""" """Test calling the turn on intent."""
hass.states.async_set("light.kitchen", "off") hass.states.async_set("light.kitchen", "off")
calls = async_mock_service(hass, HASS_DOMAIN, "turn_on") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
# Add fake region # Add fake region
language = f"{hass.config.language}-YZ" language = f"{hass.config.language}-YZ"
@ -722,9 +730,9 @@ async def test_language_region(hass: HomeAssistant, init_components) -> None:
assert len(calls) == 1 assert len(calls) == 1
call = calls[0] call = calls[0]
assert call.domain == HASS_DOMAIN assert call.domain == LIGHT_DOMAIN
assert call.service == "turn_on" assert call.service == "turn_on"
assert call.data == {"entity_id": "light.kitchen"} assert call.data == {"entity_id": ["light.kitchen"]}
async def test_reload_on_new_component(hass: HomeAssistant) -> None: async def test_reload_on_new_component(hass: HomeAssistant) -> None:
@ -755,7 +763,7 @@ async def test_reload_on_new_component(hass: HomeAssistant) -> None:
async def test_non_default_response(hass: HomeAssistant, init_components) -> None: async def test_non_default_response(hass: HomeAssistant, init_components) -> None:
"""Test intent response that is not the default.""" """Test intent response that is not the default."""
hass.states.async_set("cover.front_door", "closed") hass.states.async_set("cover.front_door", "closed")
async_mock_service(hass, "cover", SERVICE_OPEN_COVER) calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
agent = await conversation._get_agent_manager(hass).async_get_agent() agent = await conversation._get_agent_manager(hass).async_get_agent()
assert isinstance(agent, conversation.DefaultAgent) assert isinstance(agent, conversation.DefaultAgent)
@ -768,6 +776,7 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non
language=hass.config.language, language=hass.config.language,
) )
) )
assert len(calls) == 1
assert result.response.speech["plain"]["speech"] == "Opened front door" assert result.response.speech["plain"]["speech"] == "Opened front door"
@ -792,7 +801,7 @@ async def test_turn_on_area(hass: HomeAssistant, init_components) -> None:
) )
hass.states.async_set("light.stove", "off") hass.states.async_set("light.stove", "off")
calls = async_mock_service(hass, HASS_DOMAIN, "turn_on") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
await hass.services.async_call( await hass.services.async_call(
"conversation", "conversation",
@ -803,9 +812,9 @@ async def test_turn_on_area(hass: HomeAssistant, init_components) -> None:
assert len(calls) == 1 assert len(calls) == 1
call = calls[0] call = calls[0]
assert call.domain == HASS_DOMAIN assert call.domain == LIGHT_DOMAIN
assert call.service == "turn_on" assert call.service == "turn_on"
assert call.data == {"entity_id": "light.stove"} assert call.data == {"entity_id": ["light.stove"]}
basement_area = ar.async_create("basement") basement_area = ar.async_create("basement")
dr.async_update_device(device.id, area_id=basement_area.id) dr.async_update_device(device.id, area_id=basement_area.id)
@ -832,9 +841,9 @@ async def test_turn_on_area(hass: HomeAssistant, init_components) -> None:
assert len(calls) == 1 assert len(calls) == 1
call = calls[0] call = calls[0]
assert call.domain == HASS_DOMAIN assert call.domain == LIGHT_DOMAIN
assert call.service == "turn_on" assert call.service == "turn_on"
assert call.data == {"entity_id": "light.stove"} assert call.data == {"entity_id": ["light.stove"]}
async def test_light_area_same_name(hass: HomeAssistant, init_components) -> None: async def test_light_area_same_name(hass: HomeAssistant, init_components) -> None:
@ -868,7 +877,7 @@ async def test_light_area_same_name(hass: HomeAssistant, init_components) -> Non
ceiling_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "ceiling light"} ceiling_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "ceiling light"}
) )
calls = async_mock_service(hass, HASS_DOMAIN, "turn_on") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
await hass.services.async_call( await hass.services.async_call(
"conversation", "conversation",
@ -880,9 +889,9 @@ async def test_light_area_same_name(hass: HomeAssistant, init_components) -> Non
# Should only turn on one light instead of all lights in the kitchen # Should only turn on one light instead of all lights in the kitchen
assert len(calls) == 1 assert len(calls) == 1
call = calls[0] call = calls[0]
assert call.domain == HASS_DOMAIN assert call.domain == LIGHT_DOMAIN
assert call.service == "turn_on" assert call.service == "turn_on"
assert call.data == {"entity_id": kitchen_light.entity_id} assert call.data == {"entity_id": [kitchen_light.entity_id]}
async def test_agent_id_validator_invalid_agent(hass: HomeAssistant) -> None: async def test_agent_id_validator_invalid_agent(hass: HomeAssistant) -> None:

View File

@ -3,9 +3,10 @@
import pytest import pytest
import voluptuous as vol import voluptuous as vol
from homeassistant.components import conversation
from homeassistant.components.switch import SwitchDeviceClass from homeassistant.components.switch import SwitchDeviceClass
from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import HomeAssistant, State from homeassistant.core import Context, HomeAssistant, State
from homeassistant.helpers import ( from homeassistant.helpers import (
area_registry, area_registry,
config_validation as cv, config_validation as cv,
@ -13,6 +14,7 @@ from homeassistant.helpers import (
entity_registry, entity_registry,
intent, intent,
) )
from homeassistant.setup import async_setup_component
class MockIntentHandler(intent.IntentHandler): class MockIntentHandler(intent.IntentHandler):
@ -154,3 +156,17 @@ def test_async_validate_slots() -> None:
handler1.async_validate_slots( handler1.async_validate_slots(
{"name": {"value": "kitchen"}, "probability": {"value": "0.5"}} {"name": {"value": "kitchen"}, "probability": {"value": "0.5"}}
) )
async def test_cant_turn_on_sun(hass: HomeAssistant) -> None:
"""Test we can't turn on entities that don't support it."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "conversation", {})
assert await async_setup_component(hass, "intent", {})
assert await async_setup_component(hass, "sun", {})
result = await conversation.async_converse(
hass, "turn on sun", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ERROR
assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE