mirror of
https://github.com/home-assistant/core.git
synced 2025-12-24 21:06:19 +00:00
Intent target matching and media player enhancements (#115445)
* Working * Tests are passing * Fix climate * Requested changes from review
This commit is contained in:
@@ -6,9 +6,13 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.switch import SwitchDeviceClass
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME
|
||||
from homeassistant.components import conversation, light, switch
|
||||
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_FRIENDLY_NAME,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, State
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
@@ -20,13 +24,13 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_mock_service
|
||||
|
||||
|
||||
class MockIntentHandler(intent.IntentHandler):
|
||||
"""Provide a mock intent handler."""
|
||||
|
||||
def __init__(self, slot_schema):
|
||||
def __init__(self, slot_schema) -> None:
|
||||
"""Initialize the mock handler."""
|
||||
self.slot_schema = slot_schema
|
||||
|
||||
@@ -73,7 +77,7 @@ async def test_async_match_states(
|
||||
entity_registry.async_update_entity(
|
||||
state2.entity_id,
|
||||
area_id=area_bedroom.id,
|
||||
device_class=SwitchDeviceClass.OUTLET,
|
||||
device_class=switch.SwitchDeviceClass.OUTLET,
|
||||
aliases={"kill switch"},
|
||||
)
|
||||
|
||||
@@ -126,7 +130,7 @@ async def test_async_match_states(
|
||||
assert list(
|
||||
intent.async_match_states(
|
||||
hass,
|
||||
device_classes={SwitchDeviceClass.OUTLET},
|
||||
device_classes={switch.SwitchDeviceClass.OUTLET},
|
||||
area_name="bedroom",
|
||||
states=[state1, state2],
|
||||
)
|
||||
@@ -162,6 +166,346 @@ async def test_async_match_states(
|
||||
)
|
||||
|
||||
|
||||
async def test_async_match_targets(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
floor_registry: fr.FloorRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Tests for async_match_targets function."""
|
||||
# Needed for exposure
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
# House layout
|
||||
# Floor 1 (ground):
|
||||
# - Kitchen
|
||||
# - Outlet
|
||||
# - Bathroom
|
||||
# - Light
|
||||
# Floor 2 (upstairs)
|
||||
# - Bedroom
|
||||
# - Switch
|
||||
# - Bathroom
|
||||
# - Light
|
||||
# Floor 3 (also upstairs)
|
||||
# - Bedroom
|
||||
# - Switch
|
||||
# - Bathroom
|
||||
# - Light
|
||||
|
||||
# Floor 1
|
||||
floor_1 = floor_registry.async_create("first floor", aliases={"ground"})
|
||||
area_kitchen = area_registry.async_get_or_create("kitchen")
|
||||
area_kitchen = area_registry.async_update(
|
||||
area_kitchen.id, floor_id=floor_1.floor_id
|
||||
)
|
||||
area_bathroom_1 = area_registry.async_get_or_create("first floor bathroom")
|
||||
area_bathroom_1 = area_registry.async_update(
|
||||
area_bathroom_1.id, aliases={"bathroom"}, floor_id=floor_1.floor_id
|
||||
)
|
||||
|
||||
kitchen_outlet = entity_registry.async_get_or_create(
|
||||
"switch", "test", "kitchen_outlet"
|
||||
)
|
||||
kitchen_outlet = entity_registry.async_update_entity(
|
||||
kitchen_outlet.entity_id,
|
||||
name="kitchen outlet",
|
||||
device_class=switch.SwitchDeviceClass.OUTLET,
|
||||
area_id=area_kitchen.id,
|
||||
)
|
||||
state_kitchen_outlet = State(kitchen_outlet.entity_id, "on")
|
||||
|
||||
bathroom_light_1 = entity_registry.async_get_or_create(
|
||||
"light", "test", "bathroom_light_1"
|
||||
)
|
||||
bathroom_light_1 = entity_registry.async_update_entity(
|
||||
bathroom_light_1.entity_id,
|
||||
name="bathroom light",
|
||||
aliases={"overhead light"},
|
||||
area_id=area_bathroom_1.id,
|
||||
)
|
||||
state_bathroom_light_1 = State(bathroom_light_1.entity_id, "off")
|
||||
|
||||
# Floor 2
|
||||
floor_2 = floor_registry.async_create("second floor", aliases={"upstairs"})
|
||||
area_bedroom_2 = area_registry.async_get_or_create("bedroom")
|
||||
area_bedroom_2 = area_registry.async_update(
|
||||
area_bedroom_2.id, floor_id=floor_2.floor_id
|
||||
)
|
||||
area_bathroom_2 = area_registry.async_get_or_create("second floor bathroom")
|
||||
area_bathroom_2 = area_registry.async_update(
|
||||
area_bathroom_2.id, aliases={"bathroom"}, floor_id=floor_2.floor_id
|
||||
)
|
||||
|
||||
bedroom_switch_2 = entity_registry.async_get_or_create(
|
||||
"switch", "test", "bedroom_switch_2"
|
||||
)
|
||||
bedroom_switch_2 = entity_registry.async_update_entity(
|
||||
bedroom_switch_2.entity_id,
|
||||
name="second floor bedroom switch",
|
||||
area_id=area_bedroom_2.id,
|
||||
)
|
||||
state_bedroom_switch_2 = State(
|
||||
bedroom_switch_2.entity_id,
|
||||
"off",
|
||||
)
|
||||
|
||||
bathroom_light_2 = entity_registry.async_get_or_create(
|
||||
"light", "test", "bathroom_light_2"
|
||||
)
|
||||
bathroom_light_2 = entity_registry.async_update_entity(
|
||||
bathroom_light_2.entity_id,
|
||||
aliases={"bathroom light", "overhead light"},
|
||||
area_id=area_bathroom_2.id,
|
||||
supported_features=light.LightEntityFeature.EFFECT,
|
||||
)
|
||||
state_bathroom_light_2 = State(bathroom_light_2.entity_id, "off")
|
||||
|
||||
# Floor 3
|
||||
floor_3 = floor_registry.async_create("third floor", aliases={"upstairs"})
|
||||
area_bedroom_3 = area_registry.async_get_or_create("bedroom")
|
||||
area_bedroom_3 = area_registry.async_update(
|
||||
area_bedroom_3.id, floor_id=floor_3.floor_id
|
||||
)
|
||||
area_bathroom_3 = area_registry.async_get_or_create("third floor bathroom")
|
||||
area_bathroom_3 = area_registry.async_update(
|
||||
area_bathroom_3.id, aliases={"bathroom"}, floor_id=floor_3.floor_id
|
||||
)
|
||||
|
||||
bedroom_switch_3 = entity_registry.async_get_or_create(
|
||||
"switch", "test", "bedroom_switch_3"
|
||||
)
|
||||
bedroom_switch_3 = entity_registry.async_update_entity(
|
||||
bedroom_switch_3.entity_id,
|
||||
name="third floor bedroom switch",
|
||||
area_id=area_bedroom_3.id,
|
||||
)
|
||||
state_bedroom_switch_3 = State(
|
||||
bedroom_switch_3.entity_id,
|
||||
"off",
|
||||
attributes={ATTR_DEVICE_CLASS: switch.SwitchDeviceClass.OUTLET},
|
||||
)
|
||||
|
||||
bathroom_light_3 = entity_registry.async_get_or_create(
|
||||
"light", "test", "bathroom_light_3"
|
||||
)
|
||||
bathroom_light_3 = entity_registry.async_update_entity(
|
||||
bathroom_light_3.entity_id,
|
||||
name="overhead light",
|
||||
area_id=area_bathroom_3.id,
|
||||
)
|
||||
state_bathroom_light_3 = State(
|
||||
bathroom_light_3.entity_id,
|
||||
"on",
|
||||
attributes={
|
||||
ATTR_FRIENDLY_NAME: "bathroom light",
|
||||
ATTR_SUPPORTED_FEATURES: light.LightEntityFeature.EFFECT,
|
||||
},
|
||||
)
|
||||
|
||||
# -----
|
||||
bathroom_light_states = [
|
||||
state_bathroom_light_1,
|
||||
state_bathroom_light_2,
|
||||
state_bathroom_light_3,
|
||||
]
|
||||
states = [
|
||||
*bathroom_light_states,
|
||||
state_kitchen_outlet,
|
||||
state_bedroom_switch_2,
|
||||
state_bedroom_switch_3,
|
||||
]
|
||||
|
||||
# Not a unique name
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(name="bathroom light"),
|
||||
states=states,
|
||||
)
|
||||
assert not result.is_match
|
||||
assert result.no_match_reason == intent.MatchFailedReason.DUPLICATE_NAME
|
||||
assert result.no_match_name == "bathroom light"
|
||||
|
||||
# Works with duplicate names allowed
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(
|
||||
name="bathroom light", allow_duplicate_names=True
|
||||
),
|
||||
states=states,
|
||||
)
|
||||
assert result.is_match
|
||||
assert {s.entity_id for s in result.states} == {
|
||||
s.entity_id for s in bathroom_light_states
|
||||
}
|
||||
|
||||
# Also works when name is not a constraint
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(domains={"light"}),
|
||||
states=states,
|
||||
)
|
||||
assert result.is_match
|
||||
assert {s.entity_id for s in result.states} == {
|
||||
s.entity_id for s in bathroom_light_states
|
||||
}
|
||||
|
||||
# We can disambiguate by preferred floor (from context)
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(name="bathroom light"),
|
||||
intent.MatchTargetsPreferences(floor_id=floor_3.floor_id),
|
||||
states=states,
|
||||
)
|
||||
assert result.is_match
|
||||
assert len(result.states) == 1
|
||||
assert result.states[0].entity_id == bathroom_light_3.entity_id
|
||||
|
||||
# Also disambiguate by preferred area (from context)
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(name="bathroom light"),
|
||||
intent.MatchTargetsPreferences(area_id=area_bathroom_2.id),
|
||||
states=states,
|
||||
)
|
||||
assert result.is_match
|
||||
assert len(result.states) == 1
|
||||
assert result.states[0].entity_id == bathroom_light_2.entity_id
|
||||
|
||||
# Disambiguate by floor name, if unique
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(name="bathroom light", floor_name="ground"),
|
||||
states=states,
|
||||
)
|
||||
assert result.is_match
|
||||
assert len(result.states) == 1
|
||||
assert result.states[0].entity_id == bathroom_light_1.entity_id
|
||||
|
||||
# Doesn't work if floor name/alias is not unique
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(name="bathroom light", floor_name="upstairs"),
|
||||
states=states,
|
||||
)
|
||||
assert not result.is_match
|
||||
assert result.no_match_reason == intent.MatchFailedReason.DUPLICATE_NAME
|
||||
|
||||
# Disambiguate by area name, if unique
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(
|
||||
name="bathroom light", area_name="first floor bathroom"
|
||||
),
|
||||
states=states,
|
||||
)
|
||||
assert result.is_match
|
||||
assert len(result.states) == 1
|
||||
assert result.states[0].entity_id == bathroom_light_1.entity_id
|
||||
|
||||
# Doesn't work if area name/alias is not unique
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(name="bathroom light", area_name="bathroom"),
|
||||
states=states,
|
||||
)
|
||||
assert not result.is_match
|
||||
assert result.no_match_reason == intent.MatchFailedReason.DUPLICATE_NAME
|
||||
|
||||
# Does work if floor/area name combo is unique
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(
|
||||
name="bathroom light", area_name="bathroom", floor_name="ground"
|
||||
),
|
||||
states=states,
|
||||
)
|
||||
assert result.is_match
|
||||
assert len(result.states) == 1
|
||||
assert result.states[0].entity_id == bathroom_light_1.entity_id
|
||||
|
||||
# Doesn't work if area is not part of the floor
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(
|
||||
name="bathroom light",
|
||||
area_name="second floor bathroom",
|
||||
floor_name="ground",
|
||||
),
|
||||
states=states,
|
||||
)
|
||||
assert not result.is_match
|
||||
assert result.no_match_reason == intent.MatchFailedReason.AREA
|
||||
|
||||
# Check state constraint (only third floor bathroom light is on)
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(domains={"light"}, states={"on"}),
|
||||
states=states,
|
||||
)
|
||||
assert result.is_match
|
||||
assert len(result.states) == 1
|
||||
assert result.states[0].entity_id == bathroom_light_3.entity_id
|
||||
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(
|
||||
domains={"light"}, states={"on"}, floor_name="ground"
|
||||
),
|
||||
states=states,
|
||||
)
|
||||
assert not result.is_match
|
||||
|
||||
# Check assistant constraint (exposure)
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(assistant="test"),
|
||||
states=states,
|
||||
)
|
||||
assert not result.is_match
|
||||
|
||||
async_expose_entity(hass, "test", bathroom_light_1.entity_id, True)
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(assistant="test"),
|
||||
states=states,
|
||||
)
|
||||
assert result.is_match
|
||||
assert len(result.states) == 1
|
||||
assert result.states[0].entity_id == bathroom_light_1.entity_id
|
||||
|
||||
# Check device class constraint
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(
|
||||
domains={"switch"}, device_classes={switch.SwitchDeviceClass.OUTLET}
|
||||
),
|
||||
states=states,
|
||||
)
|
||||
assert result.is_match
|
||||
assert len(result.states) == 2
|
||||
assert {s.entity_id for s in result.states} == {
|
||||
kitchen_outlet.entity_id,
|
||||
bedroom_switch_3.entity_id,
|
||||
}
|
||||
|
||||
# Check features constraint (second and third floor bathroom lights have effects)
|
||||
result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(
|
||||
domains={"light"}, features=light.LightEntityFeature.EFFECT
|
||||
),
|
||||
states=states,
|
||||
)
|
||||
assert result.is_match
|
||||
assert len(result.states) == 2
|
||||
assert {s.entity_id for s in result.states} == {
|
||||
bathroom_light_2.entity_id,
|
||||
bathroom_light_3.entity_id,
|
||||
}
|
||||
|
||||
|
||||
async def test_match_device_area(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
@@ -353,24 +697,72 @@ async def test_validate_then_run_in_background(hass: HomeAssistant) -> None:
|
||||
|
||||
|
||||
async def test_invalid_area_floor_names(hass: HomeAssistant) -> None:
|
||||
"""Test that we throw an intent handle error with invalid area/floor names."""
|
||||
"""Test that we throw an appropriate errors with invalid area/floor names."""
|
||||
handler = intent.ServiceIntentHandler(
|
||||
"TestType", "light", "turn_on", "Turned {} on"
|
||||
)
|
||||
intent.async_register(hass, handler)
|
||||
|
||||
with pytest.raises(intent.IntentHandleError):
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
"TestType",
|
||||
slots={"area": {"value": "invalid area"}},
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.INVALID_AREA
|
||||
|
||||
with pytest.raises(intent.IntentHandleError):
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
"TestType",
|
||||
slots={"floor": {"value": "invalid floor"}},
|
||||
)
|
||||
assert (
|
||||
err.value.result.no_match_reason == intent.MatchFailedReason.INVALID_FLOOR
|
||||
)
|
||||
|
||||
|
||||
async def test_service_intent_handler_required_domains(hass: HomeAssistant) -> None:
|
||||
"""Test that required_domains restricts the domain of a ServiceIntentHandler."""
|
||||
hass.states.async_set("light.kitchen", "off")
|
||||
hass.states.async_set("switch.bedroom", "off")
|
||||
|
||||
calls = async_mock_service(hass, "homeassistant", "turn_on")
|
||||
handler = intent.ServiceIntentHandler(
|
||||
"TestType",
|
||||
"homeassistant",
|
||||
"turn_on",
|
||||
"Turned {} on",
|
||||
required_domains={"light"},
|
||||
)
|
||||
intent.async_register(hass, handler)
|
||||
|
||||
# Should work fine
|
||||
result = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
"TestType",
|
||||
slots={"name": {"value": "kitchen"}, "domain": {"value": "light"}},
|
||||
)
|
||||
assert result.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert len(calls) == 1
|
||||
|
||||
# Fails because the intent handler is restricted to lights only
|
||||
with pytest.raises(intent.MatchFailedError):
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
"TestType",
|
||||
slots={"name": {"value": "bedroom"}},
|
||||
)
|
||||
|
||||
# Still fails even if we provide the domain
|
||||
with pytest.raises(intent.MatchFailedError):
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
"TestType",
|
||||
slots={"name": {"value": "bedroom"}, "domain": {"value": "switch"}},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user