diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 5d7b5391fd4..b7ae2ddce34 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -17,7 +17,13 @@ from home_assistant_intents import get_intents import yaml from homeassistant import core, setup -from homeassistant.helpers import area_registry, entity_registry, intent, template +from homeassistant.helpers import ( + area_registry, + entity_registry, + intent, + template, + translation, +) from homeassistant.helpers.json import JsonObjectType, json_loads_object from .agent import AbstractConversationAgent, ConversationInput, ConversationResult @@ -178,22 +184,14 @@ class DefaultAgent(AbstractConversationAgent): and (response_key := result.response) ): # Use response template, if available - response_str = lang_intents.intent_responses.get( + response_template_str = lang_intents.intent_responses.get( result.intent.name, {} ).get(response_key) - if response_str: - response_template = template.Template(response_str, self.hass) - speech = response_template.async_render( - { - "slots": { - entity_name: entity_value.text or entity_value.value - for entity_name, entity_value in result.entities.items() - } - } + if response_template_str: + response_template = template.Template(response_template_str, self.hass) + speech = await self._build_speech( + language, response_template, intent_response, result ) - - # Normalize whitespace - speech = " ".join(speech.strip().split()) intent_response.async_set_speech(speech) return ConversationResult( @@ -220,6 +218,67 @@ class DefaultAgent(AbstractConversationAgent): return maybe_result + async def _build_speech( + self, + language: str, + response_template: template.Template, + intent_response: intent.IntentResponse, + recognize_result: RecognizeResult, + ) -> str: + all_states = intent_response.matched_states + intent_response.unmatched_states + domains = {state.domain for state in all_states} + translations = await translation.async_get_translations( + self.hass, language, "state", domains + ) + + # Use translated state names + for state in all_states: + device_class = state.attributes.get("device_class", "_") + key = f"component.{state.domain}.state.{device_class}.{state.state}" + state.state = translations.get(key, state.state) + + # Get first matched or unmatched state. + # This is available in the response template as "state". + state1: core.State | None = None + if intent_response.matched_states: + state1 = intent_response.matched_states[0] + elif intent_response.unmatched_states: + state1 = intent_response.unmatched_states[0] + + # Render response template + speech = response_template.async_render( + { + # Slots from intent recognizer + "slots": { + entity_name: entity_value.text or entity_value.value + for entity_name, entity_value in recognize_result.entities.items() + }, + # First matched or unmatched state + "state": template.TemplateState(self.hass, state1) + if state1 is not None + else None, + "query": { + # Entity states that matched the query (e.g, "on") + "matched": [ + template.TemplateState(self.hass, state) + for state in intent_response.matched_states + ], + # Entity states that did not match the query + "unmatched": [ + template.TemplateState(self.hass, state) + for state in intent_response.unmatched_states + ], + }, + } + ) + + # Normalize whitespace + if speech is not None: + speech = str(speech) + speech = " ".join(speech.strip().split()) + + return speech + async def async_reload(self, language: str | None = None): """Clear cached intents for a language.""" if language is None: @@ -392,7 +451,7 @@ class DefaultAgent(AbstractConversationAgent): return self._areas_list def _make_names_list(self) -> TextSlotList: - """Create slot list mapping entity names/aliases to entity ids.""" + """Create slot list with entity names/aliases.""" if self._names_list is not None: return self._names_list states = self.hass.states.async_all() @@ -409,14 +468,14 @@ class DefaultAgent(AbstractConversationAgent): if entity.aliases: for alias in entity.aliases: - names.append((alias, state.entity_id, context)) + names.append((alias, alias, context)) # Default name - names.append((state.name, state.entity_id, context)) + names.append((state.name, state.name, context)) else: # Default name - names.append((state.name, state.entity_id, context)) + names.append((state.name, state.name, context)) self._names_list = TextSlotList.from_tuples(names, allow_template=False) return self._names_list diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 874a9f1120c..e02d1486af5 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -1,4 +1,6 @@ """The Intent integration.""" +import logging + import voluptuous as vol from homeassistant.components import http @@ -15,11 +17,18 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, State -from homeassistant.helpers import config_validation as cv, integration_platform, intent +from homeassistant.helpers import ( + area_registry, + config_validation as cv, + integration_platform, + intent, +) from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Intent component.""" @@ -41,6 +50,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, intent.ServiceIntentHandler(intent.INTENT_TOGGLE, HA_DOMAIN, SERVICE_TOGGLE), ) + intent.async_register( + hass, + GetStateIntentHandler(), + ) return True @@ -68,6 +81,116 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): await super().async_call_service(intent_obj, state) +class GetStateIntentHandler(intent.IntentHandler): + """Answer questions about entity states.""" + + intent_type = intent.INTENT_GET_STATE + slot_schema = { + vol.Any("name", "area"): cv.string, + vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("state"): vol.All(cv.ensure_list, [cv.string]), + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the hass intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + # Entity name to match + name: str | None = slots.get("name", {}).get("value") + + # Look up area first to fail early + area_name = slots.get("area", {}).get("value") + area: area_registry.AreaEntry | None = None + if area_name is not None: + areas = area_registry.async_get(hass) + area = areas.async_get_area(area_name) or areas.async_get_area_by_name( + area_name + ) + if area is None: + raise intent.IntentHandleError(f"No area named {area_name}") + + # Optional domain/device class filters. + # Convert to sets for speed. + domains: set[str] | None = None + device_classes: set[str] | None = None + + if "domain" in slots: + domains = set(slots["domain"]["value"]) + + if "device_class" in slots: + device_classes = set(slots["device_class"]["value"]) + + state_names: set[str] | None = None + if "state" in slots: + state_names = set(slots["state"]["value"]) + + states = list( + intent.async_match_states( + hass, + name=name, + area=area, + domains=domains, + device_classes=device_classes, + ) + ) + + _LOGGER.debug( + "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s", + len(states), + name, + area, + domains, + device_classes, + ) + + # Create response + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + + success_results: list[intent.IntentResponseTarget] = [] + if area is not None: + success_results.append( + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.AREA, + name=area.name, + id=area.id, + ) + ) + + # If we are matching a state name (e.g., "which lights are on?"), then + # we split the filtered states into two groups: + # + # 1. matched - entity states that match the requested state ("on") + # 2. unmatched - entity states that don't match ("off") + # + # In the response template, we can access these as query.matched and + # query.unmatched. + matched_states: list[State] = [] + unmatched_states: list[State] = [] + + for state in states: + success_results.append( + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name=state.name, + id=state.entity_id, + ), + ) + + if (not state_names) or (state.state in state_names): + # If no state constraint, then all states will be "matched" + matched_states.append(state) + else: + unmatched_states.append(state) + + response.async_set_results(success_results=success_results) + response.async_set_states(matched_states, unmatched_states) + + return response + + async def _async_process_intent(hass: HomeAssistant, domain: str, platform): """Process the intents of an integration.""" await platform.async_setup_intents(hass) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 58252da4822..fb338f9f139 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -29,6 +29,7 @@ _T = TypeVar("_T") INTENT_TURN_OFF = "HassTurnOff" INTENT_TURN_ON = "HassTurnOn" INTENT_TOGGLE = "HassToggle" +INTENT_GET_STATE = "HassGetState" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) @@ -386,7 +387,9 @@ class ServiceIntentHandler(IntentHandler): ) if not states: - raise IntentHandleError("No entities matched") + raise IntentHandleError( + f"No entities matched for: name={name}, area={area}, domains={domains}, device_classes={device_classes}", + ) response = await self.async_handle_states(intent_obj, states, area) @@ -431,6 +434,7 @@ class ServiceIntentHandler(IntentHandler): response.async_set_results( success_results=success_results, ) + response.async_set_states(states) if self.speech is not None: response.async_set_speech(self.speech.format(speech_name)) @@ -569,6 +573,8 @@ class IntentResponse: self.intent_targets: list[IntentResponseTarget] = [] self.success_results: list[IntentResponseTarget] = [] self.failed_results: list[IntentResponseTarget] = [] + self.matched_states: list[State] = [] + self.unmatched_states: list[State] = [] if (self.intent is not None) and (self.intent.category == IntentCategory.QUERY): # speech will be the answer to the query @@ -636,6 +642,14 @@ class IntentResponse: self.success_results = success_results self.failed_results = failed_results if failed_results is not None else [] + @callback + def async_set_states( + self, matched_states: list[State], unmatched_states: list[State] | None = None + ) -> None: + """Set entity states that were matched or not matched during intent handling (query).""" + self.matched_states = matched_states + self.unmatched_states = unmatched_states or [] + @callback def as_dict(self) -> dict[str, Any]: """Return a dictionary representation of an intent response.""" diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 73788a75cf5..10541ca8b58 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -2,9 +2,15 @@ import pytest from homeassistant.components.cover import SERVICE_OPEN_COVER -from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import area_registry, entity_registry, intent from homeassistant.setup import async_setup_component from tests.common import async_mock_service @@ -175,3 +181,185 @@ async def test_turn_on_multiple_intent(hass: HomeAssistant) -> None: assert call.domain == "light" assert call.service == "turn_on" assert call.data == {"entity_id": ["light.test_lights_2"]} + + +async def test_get_state_intent(hass: HomeAssistant) -> None: + """Test HassGetState intent. + + This tests name, area, domain, device class, and state constraints. + """ + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + areas = area_registry.async_get(hass) + bedroom = areas.async_get_or_create("bedroom") + kitchen = areas.async_get_or_create("kitchen") + office = areas.async_get_or_create("office") + + # 1 light in bedroom (off) + # 1 light in kitchen (on) + # 1 sensor in kitchen (50) + # 2 binary sensors in the office (problem, moisture, on) + entities = entity_registry.async_get(hass) + bedroom_light = entities.async_get_or_create("light", "demo", "1") + entities.async_update_entity(bedroom_light.entity_id, area_id=bedroom.id) + + kitchen_sensor = entities.async_get_or_create("sensor", "demo", "2") + entities.async_update_entity(kitchen_sensor.entity_id, area_id=kitchen.id) + + kitchen_light = entities.async_get_or_create("light", "demo", "3") + entities.async_update_entity(kitchen_light.entity_id, area_id=kitchen.id) + + kitchen_sensor = entities.async_get_or_create("sensor", "demo", "4") + entities.async_update_entity(kitchen_sensor.entity_id, area_id=kitchen.id) + + problem_sensor = entities.async_get_or_create("binary_sensor", "demo", "5") + entities.async_update_entity(problem_sensor.entity_id, area_id=office.id) + + moisture_sensor = entities.async_get_or_create("binary_sensor", "demo", "6") + entities.async_update_entity(moisture_sensor.entity_id, area_id=office.id) + + hass.states.async_set( + bedroom_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "bedroom light"} + ) + hass.states.async_set( + kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} + ) + hass.states.async_set( + kitchen_sensor.entity_id, + "50.0", + attributes={ATTR_FRIENDLY_NAME: "kitchen sensor"}, + ) + hass.states.async_set( + problem_sensor.entity_id, + "on", + attributes={ATTR_FRIENDLY_NAME: "problem sensor", ATTR_DEVICE_CLASS: "problem"}, + ) + hass.states.async_set( + moisture_sensor.entity_id, + "on", + attributes={ + ATTR_FRIENDLY_NAME: "moisture sensor", + ATTR_DEVICE_CLASS: "moisture", + }, + ) + + # --- + # is bedroom light off? + result = await intent.async_handle( + hass, + "test", + "HassGetState", + {"name": {"value": "bedroom light"}, "state": {"value": "off"}}, + ) + + # yes + assert result.response_type == intent.IntentResponseType.QUERY_ANSWER + assert result.matched_states and ( + result.matched_states[0].entity_id == bedroom_light.entity_id + ) + assert not result.unmatched_states + + # --- + # is light in kitchen off? + result = await intent.async_handle( + hass, + "test", + "HassGetState", + { + "area": {"value": "kitchen"}, + "domain": {"value": "light"}, + "state": {"value": "off"}, + }, + ) + + # no, it's on + assert result.response_type == intent.IntentResponseType.QUERY_ANSWER + assert not result.matched_states + assert result.unmatched_states and ( + result.unmatched_states[0].entity_id == kitchen_light.entity_id + ) + + # --- + # what is the value of the kitchen sensor? + result = await intent.async_handle( + hass, + "test", + "HassGetState", + { + "name": {"value": "kitchen sensor"}, + }, + ) + + assert result.response_type == intent.IntentResponseType.QUERY_ANSWER + assert result.matched_states and ( + result.matched_states[0].entity_id == kitchen_sensor.entity_id + ) + assert not result.unmatched_states + + # --- + # is there a problem in the office? + result = await intent.async_handle( + hass, + "test", + "HassGetState", + { + "area": {"value": "office"}, + "device_class": {"value": "problem"}, + "state": {"value": "on"}, + }, + ) + + # yes + assert result.response_type == intent.IntentResponseType.QUERY_ANSWER + assert result.matched_states and ( + result.matched_states[0].entity_id == problem_sensor.entity_id + ) + assert not result.unmatched_states + + # --- + # is there a problem or a moisture sensor in the office? + result = await intent.async_handle( + hass, + "test", + "HassGetState", + { + "area": {"value": "office"}, + "device_class": {"value": ["problem", "moisture"]}, + }, + ) + + # yes, 2 of them + assert result.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(result.matched_states) == 2 and { + state.entity_id for state in result.matched_states + } == {problem_sensor.entity_id, moisture_sensor.entity_id} + assert not result.unmatched_states + + # --- + # are there any binary sensors in the kitchen? + result = await intent.async_handle( + hass, + "test", + "HassGetState", + { + "area": {"value": "kitchen"}, + "domain": {"value": "binary_sensor"}, + }, + ) + + # no + assert result.response_type == intent.IntentResponseType.QUERY_ANSWER + assert not result.matched_states and not result.unmatched_states + + # Test unknown area failure + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + "HassGetState", + { + "area": {"value": "does-not-exist"}, + "domain": {"value": "light"}, + }, + )