Files
core/homeassistant/components/intent/__init__.py
Michael Hansen 49c27ae7bc Check area temperature sensors in get temperature intent (#139221)
* Check area temperature sensors in get temperature intent

* Fix candidate check

* Add new code back in

* Remove cruft from climate
2025-02-28 13:02:30 -05:00

601 lines
20 KiB
Python

"""The Intent integration."""
from __future__ import annotations
from collections.abc import Collection
import logging
from typing import Any, Protocol
from aiohttp import web
import voluptuous as vol
from homeassistant.components import http, sensor
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import (
ATTR_POSITION,
DOMAIN as COVER_DOMAIN,
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION,
CoverDeviceClass,
)
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.lock import (
DOMAIN as LOCK_DOMAIN,
SERVICE_LOCK,
SERVICE_UNLOCK,
)
from homeassistant.components.media_player import MediaPlayerDeviceClass
from homeassistant.components.switch import SwitchDeviceClass
from homeassistant.components.valve import (
DOMAIN as VALVE_DOMAIN,
SERVICE_CLOSE_VALVE,
SERVICE_OPEN_VALVE,
SERVICE_SET_VALVE_POSITION,
ValveDeviceClass,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TOGGLE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State
from homeassistant.helpers import (
area_registry as ar,
config_validation as cv,
integration_platform,
intent,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from .const import DOMAIN, TIMER_DATA
from .timers import (
CancelAllTimersIntentHandler,
CancelTimerIntentHandler,
DecreaseTimerIntentHandler,
IncreaseTimerIntentHandler,
PauseTimerIntentHandler,
StartTimerIntentHandler,
TimerEventType,
TimerInfo,
TimerManager,
TimerStatusIntentHandler,
UnpauseTimerIntentHandler,
async_device_supports_timers,
async_register_timer_handler,
)
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = [
"DOMAIN",
"TimerEventType",
"TimerInfo",
"async_device_supports_timers",
"async_register_timer_handler",
]
ONOFF_DEVICE_CLASSES = {
CoverDeviceClass,
ValveDeviceClass,
SwitchDeviceClass,
MediaPlayerDeviceClass,
}
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Intent component."""
hass.data[TIMER_DATA] = TimerManager(hass)
hass.http.register_view(IntentHandleView())
await integration_platform.async_process_integration_platforms(
hass, DOMAIN, _async_process_intent
)
intent.async_register(
hass,
OnOffIntentHandler(
intent.INTENT_TURN_ON,
HOMEASSISTANT_DOMAIN,
SERVICE_TURN_ON,
description="Turns on/opens a device or entity",
device_classes=ONOFF_DEVICE_CLASSES,
),
)
intent.async_register(
hass,
OnOffIntentHandler(
intent.INTENT_TURN_OFF,
HOMEASSISTANT_DOMAIN,
SERVICE_TURN_OFF,
description="Turns off/closes a device or entity",
device_classes=ONOFF_DEVICE_CLASSES,
),
)
intent.async_register(
hass,
intent.ServiceIntentHandler(
intent.INTENT_TOGGLE,
HOMEASSISTANT_DOMAIN,
SERVICE_TOGGLE,
description="Toggles a device or entity",
device_classes=ONOFF_DEVICE_CLASSES,
),
)
intent.async_register(
hass,
GetStateIntentHandler(),
)
intent.async_register(
hass,
NevermindIntentHandler(),
)
intent.async_register(hass, SetPositionIntentHandler())
intent.async_register(hass, StartTimerIntentHandler())
intent.async_register(hass, CancelTimerIntentHandler())
intent.async_register(hass, CancelAllTimersIntentHandler())
intent.async_register(hass, IncreaseTimerIntentHandler())
intent.async_register(hass, DecreaseTimerIntentHandler())
intent.async_register(hass, PauseTimerIntentHandler())
intent.async_register(hass, UnpauseTimerIntentHandler())
intent.async_register(hass, TimerStatusIntentHandler())
intent.async_register(hass, GetCurrentDateIntentHandler())
intent.async_register(hass, GetCurrentTimeIntentHandler())
intent.async_register(hass, RespondIntentHandler())
intent.async_register(hass, GetTemperatureIntent())
return True
class IntentPlatformProtocol(Protocol):
"""Define the format that intent platforms can have."""
async def async_setup_intents(self, hass: HomeAssistant) -> None:
"""Set up platform intents."""
class OnOffIntentHandler(intent.ServiceIntentHandler):
"""Intent handler for on/off that also supports covers, valves, locks, etc."""
async def async_call_service(
self, domain: str, service: str, intent_obj: intent.Intent, state: State
) -> None:
"""Call service on entity with handling for special cases."""
hass = intent_obj.hass
if state.domain == COVER_DOMAIN:
# on = open
# off = close
if service == SERVICE_TURN_ON:
service_name = SERVICE_OPEN_COVER
else:
service_name = SERVICE_CLOSE_COVER
await self._run_then_background(
hass.async_create_task(
hass.services.async_call(
COVER_DOMAIN,
service_name,
{ATTR_ENTITY_ID: state.entity_id},
context=intent_obj.context,
blocking=True,
)
)
)
return
if state.domain == LOCK_DOMAIN:
# on = lock
# off = unlock
if service == SERVICE_TURN_ON:
service_name = SERVICE_LOCK
else:
service_name = SERVICE_UNLOCK
await self._run_then_background(
hass.async_create_task(
hass.services.async_call(
LOCK_DOMAIN,
service_name,
{ATTR_ENTITY_ID: state.entity_id},
context=intent_obj.context,
blocking=True,
)
)
)
return
if state.domain == VALVE_DOMAIN:
# on = opened
# off = closed
if service == SERVICE_TURN_ON:
service_name = SERVICE_OPEN_VALVE
else:
service_name = SERVICE_CLOSE_VALVE
await self._run_then_background(
hass.async_create_task(
hass.services.async_call(
VALVE_DOMAIN,
service_name,
{ATTR_ENTITY_ID: state.entity_id},
context=intent_obj.context,
blocking=True,
)
)
)
return
if not hass.services.has_service(state.domain, service):
raise intent.IntentHandleError(
f"Service {service} does not support entity {state.entity_id}"
)
# Fall back to homeassistant.turn_on/off
await super().async_call_service(domain, service, intent_obj, state)
class GetStateIntentHandler(intent.IntentHandler):
"""Answer questions about entity states."""
intent_type = intent.INTENT_GET_STATE
description = "Gets or checks the state of a device or entity"
slot_schema = {
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]),
vol.Optional("state"): vol.All(cv.ensure_list, [cv.string]),
vol.Optional("preferred_area_id"): cv.string,
vol.Optional("preferred_floor_id"): 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_slot = slots.get("name", {})
entity_name: str | None = name_slot.get("value")
# Get area/floor info
area_slot = slots.get("area", {})
area_id = area_slot.get("value")
floor_slot = slots.get("floor", {})
floor_id = floor_slot.get("value")
# 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"])
match_constraints = intent.MatchTargetsConstraints(
name=entity_name,
area_name=area_id,
floor_name=floor_id,
domains=domains,
device_classes=device_classes,
assistant=intent_obj.assistant,
)
match_preferences = intent.MatchTargetsPreferences(
area_id=slots.get("preferred_area_id", {}).get("value"),
floor_id=slots.get("preferred_floor_id", {}).get("value"),
)
match_result = intent.async_match_targets(
hass, match_constraints, match_preferences
)
if (
(not match_result.is_match)
and (match_result.no_match_reason is not None)
and (not match_result.no_match_reason.is_no_entities_reason())
):
# Don't try to answer questions for certain errors.
# Other match failure reasons are OK.
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
# Create response
response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.QUERY_ANSWER
success_results: list[intent.IntentResponseTarget] = []
if match_result.areas:
success_results.extend(
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.AREA,
name=area.name,
id=area.id,
)
for area in match_result.areas
)
if match_result.floors:
success_results.extend(
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.FLOOR,
name=floor.name,
id=floor.floor_id,
)
for floor in match_result.floors
)
# 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 match_result.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
class NevermindIntentHandler(intent.IntentHandler):
"""Takes no action."""
intent_type = intent.INTENT_NEVERMIND
description = "Cancels the current request and does nothing"
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Do nothing and produces an empty response."""
return intent_obj.create_response()
class SetPositionIntentHandler(intent.DynamicServiceIntentHandler):
"""Intent handler for setting positions."""
def __init__(self) -> None:
"""Create set position handler."""
super().__init__(
intent.INTENT_SET_POSITION,
required_slots={
ATTR_POSITION: vol.All(vol.Coerce(int), vol.Range(min=0, max=100))
},
description="Sets the position of a device or entity",
platforms={COVER_DOMAIN, VALVE_DOMAIN},
device_classes={CoverDeviceClass, ValveDeviceClass},
)
def get_domain_and_service(
self, intent_obj: intent.Intent, state: State
) -> tuple[str, str]:
"""Get the domain and service name to call."""
if state.domain == COVER_DOMAIN:
return (COVER_DOMAIN, SERVICE_SET_COVER_POSITION)
if state.domain == VALVE_DOMAIN:
return (VALVE_DOMAIN, SERVICE_SET_VALVE_POSITION)
raise intent.IntentHandleError(f"Domain not supported: {state.domain}")
class GetCurrentDateIntentHandler(intent.IntentHandler):
"""Gets the current date."""
intent_type = intent.INTENT_GET_CURRENT_DATE
description = "Gets the current date"
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
response = intent_obj.create_response()
response.async_set_speech_slots({"date": dt_util.now().date()})
return response
class GetCurrentTimeIntentHandler(intent.IntentHandler):
"""Gets the current time."""
intent_type = intent.INTENT_GET_CURRENT_TIME
description = "Gets the current time"
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
response = intent_obj.create_response()
response.async_set_speech_slots({"time": dt_util.now().time()})
return response
class RespondIntentHandler(intent.IntentHandler):
"""Responds with no action."""
intent_type = intent.INTENT_RESPOND
description = "Returns the provided response with no action."
slot_schema = {
vol.Optional("response"): cv.string,
}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Return the provided response, but take no action."""
slots = self.async_validate_slots(intent_obj.slots)
response = intent_obj.create_response()
if "response" in slots:
response.async_set_speech(slots["response"]["value"])
return response
class GetTemperatureIntent(intent.IntentHandler):
"""Handle GetTemperature intents."""
intent_type = intent.INTENT_GET_TEMPERATURE
description = "Gets the current temperature of a climate device or entity"
slot_schema = {
vol.Optional("area"): intent.non_empty_string,
vol.Optional("name"): intent.non_empty_string,
vol.Optional("floor"): intent.non_empty_string,
vol.Optional("preferred_area_id"): cv.string,
vol.Optional("preferred_floor_id"): cv.string,
}
platforms = {CLIMATE_DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
name: str | None = None
if "name" in slots:
name = slots["name"]["value"]
area: str | None = None
if "area" in slots:
area = slots["area"]["value"]
floor_name: str | None = None
if "floor" in slots:
floor_name = slots["floor"]["value"]
match_preferences = intent.MatchTargetsPreferences(
area_id=slots.get("preferred_area_id", {}).get("value"),
floor_id=slots.get("preferred_floor_id", {}).get("value"),
)
if (not name) and (area or match_preferences.area_id):
# Look for temperature sensors assigned to an area
area_registry = ar.async_get(hass)
area_temperature_ids: dict[str, str] = {}
# Keep candidates that are registered as area temperature sensors
def area_candidate_filter(
candidate: intent.MatchTargetsCandidate,
possible_area_ids: Collection[str],
) -> bool:
for area_id in possible_area_ids:
temperature_id = area_temperature_ids.get(area_id)
if (temperature_id is None) and (
area_entry := area_registry.async_get_area(area_id)
):
temperature_id = area_entry.temperature_entity_id or ""
area_temperature_ids[area_id] = temperature_id
if candidate.state.entity_id == temperature_id:
return True
return False
match_constraints = intent.MatchTargetsConstraints(
area_name=area,
floor_name=floor_name,
domains=[sensor.DOMAIN],
device_classes=[sensor.SensorDeviceClass.TEMPERATURE],
assistant=intent_obj.assistant,
single_target=True,
)
match_result = intent.async_match_targets(
hass,
match_constraints,
match_preferences,
area_candidate_filter=area_candidate_filter,
)
if match_result.is_match:
# Found temperature sensor
response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.QUERY_ANSWER
response.async_set_states(matched_states=match_result.states)
return response
# Look for climate devices
match_constraints = intent.MatchTargetsConstraints(
name=name,
area_name=area,
floor_name=floor_name,
domains=[CLIMATE_DOMAIN],
assistant=intent_obj.assistant,
single_target=True,
)
match_result = intent.async_match_targets(
hass, match_constraints, match_preferences
)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.QUERY_ANSWER
response.async_set_states(matched_states=match_result.states)
return response
async def _async_process_intent(
hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol
) -> None:
"""Process the intents of an integration."""
await platform.async_setup_intents(hass)
class IntentHandleView(http.HomeAssistantView):
"""View to handle intents from JSON."""
url = "/api/intent/handle"
name = "api:intent:handle"
@RequestDataValidator(
vol.Schema(
{
vol.Required("name"): cv.string,
vol.Optional("data"): vol.Schema({cv.string: object}),
}
)
)
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle intent with name/data."""
hass = request.app[http.KEY_HASS]
language = hass.config.language
try:
intent_name = data["name"]
slots = {
key: {"value": value} for key, value in data.get("data", {}).items()
}
intent_result = await intent.async_handle(
hass, DOMAIN, intent_name, slots, "", self.context(request)
)
except intent.IntentHandleError as err:
intent_result = intent.IntentResponse(language=language)
intent_result.async_set_speech(str(err))
if intent_result is None:
intent_result = intent.IntentResponse(language=language) # type: ignore[unreachable]
intent_result.async_set_speech("Sorry, I couldn't handle that")
return self.json(intent_result)