diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index db263451f0b784..3073d3e3c267e9 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -58,6 +58,7 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse 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 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse raise intent.NoStatesMatchedError( name=entity_name, area=None, + floor=None, domains={DOMAIN}, device_classes=None, ) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 96b0565ebd3321..c0307c68908de5 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -34,6 +34,7 @@ area_registry as ar, device_registry as dr, entity_registry as er, + floor_registry as fr, intent, start, template, @@ -163,7 +164,12 @@ async def async_initialize(self, config_intents: dict[str, Any] | None) -> None: 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 @@ def _get_or_load_intents( 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 @@ def _make_slot_lists(self) -> dict[str, SlotList]: # 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 @@ def _make_slot_lists(self) -> dict[str, SlotList]: 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} diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 7f3c4f5894ef66..7f463483bf934d 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -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"] } diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 63214cb135b3a2..fcebf91b8544db 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -24,7 +24,13 @@ 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 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: # 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 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: 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 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: hass, name=entity_name, area=area, + floor=floor, domains=domains, device_classes=device_classes, assistant=intent_obj.assistant, @@ -491,6 +558,7 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: 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, ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b2a8898bf47d5e..72cd71f889f9cd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index f928cf8333a3f4..d3cb743c8c5d87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aabaf488caa561..a5cf2efa79c363 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index aefb37f427e2a5..8f38459a8daba8 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -17,6 +17,7 @@ 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.""" diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index c57d93d8ceff5a..9636ac07f63f38 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -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 + } diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 1bc01c28cf2d70..d77eb698205671 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -15,6 +15,7 @@ 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 mock_service(call): 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"}}, + )