Fix exposure checks on some intents (#118988)

* Check exposure in climate intent

* Check exposure in todo list

* Check exposure for weather

* Check exposure in humidity intents

* Add extra checks to weather tests

* Add more checks to todo intent test

* Move climate intents to async_match_targets

* Update test_intent.py

* Update test_intent.py

* Remove patch
pull/119096/head
Michael Hansen 2024-06-06 20:41:25 -05:00 committed by Franck Nijhof
parent 1f6be7b4d1
commit 56db7fc7dc
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
9 changed files with 453 additions and 204 deletions

View File

@ -4,11 +4,10 @@ from __future__ import annotations
import voluptuous as vol import voluptuous as vol
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent from homeassistant.helpers import intent
from homeassistant.helpers.entity_component import EntityComponent
from . import DOMAIN, ClimateEntity from . import DOMAIN
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
@ -23,7 +22,10 @@ class GetTemperatureIntent(intent.IntentHandler):
intent_type = INTENT_GET_TEMPERATURE intent_type = INTENT_GET_TEMPERATURE
description = "Gets the current temperature of a climate device or entity" description = "Gets the current temperature of a climate device or entity"
slot_schema = {vol.Optional("area"): str, vol.Optional("name"): str} slot_schema = {
vol.Optional("area"): intent.non_empty_string,
vol.Optional("name"): intent.non_empty_string,
}
platforms = {DOMAIN} platforms = {DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@ -31,74 +33,24 @@ class GetTemperatureIntent(intent.IntentHandler):
hass = intent_obj.hass hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
component: EntityComponent[ClimateEntity] = hass.data[DOMAIN] name: str | None = None
entities: list[ClimateEntity] = list(component.entities) if "name" in slots:
climate_entity: ClimateEntity | None = None name = slots["name"]["value"]
climate_state: State | None = None
if not entities: area: str | None = None
raise intent.IntentHandleError("No climate entities") if "area" in slots:
area = slots["area"]["value"]
name_slot = slots.get("name", {}) match_constraints = intent.MatchTargetsConstraints(
entity_name: str | None = name_slot.get("value") name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant
entity_text: str | None = name_slot.get("text") )
match_result = intent.async_match_targets(hass, match_constraints)
area_slot = slots.get("area", {}) if not match_result.is_match:
area_id = area_slot.get("value") raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
if area_id: )
# Filter by area and optionally name
area_name = area_slot.get("text")
for maybe_climate in intent.async_match_states(
hass, name=entity_name, area_name=area_id, domains=[DOMAIN]
):
climate_state = maybe_climate
break
if climate_state is None:
raise intent.NoStatesMatchedError(
reason=intent.MatchFailedReason.AREA,
name=entity_text or entity_name,
area=area_name or area_id,
floor=None,
domains={DOMAIN},
device_classes=None,
)
climate_entity = component.get_entity(climate_state.entity_id)
elif entity_name:
# Filter by name
for maybe_climate in intent.async_match_states(
hass, name=entity_name, domains=[DOMAIN]
):
climate_state = maybe_climate
break
if climate_state is None:
raise intent.NoStatesMatchedError(
reason=intent.MatchFailedReason.NAME,
name=entity_name,
area=None,
floor=None,
domains={DOMAIN},
device_classes=None,
)
climate_entity = component.get_entity(climate_state.entity_id)
else:
# First entity
climate_entity = entities[0]
climate_state = hass.states.get(climate_entity.entity_id)
assert climate_entity is not None
if climate_state is None:
raise intent.IntentHandleError(f"No state for {climate_entity.name}")
assert climate_state is not None
response = intent_obj.create_response() response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.QUERY_ANSWER response.response_type = intent.IntentResponseType.QUERY_ANSWER
response.async_set_states(matched_states=[climate_state]) response.async_set_states(matched_states=match_result.states)
return response return response

View File

@ -35,7 +35,7 @@ class HumidityHandler(intent.IntentHandler):
intent_type = INTENT_HUMIDITY intent_type = INTENT_HUMIDITY
description = "Set desired humidity level" description = "Set desired humidity level"
slot_schema = { slot_schema = {
vol.Required("name"): cv.string, vol.Required("name"): intent.non_empty_string,
vol.Required("humidity"): vol.All(vol.Coerce(int), vol.Range(0, 100)), vol.Required("humidity"): vol.All(vol.Coerce(int), vol.Range(0, 100)),
} }
platforms = {DOMAIN} platforms = {DOMAIN}
@ -44,18 +44,19 @@ class HumidityHandler(intent.IntentHandler):
"""Handle the hass intent.""" """Handle the hass intent."""
hass = intent_obj.hass hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
states = list(
intent.async_match_states( match_constraints = intent.MatchTargetsConstraints(
hass, name=slots["name"]["value"],
name=slots["name"]["value"], domains=[DOMAIN],
states=hass.states.async_all(DOMAIN), assistant=intent_obj.assistant,
)
) )
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
if not states: state = match_result.states[0]
raise intent.IntentHandleError("No entities matched")
state = states[0]
service_data = {ATTR_ENTITY_ID: state.entity_id} service_data = {ATTR_ENTITY_ID: state.entity_id}
humidity = slots["humidity"]["value"] humidity = slots["humidity"]["value"]
@ -89,7 +90,7 @@ class SetModeHandler(intent.IntentHandler):
intent_type = INTENT_MODE intent_type = INTENT_MODE
description = "Set humidifier mode" description = "Set humidifier mode"
slot_schema = { slot_schema = {
vol.Required("name"): cv.string, vol.Required("name"): intent.non_empty_string,
vol.Required("mode"): cv.string, vol.Required("mode"): cv.string,
} }
platforms = {DOMAIN} platforms = {DOMAIN}
@ -98,18 +99,18 @@ class SetModeHandler(intent.IntentHandler):
"""Handle the hass intent.""" """Handle the hass intent."""
hass = intent_obj.hass hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
states = list( match_constraints = intent.MatchTargetsConstraints(
intent.async_match_states( name=slots["name"]["value"],
hass, domains=[DOMAIN],
name=slots["name"]["value"], assistant=intent_obj.assistant,
states=hass.states.async_all(DOMAIN),
)
) )
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
if not states: state = match_result.states[0]
raise intent.IntentHandleError("No entities matched")
state = states[0]
service_data = {ATTR_ENTITY_ID: state.entity_id} service_data = {ATTR_ENTITY_ID: state.entity_id}
intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes") intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes")

View File

@ -4,7 +4,6 @@ from __future__ import annotations
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent from homeassistant.helpers import intent
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from . import DOMAIN, TodoItem, TodoItemStatus, TodoListEntity from . import DOMAIN, TodoItem, TodoItemStatus, TodoListEntity
@ -22,7 +21,7 @@ class ListAddItemIntent(intent.IntentHandler):
intent_type = INTENT_LIST_ADD_ITEM intent_type = INTENT_LIST_ADD_ITEM
description = "Add item to a todo list" description = "Add item to a todo list"
slot_schema = {"item": cv.string, "name": cv.string} slot_schema = {"item": intent.non_empty_string, "name": intent.non_empty_string}
platforms = {DOMAIN} platforms = {DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@ -37,18 +36,19 @@ class ListAddItemIntent(intent.IntentHandler):
target_list: TodoListEntity | None = None target_list: TodoListEntity | None = None
# Find matching list # Find matching list
for list_state in intent.async_match_states( match_constraints = intent.MatchTargetsConstraints(
hass, name=list_name, domains=[DOMAIN] name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant
): )
target_list = component.get_entity(list_state.entity_id) match_result = intent.async_match_targets(hass, match_constraints)
if target_list is not None: if not match_result.is_match:
break raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
target_list = component.get_entity(match_result.states[0].entity_id)
if target_list is None: if target_list is None:
raise intent.IntentHandleError(f"No to-do list: {list_name}") raise intent.IntentHandleError(f"No to-do list: {list_name}")
assert target_list is not None
# Add to list # Add to list
await target_list.async_create_todo_item( await target_list.async_create_todo_item(
TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION)

View File

@ -6,10 +6,8 @@ import voluptuous as vol
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import intent from homeassistant.helpers import intent
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from . import DOMAIN, WeatherEntity from . import DOMAIN
INTENT_GET_WEATHER = "HassGetWeather" INTENT_GET_WEATHER = "HassGetWeather"
@ -24,7 +22,7 @@ class GetWeatherIntent(intent.IntentHandler):
intent_type = INTENT_GET_WEATHER intent_type = INTENT_GET_WEATHER
description = "Gets the current weather" description = "Gets the current weather"
slot_schema = {vol.Optional("name"): cv.string} slot_schema = {vol.Optional("name"): intent.non_empty_string}
platforms = {DOMAIN} platforms = {DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@ -32,43 +30,21 @@ class GetWeatherIntent(intent.IntentHandler):
hass = intent_obj.hass hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
weather: WeatherEntity | None = None
weather_state: State | None = None weather_state: State | None = None
component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] name: str | None = None
entities = list(component.entities)
if "name" in slots: if "name" in slots:
# Named weather entity name = slots["name"]["value"]
weather_name = slots["name"]["value"]
# Find matching weather entity match_constraints = intent.MatchTargetsConstraints(
matching_states = intent.async_match_states( name=name, domains=[DOMAIN], assistant=intent_obj.assistant
hass, name=weather_name, domains=[DOMAIN] )
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
) )
for maybe_weather_state in matching_states:
weather = component.get_entity(maybe_weather_state.entity_id)
if weather is not None:
weather_state = maybe_weather_state
break
if weather is None: weather_state = match_result.states[0]
raise intent.IntentHandleError(
f"No weather entity named {weather_name}"
)
elif entities:
# First weather entity
weather = entities[0]
weather_name = weather.name
weather_state = hass.states.get(weather.entity_id)
if weather is None:
raise intent.IntentHandleError("No weather entity")
if weather_state is None:
raise intent.IntentHandleError(f"No state for weather: {weather.name}")
assert weather is not None
assert weather_state is not None
# Create response # Create response
response = intent_obj.create_response() response = intent_obj.create_response()
@ -77,8 +53,8 @@ class GetWeatherIntent(intent.IntentHandler):
success_results=[ success_results=[
intent.IntentResponseTarget( intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY, type=intent.IntentResponseTargetType.ENTITY,
name=weather_name, name=weather_state.name,
id=weather.entity_id, id=weather_state.entity_id,
) )
] ]
) )

