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:
Michael Hansen 2023-02-09 22:39:46 -06:00 committed by GitHub
parent ea356ad260
commit 8cd5106c15
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 406 additions and 22 deletions

View File

@ -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

View File

@ -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)

View File

@ -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."""

View File

@ -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"},
},
)