Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial support for floors to intents #114456

Merged
merged 6 commits into from
Mar 30, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
44 changes: 42 additions & 2 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 @@ -166,6 +167,11 @@ async def async_initialize(self, config_intents: dict[str, Any] | None) -> None:
self._async_handle_area_registry_changed,
run_immediately=True,
)
self.hass.bus.async_listen(
fr.EVENT_FLOOR_REGISTRY_UPDATED,
self._async_handle_floor_registry_changed,
run_immediately=True,
)
self.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED,
self._async_handle_entity_registry_changed,
Expand Down Expand Up @@ -699,7 +705,14 @@ def _get_or_load_intents(
def _async_handle_area_registry_changed(
self, event: core.Event[ar.EventAreaRegistryUpdatedData]
) -> None:
"""Clear area area cache when the area registry has changed."""
"""Clear area list cache when the area registry has changed."""
self._slot_lists = None

@core.callback
def _async_handle_floor_registry_changed(
synesthesiam marked this conversation as resolved.
Show resolved Hide resolved
self, event: core.Event[fr.EventFloorRegistryUpdatedData]
) -> None:
"""Clear floor list cache when the floor registry has changed."""
self._slot_lists = None

@core.callback
Expand Down Expand Up @@ -773,6 +786,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 +803,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),
balloob marked this conversation as resolved.
Show resolved Hide resolved
}

return self._slot_lists
Expand Down Expand Up @@ -953,6 +982,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 +1033,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,
synesthesiam marked this conversation as resolved.
Show resolved Hide resolved
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)
synesthesiam marked this conversation as resolved.
Show resolved Hide resolved
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
Loading