Add initial support for floors to intents (#114456)
* Add initial support for floors to intents * Fix climate intent * More tests * No return value * Add requested changes * Reuse event handlerpull/114505/head
parent
502231b7d2
commit
d23b22b566
|
@ -58,6 +58,7 @@ class GetTemperatureIntent(intent.IntentHandler):
|
|||
raise intent.NoStatesMatchedError(
|
||||
name=entity_text or entity_name,
|
||||
area=area_name or area_id,
|
||||
floor=None,
|
||||
domains={DOMAIN},
|
||||
device_classes=None,
|
||||
)
|
||||
|
@ -75,6 +76,7 @@ class GetTemperatureIntent(intent.IntentHandler):
|
|||
raise intent.NoStatesMatchedError(
|
||||
name=entity_name,
|
||||
area=None,
|
||||
floor=None,
|
||||
domains={DOMAIN},
|
||||
device_classes=None,
|
||||
)
|
||||
|
|
|
@ -34,6 +34,7 @@ from homeassistant.helpers import (
|
|||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
intent,
|
||||
start,
|
||||
template,
|
||||
|
@ -163,7 +164,12 @@ class DefaultAgent(AbstractConversationAgent):
|
|||
|
||||
self.hass.bus.async_listen(
|
||||
ar.EVENT_AREA_REGISTRY_UPDATED,
|
||||
self._async_handle_area_registry_changed,
|
||||
self._async_handle_area_floor_registry_changed,
|
||||
run_immediately=True,
|
||||
)
|
||||
self.hass.bus.async_listen(
|
||||
fr.EVENT_FLOOR_REGISTRY_UPDATED,
|
||||
self._async_handle_area_floor_registry_changed,
|
||||
run_immediately=True,
|
||||
)
|
||||
self.hass.bus.async_listen(
|
||||
|
@ -696,10 +702,13 @@ class DefaultAgent(AbstractConversationAgent):
|
|||
return lang_intents
|
||||
|
||||
@core.callback
|
||||
def _async_handle_area_registry_changed(
|
||||
self, event: core.Event[ar.EventAreaRegistryUpdatedData]
|
||||
def _async_handle_area_floor_registry_changed(
|
||||
self,
|
||||
event: core.Event[
|
||||
ar.EventAreaRegistryUpdatedData | fr.EventFloorRegistryUpdatedData
|
||||
],
|
||||
) -> None:
|
||||
"""Clear area area cache when the area registry has changed."""
|
||||
"""Clear area/floor list cache when the area registry has changed."""
|
||||
self._slot_lists = None
|
||||
|
||||
@core.callback
|
||||
|
@ -773,6 +782,8 @@ class DefaultAgent(AbstractConversationAgent):
|
|||
# Default name
|
||||
entity_names.append((state.name, state.name, context))
|
||||
|
||||
_LOGGER.debug("Exposed entities: %s", entity_names)
|
||||
|
||||
# Expose all areas.
|
||||
#
|
||||
# We pass in area id here with the expectation that no two areas will
|
||||
|
@ -788,11 +799,25 @@ class DefaultAgent(AbstractConversationAgent):
|
|||
|
||||
area_names.append((alias, area.id))
|
||||
|
||||
_LOGGER.debug("Exposed entities: %s", entity_names)
|
||||
# Expose all floors.
|
||||
#
|
||||
# We pass in floor id here with the expectation that no two floors will
|
||||
# share the same name or alias.
|
||||
floors = fr.async_get(self.hass)
|
||||
floor_names = []
|
||||
for floor in floors.async_list_floors():
|
||||
floor_names.append((floor.name, floor.floor_id))
|
||||
if floor.aliases:
|
||||
for alias in floor.aliases:
|
||||
if not alias.strip():
|
||||
continue
|
||||
|
||||
floor_names.append((alias, floor.floor_id))
|
||||
|
||||
self._slot_lists = {
|
||||
"area": TextSlotList.from_tuples(area_names, allow_template=False),
|
||||
"name": TextSlotList.from_tuples(entity_names, allow_template=False),
|
||||
"floor": TextSlotList.from_tuples(floor_names, allow_template=False),
|
||||
}
|
||||
|
||||
return self._slot_lists
|
||||
|
@ -953,6 +978,10 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str
|
|||
# area only
|
||||
return ErrorKey.NO_AREA, {"area": unmatched_area}
|
||||
|
||||
if unmatched_floor := unmatched_text.get("floor"):
|
||||
# floor only
|
||||
return ErrorKey.NO_FLOOR, {"floor": unmatched_floor}
|
||||
|
||||
# Area may still have matched
|
||||
matched_area: str | None = None
|
||||
if matched_area_entity := result.entities.get("area"):
|
||||
|
@ -1000,6 +1029,13 @@ def _get_no_states_matched_response(
|
|||
"area": no_states_error.area,
|
||||
}
|
||||
|
||||
if no_states_error.floor:
|
||||
# domain in floor
|
||||
return ErrorKey.NO_DOMAIN_IN_FLOOR, {
|
||||
"domain": domain,
|
||||
"floor": no_states_error.floor,
|
||||
}
|
||||
|
||||
# domain only
|
||||
return ErrorKey.NO_DOMAIN, {"domain": domain}
|
||||
|
||||
|
|
|
@ -7,5 +7,5 @@
|
|||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==1.6.1", "home-assistant-intents==2024.3.27"]
|
||||
"requirements": ["hassil==1.6.1", "home-assistant-intents==2024.3.29"]
|
||||
}
|
||||
|
|
|
@ -24,7 +24,13 @@ from homeassistant.core import Context, HomeAssistant, State, callback
|
|||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.loader import bind_hass
|
||||
|
||||
from . import area_registry, config_validation as cv, device_registry, entity_registry
|
||||
from . import (
|
||||
area_registry,
|
||||
config_validation as cv,
|
||||
device_registry,
|
||||
entity_registry,
|
||||
floor_registry,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_SlotsType = dict[str, Any]
|
||||
|
@ -144,16 +150,18 @@ class NoStatesMatchedError(IntentError):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None,
|
||||
area: str | None,
|
||||
domains: set[str] | None,
|
||||
device_classes: set[str] | None,
|
||||
name: str | None = None,
|
||||
area: str | None = None,
|
||||
floor: str | None = None,
|
||||
domains: set[str] | None = None,
|
||||
device_classes: set[str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize error."""
|
||||
super().__init__()
|
||||
|
||||
self.name = name
|
||||
self.area = area
|
||||
self.floor = floor
|
||||
self.domains = domains
|
||||
self.device_classes = device_classes
|
||||
|
||||
|
@ -220,12 +228,35 @@ def _find_area(
|
|||
return None
|
||||
|
||||
|
||||
def _filter_by_area(
|
||||
def _find_floor(
|
||||
id_or_name: str, floors: floor_registry.FloorRegistry
|
||||
) -> floor_registry.FloorEntry | None:
|
||||
"""Find an floor by id or name, checking aliases too."""
|
||||
floor = floors.async_get_floor(id_or_name) or floors.async_get_floor_by_name(
|
||||
id_or_name
|
||||
)
|
||||
if floor is not None:
|
||||
return floor
|
||||
|
||||
# Check floor aliases
|
||||
for maybe_floor in floors.floors.values():
|
||||
if not maybe_floor.aliases:
|
||||
continue
|
||||
|
||||
for floor_alias in maybe_floor.aliases:
|
||||
if id_or_name == floor_alias.casefold():
|
||||
return maybe_floor
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _filter_by_areas(
|
||||
states_and_entities: list[tuple[State, entity_registry.RegistryEntry | None]],
|
||||
area: area_registry.AreaEntry,
|
||||
areas: Iterable[area_registry.AreaEntry],
|
||||
devices: device_registry.DeviceRegistry,
|
||||
) -> Iterable[tuple[State, entity_registry.RegistryEntry | None]]:
|
||||
"""Filter state/entity pairs by an area."""
|
||||
filter_area_ids: set[str | None] = {a.id for a in areas}
|
||||
entity_area_ids: dict[str, str | None] = {}
|
||||
for _state, entity in states_and_entities:
|
||||
if entity is None:
|
||||
|
@ -241,7 +272,7 @@ def _filter_by_area(
|
|||
entity_area_ids[entity.id] = device.area_id
|
||||
|
||||
for state, entity in states_and_entities:
|
||||
if (entity is not None) and (entity_area_ids.get(entity.id) == area.id):
|
||||
if (entity is not None) and (entity_area_ids.get(entity.id) in filter_area_ids):
|
||||
yield (state, entity)
|
||||
|
||||
|
||||
|
@ -252,11 +283,14 @@ def async_match_states(
|
|||
name: str | None = None,
|
||||
area_name: str | None = None,
|
||||
area: area_registry.AreaEntry | None = None,
|
||||
floor_name: str | None = None,
|
||||
floor: floor_registry.FloorEntry | None = None,
|
||||
domains: Collection[str] | None = None,
|
||||
device_classes: Collection[str] | None = None,
|
||||
states: Iterable[State] | None = None,
|
||||
entities: entity_registry.EntityRegistry | None = None,
|
||||
areas: area_registry.AreaRegistry | None = None,
|
||||
floors: floor_registry.FloorRegistry | None = None,
|
||||
devices: device_registry.DeviceRegistry | None = None,
|
||||
assistant: str | None = None,
|
||||
) -> Iterable[State]:
|
||||
|
@ -268,6 +302,15 @@ def async_match_states(
|
|||
if entities is None:
|
||||
entities = entity_registry.async_get(hass)
|
||||
|
||||
if devices is None:
|
||||
devices = device_registry.async_get(hass)
|
||||
|
||||
if areas is None:
|
||||
areas = area_registry.async_get(hass)
|
||||
|
||||
if floors is None:
|
||||
floors = floor_registry.async_get(hass)
|
||||
|
||||
# Gather entities
|
||||
states_and_entities: list[tuple[State, entity_registry.RegistryEntry | None]] = []
|
||||
for state in states:
|
||||
|
@ -294,20 +337,35 @@ def async_match_states(
|
|||
if _is_device_class(state, entity, device_classes)
|
||||
]
|
||||
|
||||
filter_areas: list[area_registry.AreaEntry] = []
|
||||
|
||||
if (floor is None) and (floor_name is not None):
|
||||
# Look up floor by name
|
||||
floor = _find_floor(floor_name, floors)
|
||||
if floor is None:
|
||||
_LOGGER.warning("Floor not found: %s", floor_name)
|
||||
return
|
||||
|
||||
if floor is not None:
|
||||
filter_areas = [
|
||||
a for a in areas.async_list_areas() if a.floor_id == floor.floor_id
|
||||
]
|
||||
|
||||
if (area is None) and (area_name is not None):
|
||||
# Look up area by name
|
||||
if areas is None:
|
||||
areas = area_registry.async_get(hass)
|
||||
|
||||
area = _find_area(area_name, areas)
|
||||
assert area is not None, f"No area named {area_name}"
|
||||
if area is None:
|
||||
_LOGGER.warning("Area not found: %s", area_name)
|
||||
return
|
||||
|
||||
if area is not None:
|
||||
# Filter by states/entities by area
|
||||
if devices is None:
|
||||
devices = device_registry.async_get(hass)
|
||||
filter_areas = [area]
|
||||
|
||||
states_and_entities = list(_filter_by_area(states_and_entities, area, devices))
|
||||
if filter_areas:
|
||||
# Filter by states/entities by area
|
||||
states_and_entities = list(
|
||||
_filter_by_areas(states_and_entities, filter_areas, devices)
|
||||
)
|
||||
|
||||
if assistant is not None:
|
||||
# Filter by exposure
|
||||
|
@ -318,9 +376,6 @@ def async_match_states(
|
|||
]
|
||||
|
||||
if name is not None:
|
||||
if devices is None:
|
||||
devices = device_registry.async_get(hass)
|
||||
|
||||
# Filter by name
|
||||
name = name.casefold()
|
||||
|
||||
|
@ -389,7 +444,7 @@ class DynamicServiceIntentHandler(IntentHandler):
|
|||
"""
|
||||
|
||||
slot_schema = {
|
||||
vol.Any("name", "area"): cv.string,
|
||||
vol.Any("name", "area", "floor"): cv.string,
|
||||
vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
|
@ -453,7 +508,7 @@ class DynamicServiceIntentHandler(IntentHandler):
|
|||
# Don't match on name if targeting all entities
|
||||
entity_name = None
|
||||
|
||||
# Look up area first to fail early
|
||||
# Look up area to fail early
|
||||
area_slot = slots.get("area", {})
|
||||
area_id = area_slot.get("value")
|
||||
area_name = area_slot.get("text")
|
||||
|
@ -464,6 +519,17 @@ class DynamicServiceIntentHandler(IntentHandler):
|
|||
if area is None:
|
||||
raise IntentHandleError(f"No area named {area_name}")
|
||||
|
||||
# Look up floor to fail early
|
||||
floor_slot = slots.get("floor", {})
|
||||
floor_id = floor_slot.get("value")
|
||||
floor_name = floor_slot.get("text")
|
||||
floor: floor_registry.FloorEntry | None = None
|
||||
if floor_id is not None:
|
||||
floors = floor_registry.async_get(hass)
|
||||
floor = floors.async_get_floor(floor_id)
|
||||
if floor is None:
|
||||
raise IntentHandleError(f"No floor named {floor_name}")
|
||||
|
||||
# Optional domain/device class filters.
|
||||
# Convert to sets for speed.
|
||||
domains: set[str] | None = None
|
||||
|
@ -480,6 +546,7 @@ class DynamicServiceIntentHandler(IntentHandler):
|
|||
hass,
|
||||
name=entity_name,
|
||||
area=area,
|
||||
floor=floor,
|
||||
domains=domains,
|
||||
device_classes=device_classes,
|
||||
assistant=intent_obj.assistant,
|
||||
|
@ -491,6 +558,7 @@ class DynamicServiceIntentHandler(IntentHandler):
|
|||
raise NoStatesMatchedError(
|
||||
name=entity_text or entity_name,
|
||||
area=area_name or area_id,
|
||||
floor=floor_name or floor_id,
|
||||
domains=domains,
|
||||
device_classes=device_classes,
|
||||
)
|
||||
|
|
|
@ -31,7 +31,7 @@ hass-nabucasa==0.79.0
|
|||
hassil==1.6.1
|
||||
home-assistant-bluetooth==1.12.0
|
||||
home-assistant-frontend==20240329.1
|
||||
home-assistant-intents==2024.3.27
|
||||
home-assistant-intents==2024.3.29
|
||||
httpx==0.27.0
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.3
|
||||
|
|
|
@ -1084,7 +1084,7 @@ holidays==0.45
|
|||
home-assistant-frontend==20240329.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2024.3.27
|
||||
home-assistant-intents==2024.3.29
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.7.2
|
||||
|
|
|
@ -883,7 +883,7 @@ holidays==0.45
|
|||
home-assistant-frontend==20240329.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2024.3.27
|
||||
home-assistant-intents==2024.3.29
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.7.2
|
||||
|
|
|
@ -17,6 +17,7 @@ from homeassistant.helpers import (
|
|||
device_registry as dr,
|
||||
entity,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
intent,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
@ -480,6 +481,20 @@ async def test_error_no_area(hass: HomeAssistant, init_components) -> None:
|
|||
)
|
||||
|
||||
|
||||
async def test_error_no_floor(hass: HomeAssistant, init_components) -> None:
|
||||
"""Test error message when floor is missing."""
|
||||
result = await conversation.async_converse(
|
||||
hass, "turn on all the lights on missing floor", None, Context(), None
|
||||
)
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS
|
||||
assert (
|
||||
result.response.speech["plain"]["speech"]
|
||||
== "Sorry, I am not aware of any floor called missing"
|
||||
)
|
||||
|
||||
|
||||
async def test_error_no_device_in_area(
|
||||
hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry
|
||||
) -> None:
|
||||
|
@ -549,6 +564,48 @@ async def test_error_no_domain_in_area(
|
|||
)
|
||||
|
||||
|
||||
async def test_error_no_domain_in_floor(
|
||||
hass: HomeAssistant,
|
||||
init_components,
|
||||
area_registry: ar.AreaRegistry,
|
||||
floor_registry: fr.FloorRegistry,
|
||||
) -> None:
|
||||
"""Test error message when no devices/entities for a domain exist on a floor."""
|
||||
floor_ground = floor_registry.async_create("ground")
|
||||
area_kitchen = area_registry.async_get_or_create("kitchen_id")
|
||||
area_kitchen = area_registry.async_update(
|
||||
area_kitchen.id, name="kitchen", floor_id=floor_ground.floor_id
|
||||
)
|
||||
result = await conversation.async_converse(
|
||||
hass, "turn on all lights on the ground floor", None, Context(), None
|
||||
)
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS
|
||||
assert (
|
||||
result.response.speech["plain"]["speech"]
|
||||
== "Sorry, I am not aware of any light on the ground floor"
|
||||
)
|
||||
|
||||
# Add a new floor/area to trigger registry event handlers
|
||||
floor_upstairs = floor_registry.async_create("upstairs")
|
||||
area_bedroom = area_registry.async_get_or_create("bedroom_id")
|
||||
area_bedroom = area_registry.async_update(
|
||||
area_bedroom.id, name="bedroom", floor_id=floor_upstairs.floor_id
|
||||
)
|
||||
|
||||
result = await conversation.async_converse(
|
||||
hass, "turn on all lights upstairs", None, Context(), None
|
||||
)
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS
|
||||
assert (
|
||||
result.response.speech["plain"]["speech"]
|
||||
== "Sorry, I am not aware of any light on the upstairs floor"
|
||||
)
|
||||
|
||||
|
||||
async def test_error_no_device_class(hass: HomeAssistant, init_components) -> None:
|
||||
"""Test error message when no entities of a device class exist."""
|
||||
|
||||
|
@ -736,7 +793,7 @@ async def test_no_states_matched_default_error(
|
|||
|
||||
with patch(
|
||||
"homeassistant.components.conversation.default_agent.intent.async_handle",
|
||||
side_effect=intent.NoStatesMatchedError(None, None, None, None),
|
||||
side_effect=intent.NoStatesMatchedError(),
|
||||
):
|
||||
result = await conversation.async_converse(
|
||||
hass, "turn on lights in the kitchen", None, Context(), None
|
||||
|
@ -759,11 +816,16 @@ async def test_empty_aliases(
|
|||
area_registry: ar.AreaRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
floor_registry: fr.FloorRegistry,
|
||||
) -> None:
|
||||
"""Test that empty aliases are not added to slot lists."""
|
||||
floor_1 = floor_registry.async_create("first floor", aliases={" "})
|
||||
|
||||
area_kitchen = area_registry.async_get_or_create("kitchen_id")
|
||||
area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
|
||||
area_kitchen = area_registry.async_update(area_kitchen.id, aliases={" "})
|
||||
area_kitchen = area_registry.async_update(
|
||||
area_kitchen.id, aliases={" "}, floor_id=floor_1
|
||||
)
|
||||
|
||||
entry = MockConfigEntry()
|
||||
entry.add_to_hass(hass)
|
||||
|
@ -799,7 +861,7 @@ async def test_empty_aliases(
|
|||
slot_lists = mock_recognize_all.call_args[0][2]
|
||||
|
||||
# Slot lists should only contain non-empty text
|
||||
assert slot_lists.keys() == {"area", "name"}
|
||||
assert slot_lists.keys() == {"area", "name", "floor"}
|
||||
areas = slot_lists["area"]
|
||||
assert len(areas.values) == 1
|
||||
assert areas.values[0].value_out == area_kitchen.id
|
||||
|
@ -810,6 +872,11 @@ async def test_empty_aliases(
|
|||
assert names.values[0].value_out == kitchen_light.name
|
||||
assert names.values[0].text_in.text == kitchen_light.name
|
||||
|
||||
floors = slot_lists["floor"]
|
||||
assert len(floors.values) == 1
|
||||
assert floors.values[0].value_out == floor_1.floor_id
|
||||
assert floors.values[0].text_in.text == floor_1.name
|
||||
|
||||
|
||||
async def test_all_domains_loaded(hass: HomeAssistant, init_components) -> None:
|
||||
"""Test that sentences for all domains are always loaded."""
|
||||
|
|
|
@ -2,14 +2,26 @@
|
|||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import conversation, cover, media_player, vacuum, valve
|
||||
from homeassistant.components import (
|
||||
conversation,
|
||||
cover,
|
||||
light,
|
||||
media_player,
|
||||
vacuum,
|
||||
valve,
|
||||
)
|
||||
from homeassistant.components.cover import intent as cover_intent
|
||||
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
|
||||
from homeassistant.components.media_player import intent as media_player_intent
|
||||
from homeassistant.components.vacuum import intent as vaccum_intent
|
||||
from homeassistant.const import STATE_CLOSED
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
intent,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import async_mock_service
|
||||
|
@ -244,3 +256,92 @@ async def test_media_player_intents(
|
|||
"entity_id": entity_id,
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.75,
|
||||
}
|
||||
|
||||
|
||||
async def test_turn_floor_lights_on_off(
|
||||
hass: HomeAssistant,
|
||||
init_components,
|
||||
entity_registry: er.EntityRegistry,
|
||||
area_registry: ar.AreaRegistry,
|
||||
floor_registry: fr.FloorRegistry,
|
||||
) -> None:
|
||||
"""Test that we can turn lights on/off for an entire floor."""
|
||||
floor_ground = floor_registry.async_create("ground", aliases={"downstairs"})
|
||||
floor_upstairs = floor_registry.async_create("upstairs")
|
||||
|
||||
# Kitchen and living room are on the ground floor
|
||||
area_kitchen = area_registry.async_get_or_create("kitchen_id")
|
||||
area_kitchen = area_registry.async_update(
|
||||
area_kitchen.id, name="kitchen", floor_id=floor_ground.floor_id
|
||||
)
|
||||
|
||||
area_living_room = area_registry.async_get_or_create("living_room_id")
|
||||
area_living_room = area_registry.async_update(
|
||||
area_living_room.id, name="living_room", floor_id=floor_ground.floor_id
|
||||
)
|
||||
|
||||
# Bedroom is upstairs
|
||||
area_bedroom = area_registry.async_get_or_create("bedroom_id")
|
||||
area_bedroom = area_registry.async_update(
|
||||
area_bedroom.id, name="bedroom", floor_id=floor_upstairs.floor_id
|
||||
)
|
||||
|
||||
# One light per area
|
||||
kitchen_light = entity_registry.async_get_or_create(
|
||||
"light", "demo", "kitchen_light"
|
||||
)
|
||||
kitchen_light = entity_registry.async_update_entity(
|
||||
kitchen_light.entity_id, area_id=area_kitchen.id
|
||||
)
|
||||
hass.states.async_set(kitchen_light.entity_id, "off")
|
||||
|
||||
living_room_light = entity_registry.async_get_or_create(
|
||||
"light", "demo", "living_room_light"
|
||||
)
|
||||
living_room_light = entity_registry.async_update_entity(
|
||||
living_room_light.entity_id, area_id=area_living_room.id
|
||||
)
|
||||
hass.states.async_set(living_room_light.entity_id, "off")
|
||||
|
||||
bedroom_light = entity_registry.async_get_or_create(
|
||||
"light", "demo", "bedroom_light"
|
||||
)
|
||||
bedroom_light = entity_registry.async_update_entity(
|
||||
bedroom_light.entity_id, area_id=area_bedroom.id
|
||||
)
|
||||
hass.states.async_set(bedroom_light.entity_id, "off")
|
||||
|
||||
# Target by floor
|
||||
on_calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON)
|
||||
result = await conversation.async_converse(
|
||||
hass, "turn on all lights downstairs", None, Context(), None
|
||||
)
|
||||
|
||||
assert len(on_calls) == 2
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert {s.entity_id for s in result.response.matched_states} == {
|
||||
kitchen_light.entity_id,
|
||||
living_room_light.entity_id,
|
||||
}
|
||||
|
||||
on_calls.clear()
|
||||
result = await conversation.async_converse(
|
||||
hass, "upstairs lights on", None, Context(), None
|
||||
)
|
||||
|
||||
assert len(on_calls) == 1
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert {s.entity_id for s in result.response.matched_states} == {
|
||||
bedroom_light.entity_id
|
||||
}
|
||||
|
||||
off_calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_OFF)
|
||||
result = await conversation.async_converse(
|
||||
hass, "turn upstairs lights off", None, Context(), None
|
||||
)
|
||||
|
||||
assert len(off_calls) == 1
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert {s.entity_id for s in result.response.matched_states} == {
|
||||
bedroom_light.entity_id
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ from homeassistant.helpers import (
|
|||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
intent,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
@ -34,12 +35,25 @@ async def test_async_match_states(
|
|||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
floor_registry: fr.FloorRegistry,
|
||||
) -> None:
|
||||
"""Test async_match_state helper."""
|
||||
area_kitchen = area_registry.async_get_or_create("kitchen")
|
||||
area_registry.async_update(area_kitchen.id, aliases={"food room"})
|
||||
area_kitchen = area_registry.async_update(area_kitchen.id, aliases={"food room"})
|
||||
area_bedroom = area_registry.async_get_or_create("bedroom")
|
||||
|
||||
# Kitchen is on the first floor
|
||||
floor_1 = floor_registry.async_create("first floor", aliases={"ground floor"})
|
||||
area_kitchen = area_registry.async_update(
|
||||
area_kitchen.id, floor_id=floor_1.floor_id
|
||||
)
|
||||
|
||||
# Bedroom is on the second floor
|
||||
floor_2 = floor_registry.async_create("second floor")
|
||||
area_bedroom = area_registry.async_update(
|
||||
area_bedroom.id, floor_id=floor_2.floor_id
|
||||
)
|
||||
|
||||
state1 = State(
|
||||
"light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"}
|
||||
)
|
||||
|
@ -94,6 +108,13 @@ async def test_async_match_states(
|
|||
)
|
||||
)
|
||||
|
||||
# Invalid area
|
||||
assert not list(
|
||||
intent.async_match_states(
|
||||
hass, area_name="invalid area", states=[state1, state2]
|
||||
)
|
||||
)
|
||||
|
||||
# Domain + area
|
||||
assert list(
|
||||
intent.async_match_states(
|
||||
|
@ -111,6 +132,35 @@ async def test_async_match_states(
|
|||
)
|
||||
) == [state2]
|
||||
|
||||
# Floor
|
||||
assert list(
|
||||
intent.async_match_states(
|
||||
hass, floor_name="first floor", states=[state1, state2]
|
||||
)
|
||||
) == [state1]
|
||||
|
||||
assert list(
|
||||
intent.async_match_states(
|
||||
# Check alias
|
||||
hass,
|
||||
floor_name="ground floor",
|
||||
states=[state1, state2],
|
||||
)
|
||||
) == [state1]
|
||||
|
||||
assert list(
|
||||
intent.async_match_states(
|
||||
hass, floor_name="second floor", states=[state1, state2]
|
||||
)
|
||||
) == [state2]
|
||||
|
||||
# Invalid floor
|
||||
assert not list(
|
||||
intent.async_match_states(
|
||||
hass, floor_name="invalid floor", states=[state1, state2]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_match_device_area(
|
||||
hass: HomeAssistant,
|
||||
|
@ -300,3 +350,27 @@ async def test_validate_then_run_in_background(hass: HomeAssistant) -> None:
|
|||
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data == {"entity_id": "light.kitchen"}
|
||||
|
||||
|
||||
async def test_invalid_area_floor_names(hass: HomeAssistant) -> None:
|
||||
"""Test that we throw an intent handle error with invalid area/floor names."""
|
||||
handler = intent.ServiceIntentHandler(
|
||||
"TestType", "light", "turn_on", "Turned {} on"
|
||||
)
|
||||
intent.async_register(hass, handler)
|
||||
|
||||
with pytest.raises(intent.IntentHandleError):
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
"TestType",
|
||||
slots={"area": {"value": "invalid area"}},
|
||||
)
|
||||
|
||||
with pytest.raises(intent.IntentHandleError):
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
"TestType",
|
||||
slots={"floor": {"value": "invalid floor"}},
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue