mirror of
https://github.com/home-assistant/core.git
synced 2025-04-22 16:27:56 +00:00
Add HassGetState intent for queries (#87808)
* Use names instead of entity ids for list * Add HassGetState for Assist queries * Add unknown area to test * Clean up and test device classes
This commit is contained in:
parent
ea356ad260
commit
8cd5106c15
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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."""
|
||||
|
@ -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"},
|
||||
},
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user