1
mirror of https://github.com/home-assistant/core synced 2024-09-28 03:04:04 +02:00

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 handler
This commit is contained in:
Michael Hansen 2024-03-30 15:59:20 -05:00 committed by GitHub
parent 502231b7d2
commit d23b22b566
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 384 additions and 36 deletions

View File

@ -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,
)

View File

@ -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}

View File

@ -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"]
}

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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
}

View File

@ -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"}},
)