"""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.button import ( DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS as SERVICE_PRESS_BUTTON, ButtonDeviceClass, ) 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.input_button import DOMAIN as INPUT_BUTTON_DOMAIN 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 = { ButtonDeviceClass, 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/presses a device or entity. For locks, this performs a 'lock' action. Use for requests like 'turn on', 'activate', 'enable', or 'lock'.", 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. For locks, this performs an 'unlock' action. Use for requests like 'turn off', 'deactivate', 'disable', or 'unlock'.", 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 in (BUTTON_DOMAIN, INPUT_BUTTON_DOMAIN): if service != SERVICE_TURN_ON: raise intent.IntentHandleError( f"Entity {state.entity_id} cannot be turned off" ) await self._run_then_background( hass.async_create_task( hass.services.async_call( state.domain, SERVICE_PRESS_BUTTON, {ATTR_ENTITY_ID: state.entity_id}, context=intent_obj.context, blocking=True, ) ) ) return 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)