Skip to content

Commit

Permalink
Add initial support for floors to intents (#114456)
Browse files Browse the repository at this point in the history
* Add initial support for floors to intents

* Fix climate intent

* More tests

* No return value

* Add requested changes

* Reuse event handler
  • Loading branch information
synesthesiam committed Mar 30, 2024
1 parent 502231b commit d23b22b
Show file tree
Hide file tree
Showing 10 changed files with 384 additions and 36 deletions.
2 changes: 2 additions & 0 deletions homeassistant/components/climate/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
)
Expand Down
46 changes: 41 additions & 5 deletions homeassistant/components/conversation/default_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
area_registry as ar,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
intent,
start,
template,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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}

Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/conversation/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
110 changes: 89 additions & 21 deletions homeassistant/helpers/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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)


Expand All @@ -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]:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -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]),
}
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/package_constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit d23b22b

Please sign in to comment.