From d1bd35b20bf6381ddb0fdca519006be65427cdfe Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 11 Nov 2025 14:17:26 -0600 Subject: [PATCH] Add name/area/floor lists --- .../components/conversation/__init__.py | 61 +++++++++++++--- tests/components/conversation/test_init.py | 71 ++++++++++++++++++- 2 files changed, 119 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index d96e3178f949..5163e1c97e7b 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -14,6 +14,7 @@ from home_assistant_intents import get_intents, get_languages import voluptuous as vol import yaml +from homeassistant.components.homeassistant.exposed_entities import async_should_expose from homeassistant.config_entries import ConfigEntry from homeassistant.const import MATCH_ALL from homeassistant.core import ( @@ -24,7 +25,13 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, intent +from homeassistant.helpers import ( + area_registry as ar, + config_validation as cv, + entity_registry as er, + floor_registry as fr, + intent, +) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType @@ -277,7 +284,7 @@ async def async_get_intents( hass: HomeAssistant, language: str, source: IntentSource = IntentSource.ALL ) -> Intents: """Load intents for a language.""" - intents_dict: dict[str, Any] = {"intents": {}} + intents_dict: dict[str, Any] = {"intents": {}, "lists": {}} agent = get_agent_manager(hass).default_agent assert agent is not None @@ -310,15 +317,7 @@ async def async_get_intents( if (source & IntentSource.CONVERSATION_CONFIG) and agent.config_intents: # From conversation YAML config - merge_dict( - intents_dict, - { - "intents": { - intent_name: {"data": [{"sentences": sentences}]} - for intent_name, sentences in agent.config_intents.items() - } - }, - ) + merge_dict(intents_dict, agent.config_intents) if (source & IntentSource.SENTENCE_TRIGGERS) and agent.trigger_details: # From automations @@ -336,6 +335,46 @@ async def async_get_intents( }, ) + # Add exposed entities + areas/floors + entity_tuples = [] + entity_registry = er.async_get(hass) + for state in hass.states.async_all(): + if not async_should_expose(hass, DOMAIN, state.entity_id): + continue + + context: dict[str, Any] = {"domain": state.domain} + entity_tuples.append((state.name, context)) + + if not (entity := entity_registry.async_get(state.entity_id)): + continue + + entity_tuples.extend( + (alias, context) + for alias in filter(None, (a.strip() for a in entity.aliases)) + ) + + intents_dict["lists"]["name"] = { + "values": [ + {"in": name, "out": name, "context": context} + for name, context in entity_tuples + ] + } + + # Areas/floors + area_registry = ar.async_get(hass) + floor_registry = fr.async_get(hass) + for list_name, entries in ( + ("area", area_registry.async_list_areas()), + ("floor", floor_registry.async_list_floors()), + ): + entry_names = set() + for entry in entries: + entry_names.add(entry.name) + entry_names.update(filter(None, (a.strip() for a in entry.aliases))) + + if entry_names: + intents_dict["lists"][list_name] = {"values": list(entry_names)} + # Parse and post-process intents_dict["language"] = language intents = Intents.from_dict(intents_dict) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 2d4f5aef45f5..5d18948f9acb 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -3,6 +3,8 @@ from http import HTTPStatus from unittest.mock import patch +from hassil.expression import TextChunk +from hassil.intents import TextSlotList import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -17,10 +19,17 @@ from homeassistant.components.conversation import ( default_agent, ) from homeassistant.components.conversation.const import HOME_ASSISTANT_AGENT +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -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 . import MockAgent @@ -437,7 +446,7 @@ async def test_async_get_intents_bad_language(hass: HomeAssistant) -> None: async def test_async_get_intents_language_region(hass: HomeAssistant) -> None: - """Test getting available intents with a regtional language code.""" + """Test getting available intents with a regional language code.""" assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) @@ -446,3 +455,61 @@ async def test_async_get_intents_language_region(hass: HomeAssistant) -> None: # built-in and custom sentences for "en" are included for intent_name in ("HassTurnOn", "OrderBeer"): assert intent_name in intents.intents + + +async def test_async_get_intents_lists( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test that intents contain name/area/floor lists.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + + # Create a light in an area 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 + ) + kitchen_light = entity_registry.async_get_or_create("light", "demo", "light1234") + 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", {ATTR_FRIENDLY_NAME: "demo light"} + ) + + # Add a second unexposed light + unexposed_light = entity_registry.async_get_or_create("light", "demo", "light5678") + unexposed_light = entity_registry.async_update_entity( + unexposed_light.entity_id, area_id=area_kitchen.id + ) + hass.states.async_set( + unexposed_light.entity_id, "off", {ATTR_FRIENDLY_NAME: "unexposed light"} + ) + async_expose_entity(hass, conversation.DOMAIN, unexposed_light.entity_id, False) + + # name/area/floor lists should reflect state of registries + intents = await conversation.async_get_intents(hass, "en") + + name_list = intents.slot_lists.get("name") + assert isinstance(name_list, TextSlotList) + + # 1 exposed light + assert len(name_list.values) == 1 + assert name_list.values[0].text_in == TextChunk("demo light") + assert name_list.values[0].context == {"domain": "light"} + + # 1 area + area_list = intents.slot_lists.get("area") + assert isinstance(area_list, TextSlotList) + assert len(area_list.values) == 1 + assert area_list.values[0].text_in == TextChunk("kitchen") + + # 1 floor + floor_list = intents.slot_lists.get("floor") + assert isinstance(floor_list, TextSlotList) + assert len(floor_list.values) == 1 + assert floor_list.values[0].text_in == TextChunk("ground")