View File

@ -712,6 +712,7 @@ def async_match_states(
domains: Collection[str] | None = None, domains: Collection[str] | None = None,
device_classes: Collection[str] | None = None, device_classes: Collection[str] | None = None,
states: list[State] | None = None, states: list[State] | None = None,
assistant: str | None = None,
) -> Iterable[State]: ) -> Iterable[State]:
"""Simplified interface to async_match_targets that returns states matching the constraints.""" """Simplified interface to async_match_targets that returns states matching the constraints."""
result = async_match_targets( result = async_match_targets(
@ -722,6 +723,7 @@ def async_match_states(
floor_name=floor_name, floor_name=floor_name,
domains=domains, domains=domains,
device_classes=device_classes, device_classes=device_classes,
assistant=assistant,
), ),
states=states, states=states,
) )

View File

@ -1,21 +1,23 @@
"""Test climate intents.""" """Test climate intents."""
from collections.abc import Generator from collections.abc import Generator
from unittest.mock import patch
import pytest import pytest
from homeassistant.components import conversation
from homeassistant.components.climate import ( from homeassistant.components.climate import (
DOMAIN, DOMAIN,
ClimateEntity, ClimateEntity,
HVACMode, HVACMode,
intent as climate_intent, intent as climate_intent,
) )
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import Platform, UnitOfTemperature from homeassistant.const import Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar, entity_registry as er, intent from homeassistant.helpers import area_registry as ar, entity_registry as er, intent
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.setup import async_setup_component
from tests.common import ( from tests.common import (
MockConfigEntry, MockConfigEntry,
@ -113,6 +115,7 @@ async def test_get_temperature(
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test HassClimateGetTemperature intent.""" """Test HassClimateGetTemperature intent."""
assert await async_setup_component(hass, "homeassistant", {})
await climate_intent.async_setup_intents(hass) await climate_intent.async_setup_intents(hass)
climate_1 = MockClimateEntity() climate_1 = MockClimateEntity()
@ -148,10 +151,14 @@ async def test_get_temperature(
# First climate entity will be selected (no area) # First climate entity will be selected (no area)
response = await intent.async_handle( response = await intent.async_handle(
hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
) )
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1 assert response.matched_states
assert response.matched_states[0].entity_id == climate_1.entity_id assert response.matched_states[0].entity_id == climate_1.entity_id
state = response.matched_states[0] state = response.matched_states[0]
assert state.attributes["current_temperature"] == 10.0 assert state.attributes["current_temperature"] == 10.0
@ -162,6 +169,7 @@ async def test_get_temperature(
"test", "test",
climate_intent.INTENT_GET_TEMPERATURE, climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": bedroom_area.name}}, {"area": {"value": bedroom_area.name}},
assistant=conversation.DOMAIN,
) )
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1 assert len(response.matched_states) == 1
@ -175,6 +183,7 @@ async def test_get_temperature(
"test", "test",
climate_intent.INTENT_GET_TEMPERATURE, climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": "Climate 2"}}, {"name": {"value": "Climate 2"}},
assistant=conversation.DOMAIN,
) )
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1 assert len(response.matched_states) == 1
@ -189,6 +198,7 @@ async def test_get_temperature(
"test", "test",
climate_intent.INTENT_GET_TEMPERATURE, climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": office_area.name}}, {"area": {"value": office_area.name}},
assistant=conversation.DOMAIN,
) )
# Exception should contain details of what we tried to match # Exception should contain details of what we tried to match
@ -197,7 +207,7 @@ async def test_get_temperature(
constraints = error.value.constraints constraints = error.value.constraints
assert constraints.name is None assert constraints.name is None
assert constraints.area_name == office_area.name assert constraints.area_name == office_area.name
assert constraints.domains == {DOMAIN} assert constraints.domains and (set(constraints.domains) == {DOMAIN})
assert constraints.device_classes is None assert constraints.device_classes is None
# Check wrong name # Check wrong name
@ -214,7 +224,7 @@ async def test_get_temperature(
constraints = error.value.constraints constraints = error.value.constraints
assert constraints.name == "Does not exist" assert constraints.name == "Does not exist"
assert constraints.area_name is None assert constraints.area_name is None
assert constraints.domains == {DOMAIN} assert constraints.domains and (set(constraints.domains) == {DOMAIN})
assert constraints.device_classes is None assert constraints.device_classes is None
# Check wrong name with area # Check wrong name with area
@ -231,7 +241,7 @@ async def test_get_temperature(
constraints = error.value.constraints constraints = error.value.constraints
assert constraints.name == "Climate 1" assert constraints.name == "Climate 1"
assert constraints.area_name == bedroom_area.name assert constraints.area_name == bedroom_area.name
assert constraints.domains == {DOMAIN} assert constraints.domains and (set(constraints.domains) == {DOMAIN})
assert constraints.device_classes is None assert constraints.device_classes is None
@ -239,62 +249,190 @@ async def test_get_temperature_no_entities(
hass: HomeAssistant, hass: HomeAssistant,
) -> None: ) -> None:
"""Test HassClimateGetTemperature intent with no climate entities.""" """Test HassClimateGetTemperature intent with no climate entities."""
assert await async_setup_component(hass, "homeassistant", {})
await climate_intent.async_setup_intents(hass) await climate_intent.async_setup_intents(hass)
await create_mock_platform(hass, []) await create_mock_platform(hass, [])
with pytest.raises(intent.IntentHandleError): with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle( await intent.async_handle(
hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
) )
assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN
async def test_get_temperature_no_state( async def test_not_exposed(
hass: HomeAssistant, hass: HomeAssistant,
area_registry: ar.AreaRegistry, area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test HassClimateGetTemperature intent when states are missing.""" """Test HassClimateGetTemperature intent when entities aren't exposed."""
assert await async_setup_component(hass, "homeassistant", {})
await climate_intent.async_setup_intents(hass) await climate_intent.async_setup_intents(hass)
climate_1 = MockClimateEntity() climate_1 = MockClimateEntity()
climate_1._attr_name = "Climate 1" climate_1._attr_name = "Climate 1"
climate_1._attr_unique_id = "1234" climate_1._attr_unique_id = "1234"
climate_1._attr_current_temperature = 10.0
entity_registry.async_get_or_create( entity_registry.async_get_or_create(
DOMAIN, "test", "1234", suggested_object_id="climate_1" DOMAIN, "test", "1234", suggested_object_id="climate_1"
) )
await create_mock_platform(hass, [climate_1]) climate_2 = MockClimateEntity()
climate_2._attr_name = "Climate 2"
climate_2._attr_unique_id = "5678"
climate_2._attr_current_temperature = 22.0
entity_registry.async_get_or_create(
DOMAIN, "test", "5678", suggested_object_id="climate_2"
)
await create_mock_platform(hass, [climate_1, climate_2])
# Add climate entities to same area
living_room_area = area_registry.async_create(name="Living Room") living_room_area = area_registry.async_create(name="Living Room")
bedroom_area = area_registry.async_create(name="Bedroom")
entity_registry.async_update_entity( entity_registry.async_update_entity(
climate_1.entity_id, area_id=living_room_area.id climate_1.entity_id, area_id=living_room_area.id
) )
entity_registry.async_update_entity(
climate_2.entity_id, area_id=living_room_area.id
)
with ( # Should fail with empty name
patch("homeassistant.core.StateMachine.get", return_value=None), with pytest.raises(intent.InvalidSlotInfo):
pytest.raises(intent.IntentHandleError),
):
await intent.async_handle(
hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {}
)
with (
patch("homeassistant.core.StateMachine.async_all", return_value=[]),
pytest.raises(intent.MatchFailedError) as error,
):
await intent.async_handle( await intent.async_handle(
hass, hass,
"test", "test",
climate_intent.INTENT_GET_TEMPERATURE, climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": "Living Room"}}, {"name": {"value": ""}},
assistant=conversation.DOMAIN,
) )
# Exception should contain details of what we tried to match # Should fail with empty area
assert isinstance(error.value, intent.MatchFailedError) with pytest.raises(intent.InvalidSlotInfo):
assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA await intent.async_handle(
constraints = error.value.constraints hass,
assert constraints.name is None "test",
assert constraints.area_name == "Living Room" climate_intent.INTENT_GET_TEMPERATURE,
assert constraints.domains == {DOMAIN} {"area": {"value": ""}},
assert constraints.device_classes is None assistant=conversation.DOMAIN,
)
# Expose second, hide first
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False)
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True)
# Second climate entity is exposed
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
# Using the area should work
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": living_room_area.name}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
# Using the name of the exposed entity should work
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": climate_2.name}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
# Using the name of the *unexposed* entity should fail
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": climate_1.name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.NAME
# Expose first, hide second
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True)
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False)
# Second climate entity is exposed
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_1.entity_id
# Wrong area name
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": bedroom_area.name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA
# Neither are exposed
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False)
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False)
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
# Should fail with area
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": living_room_area.name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
# Should fail with both names
for name in (climate_1.name, climate_2.name):
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT

View File

@ -2,6 +2,8 @@
import pytest import pytest
from homeassistant.components import conversation
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.components.humidifier import ( from homeassistant.components.humidifier import (
ATTR_AVAILABLE_MODES, ATTR_AVAILABLE_MODES,
ATTR_HUMIDITY, ATTR_HUMIDITY,
@ -19,13 +21,22 @@ from homeassistant.const import (
STATE_ON, STATE_ON,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.intent import IntentHandleError, async_handle from homeassistant.helpers.intent import (
IntentHandleError,
IntentResponseType,
InvalidSlotInfo,
MatchFailedError,
MatchFailedReason,
async_handle,
)
from homeassistant.setup import async_setup_component
from tests.common import async_mock_service from tests.common import async_mock_service
async def test_intent_set_humidity(hass: HomeAssistant) -> None: async def test_intent_set_humidity(hass: HomeAssistant) -> None:
"""Test the set humidity intent.""" """Test the set humidity intent."""
assert await async_setup_component(hass, "homeassistant", {})
hass.states.async_set( hass.states.async_set(
"humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40}
) )
@ -38,6 +49,7 @@ async def test_intent_set_humidity(hass: HomeAssistant) -> None:
"test", "test",
intent.INTENT_HUMIDITY, intent.INTENT_HUMIDITY,
{"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}},
assistant=conversation.DOMAIN,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -54,6 +66,7 @@ async def test_intent_set_humidity(hass: HomeAssistant) -> None:
async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None: async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None:
"""Test the set humidity intent for turned off humidifier.""" """Test the set humidity intent for turned off humidifier."""
assert await async_setup_component(hass, "homeassistant", {})
hass.states.async_set( hass.states.async_set(
"humidifier.bedroom_humidifier", STATE_OFF, {ATTR_HUMIDITY: 40} "humidifier.bedroom_humidifier", STATE_OFF, {ATTR_HUMIDITY: 40}
) )
@ -66,6 +79,7 @@ async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None:
"test", "test",
intent.INTENT_HUMIDITY, intent.INTENT_HUMIDITY,
{"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}},
assistant=conversation.DOMAIN,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -89,6 +103,7 @@ async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None:
async def test_intent_set_mode(hass: HomeAssistant) -> None: async def test_intent_set_mode(hass: HomeAssistant) -> None:
"""Test the set mode intent.""" """Test the set mode intent."""
assert await async_setup_component(hass, "homeassistant", {})
hass.states.async_set( hass.states.async_set(
"humidifier.bedroom_humidifier", "humidifier.bedroom_humidifier",
STATE_ON, STATE_ON,
@ -108,6 +123,7 @@ async def test_intent_set_mode(hass: HomeAssistant) -> None:
"test", "test",
intent.INTENT_MODE, intent.INTENT_MODE,
{"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}},
assistant=conversation.DOMAIN,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -127,6 +143,7 @@ async def test_intent_set_mode(hass: HomeAssistant) -> None:
async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None: async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None:
"""Test the set mode intent.""" """Test the set mode intent."""
assert await async_setup_component(hass, "homeassistant", {})
hass.states.async_set( hass.states.async_set(
"humidifier.bedroom_humidifier", "humidifier.bedroom_humidifier",
STATE_OFF, STATE_OFF,
@ -146,6 +163,7 @@ async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None:
"test", "test",
intent.INTENT_MODE, intent.INTENT_MODE,
{"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}},
assistant=conversation.DOMAIN,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -169,6 +187,7 @@ async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None:
async def test_intent_set_mode_tests_feature(hass: HomeAssistant) -> None: async def test_intent_set_mode_tests_feature(hass: HomeAssistant) -> None:
"""Test the set mode intent where modes are not supported.""" """Test the set mode intent where modes are not supported."""
assert await async_setup_component(hass, "homeassistant", {})
hass.states.async_set( hass.states.async_set(
"humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40}
) )
@ -181,6 +200,7 @@ async def test_intent_set_mode_tests_feature(hass: HomeAssistant) -> None:
"test", "test",
intent.INTENT_MODE, intent.INTENT_MODE,
{"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}},
assistant=conversation.DOMAIN,
) )
assert str(excinfo.value) == "Entity bedroom humidifier does not support modes" assert str(excinfo.value) == "Entity bedroom humidifier does not support modes"
assert len(mode_calls) == 0 assert len(mode_calls) == 0
@ -191,6 +211,7 @@ async def test_intent_set_unknown_mode(
hass: HomeAssistant, available_modes: list[str] | None hass: HomeAssistant, available_modes: list[str] | None
) -> None: ) -> None:
"""Test the set mode intent for unsupported mode.""" """Test the set mode intent for unsupported mode."""
assert await async_setup_component(hass, "homeassistant", {})
hass.states.async_set( hass.states.async_set(
"humidifier.bedroom_humidifier", "humidifier.bedroom_humidifier",
STATE_ON, STATE_ON,
@ -210,6 +231,111 @@ async def test_intent_set_unknown_mode(
"test", "test",
intent.INTENT_MODE, intent.INTENT_MODE,
{"name": {"value": "Bedroom humidifier"}, "mode": {"value": "eco"}}, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "eco"}},
assistant=conversation.DOMAIN,
) )
assert str(excinfo.value) == "Entity bedroom humidifier does not support eco mode" assert str(excinfo.value) == "Entity bedroom humidifier does not support eco mode"
assert len(mode_calls) == 0 assert len(mode_calls) == 0
async def test_intent_errors(hass: HomeAssistant) -> None:
"""Test the error conditions for set humidity and set mode intents."""
assert await async_setup_component(hass, "homeassistant", {})
entity_id = "humidifier.bedroom_humidifier"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_HUMIDITY: 40,
ATTR_SUPPORTED_FEATURES: 1,
ATTR_AVAILABLE_MODES: ["home", "away"],
ATTR_MODE: None,
},
)
async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY)
async_mock_service(hass, DOMAIN, SERVICE_SET_MODE)
await intent.async_setup_intents(hass)
# Humidifiers are exposed by default
result = await async_handle(
hass,
"test",
intent.INTENT_HUMIDITY,
{"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}},
assistant=conversation.DOMAIN,
)
assert result.response_type == IntentResponseType.ACTION_DONE
result = await async_handle(
hass,
"test",
intent.INTENT_MODE,
{"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}},
assistant=conversation.DOMAIN,
)
assert result.response_type == IntentResponseType.ACTION_DONE
# Unexposing it should fail
async_expose_entity(hass, conversation.DOMAIN, entity_id, False)
with pytest.raises(MatchFailedError) as err:
await async_handle(
hass,
"test",
intent.INTENT_HUMIDITY,
{"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == MatchFailedReason.ASSISTANT
with pytest.raises(MatchFailedError) as err:
await async_handle(
hass,
"test",
intent.INTENT_MODE,
{"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == MatchFailedReason.ASSISTANT
# Expose again to test other errors
async_expose_entity(hass, conversation.DOMAIN, entity_id, True)
# Empty name should fail
with pytest.raises(InvalidSlotInfo):
await async_handle(
hass,
"test",
intent.INTENT_HUMIDITY,
{"name": {"value": ""}, "humidity": {"value": "50"}},
assistant=conversation.DOMAIN,
)
with pytest.raises(InvalidSlotInfo):
await async_handle(
hass,
"test",
intent.INTENT_MODE,
{"name": {"value": ""}, "mode": {"value": "away"}},
assistant=conversation.DOMAIN,
)
# Wrong name should fail
with pytest.raises(MatchFailedError) as err:
await async_handle(
hass,
"test",
intent.INTENT_HUMIDITY,
{"name": {"value": "does not exist"}, "humidity": {"value": "50"}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == MatchFailedReason.NAME
with pytest.raises(MatchFailedError) as err:
await async_handle(
hass,
"test",
intent.INTENT_MODE,
{"name": {"value": "does not exist"}, "mode": {"value": "away"}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == MatchFailedReason.NAME

View File

@ -9,6 +9,8 @@ import zoneinfo
import pytest import pytest
import voluptuous as vol import voluptuous as vol
from homeassistant.components import conversation
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.components.todo import ( from homeassistant.components.todo import (
DOMAIN, DOMAIN,
TodoItem, TodoItem,
@ -23,6 +25,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import intent from homeassistant.helpers import intent
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.setup import async_setup_component
from tests.common import ( from tests.common import (
MockConfigEntry, MockConfigEntry,
@ -1110,6 +1113,7 @@ async def test_add_item_intent(
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
) -> None: ) -> None:
"""Test adding items to lists using an intent.""" """Test adding items to lists using an intent."""
assert await async_setup_component(hass, "homeassistant", {})
await todo_intent.async_setup_intents(hass) await todo_intent.async_setup_intents(hass)
entity1 = MockTodoListEntity() entity1 = MockTodoListEntity()
@ -1128,6 +1132,7 @@ async def test_add_item_intent(
"test", "test",
todo_intent.INTENT_LIST_ADD_ITEM, todo_intent.INTENT_LIST_ADD_ITEM,
{"item": {"value": "beer"}, "name": {"value": "list 1"}}, {"item": {"value": "beer"}, "name": {"value": "list 1"}},
assistant=conversation.DOMAIN,
) )
assert response.response_type == intent.IntentResponseType.ACTION_DONE assert response.response_type == intent.IntentResponseType.ACTION_DONE
@ -1143,6 +1148,7 @@ async def test_add_item_intent(
"test", "test",
todo_intent.INTENT_LIST_ADD_ITEM, todo_intent.INTENT_LIST_ADD_ITEM,
{"item": {"value": "cheese"}, "name": {"value": "List 2"}}, {"item": {"value": "cheese"}, "name": {"value": "List 2"}},
assistant=conversation.DOMAIN,
) )
assert response.response_type == intent.IntentResponseType.ACTION_DONE assert response.response_type == intent.IntentResponseType.ACTION_DONE
@ -1157,6 +1163,7 @@ async def test_add_item_intent(
"test", "test",
todo_intent.INTENT_LIST_ADD_ITEM, todo_intent.INTENT_LIST_ADD_ITEM,
{"item": {"value": "wine"}, "name": {"value": "lIST 2"}}, {"item": {"value": "wine"}, "name": {"value": "lIST 2"}},
assistant=conversation.DOMAIN,
) )
assert response.response_type == intent.IntentResponseType.ACTION_DONE assert response.response_type == intent.IntentResponseType.ACTION_DONE
@ -1165,13 +1172,46 @@ async def test_add_item_intent(
assert entity2.items[1].summary == "wine" assert entity2.items[1].summary == "wine"
assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION
# Should fail if lists are not exposed
async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False)
async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False)
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
todo_intent.INTENT_LIST_ADD_ITEM,
{"item": {"value": "cookies"}, "name": {"value": "list 1"}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
# Missing list # Missing list
with pytest.raises(intent.IntentHandleError): with pytest.raises(intent.MatchFailedError):
await intent.async_handle( await intent.async_handle(
hass, hass,
"test", "test",
todo_intent.INTENT_LIST_ADD_ITEM, todo_intent.INTENT_LIST_ADD_ITEM,
{"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}},
assistant=conversation.DOMAIN,
)
# Fail with empty name/item
with pytest.raises(intent.InvalidSlotInfo):
await intent.async_handle(
hass,
"test",
todo_intent.INTENT_LIST_ADD_ITEM,
{"item": {"value": "wine"}, "name": {"value": ""}},
assistant=conversation.DOMAIN,
)
with pytest.raises(intent.InvalidSlotInfo):
await intent.async_handle(
hass,
"test",
todo_intent.INTENT_LIST_ADD_ITEM,
{"item": {"value": ""}, "name": {"value": "list 1"}},
assistant=conversation.DOMAIN,
) )

View File

@ -1,9 +1,9 @@
"""Test weather intents.""" """Test weather intents."""
from unittest.mock import patch
import pytest import pytest
from homeassistant.components import conversation
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.components.weather import ( from homeassistant.components.weather import (
DOMAIN, DOMAIN,
WeatherEntity, WeatherEntity,
@ -16,15 +16,18 @@ from homeassistant.setup import async_setup_component
async def test_get_weather(hass: HomeAssistant) -> None: async def test_get_weather(hass: HomeAssistant) -> None:
"""Test get weather for first entity and by name.""" """Test get weather for first entity and by name."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "weather", {"weather": {}}) assert await async_setup_component(hass, "weather", {"weather": {}})
entity1 = WeatherEntity() entity1 = WeatherEntity()
entity1._attr_name = "Weather 1" entity1._attr_name = "Weather 1"
entity1.entity_id = "weather.test_1" entity1.entity_id = "weather.test_1"
async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, True)
entity2 = WeatherEntity() entity2 = WeatherEntity()
entity2._attr_name = "Weather 2" entity2._attr_name = "Weather 2"
entity2.entity_id = "weather.test_2" entity2.entity_id = "weather.test_2"
async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, True)
await hass.data[DOMAIN].async_add_entities([entity1, entity2]) await hass.data[DOMAIN].async_add_entities([entity1, entity2])
@ -45,15 +48,31 @@ async def test_get_weather(hass: HomeAssistant) -> None:
"test", "test",
weather_intent.INTENT_GET_WEATHER, weather_intent.INTENT_GET_WEATHER,
{"name": {"value": "Weather 2"}}, {"name": {"value": "Weather 2"}},
assistant=conversation.DOMAIN,
) )
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1 assert len(response.matched_states) == 1
state = response.matched_states[0] state = response.matched_states[0]
assert state.entity_id == entity2.entity_id assert state.entity_id == entity2.entity_id
# Should fail if not exposed
async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False)
async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False)
for name in (entity1.name, entity2.name):
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
weather_intent.INTENT_GET_WEATHER,
{"name": {"value": name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
async def test_get_weather_wrong_name(hass: HomeAssistant) -> None: async def test_get_weather_wrong_name(hass: HomeAssistant) -> None:
"""Test get weather with the wrong name.""" """Test get weather with the wrong name."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "weather", {"weather": {}}) assert await async_setup_component(hass, "weather", {"weather": {}})
entity1 = WeatherEntity() entity1 = WeatherEntity()
@ -63,48 +82,43 @@ async def test_get_weather_wrong_name(hass: HomeAssistant) -> None:
await hass.data[DOMAIN].async_add_entities([entity1]) await hass.data[DOMAIN].async_add_entities([entity1])
await weather_intent.async_setup_intents(hass) await weather_intent.async_setup_intents(hass)
async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, True)
# Incorrect name # Incorrect name
with pytest.raises(intent.IntentHandleError): with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle( await intent.async_handle(
hass, hass,
"test", "test",
weather_intent.INTENT_GET_WEATHER, weather_intent.INTENT_GET_WEATHER,
{"name": {"value": "not the right name"}}, {"name": {"value": "not the right name"}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.NAME
# Empty name
with pytest.raises(intent.InvalidSlotInfo):
await intent.async_handle(
hass,
"test",
weather_intent.INTENT_GET_WEATHER,
{"name": {"value": ""}},
assistant=conversation.DOMAIN,
) )
async def test_get_weather_no_entities(hass: HomeAssistant) -> None: async def test_get_weather_no_entities(hass: HomeAssistant) -> None:
"""Test get weather with no weather entities.""" """Test get weather with no weather entities."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "weather", {"weather": {}}) assert await async_setup_component(hass, "weather", {"weather": {}})
await weather_intent.async_setup_intents(hass) await weather_intent.async_setup_intents(hass)
# No weather entities # No weather entities
with pytest.raises(intent.IntentHandleError): with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {}) await intent.async_handle(
hass,
"test",
async def test_get_weather_no_state(hass: HomeAssistant) -> None: weather_intent.INTENT_GET_WEATHER,
"""Test get weather when state is not returned.""" {},
assert await async_setup_component(hass, "weather", {"weather": {}}) assistant=conversation.DOMAIN,
)
entity1 = WeatherEntity() assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN
entity1._attr_name = "Weather 1"
entity1.entity_id = "weather.test_1"
await hass.data[DOMAIN].async_add_entities([entity1])
await weather_intent.async_setup_intents(hass)
# Success with state
response = await intent.async_handle(
hass, "test", weather_intent.INTENT_GET_WEATHER, {}
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
# Failure without state
with (
patch("homeassistant.core.StateMachine.get", return_value=None),
pytest.raises(intent.IntentHandleError),
):
await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {})