diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 333fb24498b..2e6c813a551 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -30,7 +30,17 @@ from .agent_manager import ( async_get_agent, get_agent_manager, ) -from .const import HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT +from .const import ( + ATTR_AGENT_ID, + ATTR_CONVERSATION_ID, + ATTR_LANGUAGE, + ATTR_TEXT, + DOMAIN, + HOME_ASSISTANT_AGENT, + OLD_HOME_ASSISTANT_AGENT, + SERVICE_PROCESS, + SERVICE_RELOAD, +) from .default_agent import async_get_default_agent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http @@ -52,19 +62,8 @@ __all__ = [ _LOGGER = logging.getLogger(__name__) -ATTR_TEXT = "text" -ATTR_LANGUAGE = "language" -ATTR_AGENT_ID = "agent_id" -ATTR_CONVERSATION_ID = "conversation_id" - -DOMAIN = "conversation" - REGEX_TYPE = type(re.compile("")) -SERVICE_PROCESS = "process" -SERVICE_RELOAD = "reload" - - SERVICE_PROCESS_SCHEMA = vol.Schema( { vol.Required(ATTR_TEXT): cv.string, @@ -183,7 +182,10 @@ def async_get_agent_info( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" - entity_component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) + entity_component: EntityComponent[ConversationEntity] = EntityComponent( + _LOGGER, DOMAIN, hass + ) + hass.data[DOMAIN] = entity_component await async_setup_default_agent( hass, entity_component, config.get(DOMAIN, {}).get("intents", {}) diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index d20b6d96aa2..70a598e8b56 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -4,3 +4,11 @@ DOMAIN = "conversation" DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} HOME_ASSISTANT_AGENT = "conversation.home_assistant" OLD_HOME_ASSISTANT_AGENT = "homeassistant" + +ATTR_TEXT = "text" +ATTR_LANGUAGE = "language" +ATTR_AGENT_ID = "agent_id" +ATTR_CONVERSATION_ID = "conversation_id" + +SERVICE_PROCESS = "process" +SERVICE_RELOAD = "reload" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 0bf645c0460..7c0d2ec254f 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -335,10 +335,18 @@ class DefaultAgent(ConversationEntity): assert lang_intents is not None # Slot values to pass to the intent - slots = { - entity.name: {"value": entity.value, "text": entity.text or entity.value} - for entity in result.entities_list - } + slots: dict[str, Any] = {} + + # Automatically add device id + if user_input.device_id is not None: + slots["device_id"] = user_input.device_id + + # Add entities from match + for entity in result.entities_list: + slots[entity.name] = { + "value": entity.value, + "text": entity.text or entity.value, + } try: intent_response = await intent.async_handle( @@ -364,14 +372,16 @@ class DefaultAgent(ConversationEntity): ), conversation_id, ) - except intent.IntentHandleError: + except intent.IntentHandleError as err: # Intent was valid and entities matched constraints, but an error # occurred during handling. _LOGGER.exception("Intent handling error") return _make_error_result( language, intent.IntentResponseErrorCode.FAILED_TO_HANDLE, - self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents), + self._get_error_text( + err.response_key or ErrorKey.HANDLE_ERROR, lang_intents + ), conversation_id, ) except intent.IntentUnexpectedError: @@ -412,7 +422,6 @@ class DefaultAgent(ConversationEntity): language: str, ) -> RecognizeResult | None: """Search intents for a match to user input.""" - # Prioritize matches with entity names above area names maybe_result: RecognizeResult | None = None for result in recognize_all( user_input.text, @@ -518,13 +527,16 @@ class DefaultAgent(ConversationEntity): state1 = unmatched[0] # Render response template + speech_slots = { + entity_name: entity_value.text or entity_value.value + for entity_name, entity_value in recognize_result.entities.items() + } + speech_slots.update(intent_response.speech_slots) + 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() - }, + # Slots from intent recognizer and response + "slots": speech_slots, # First matched or unmatched state "state": ( template.TemplateState(self.hass, state1) @@ -849,7 +861,7 @@ class DefaultAgent(ConversationEntity): def _get_error_text( self, - error_key: ErrorKey, + error_key: ErrorKey | str, lang_intents: LanguageIntents | None, **response_args, ) -> str: @@ -857,7 +869,11 @@ class DefaultAgent(ConversationEntity): if lang_intents is None: return _DEFAULT_ERROR_TEXT - response_key = error_key.value + if isinstance(error_key, ErrorKey): + response_key = error_key.value + else: + response_key = error_key + response_str = ( lang_intents.error_responses.get(response_key) or _DEFAULT_ERROR_TEXT ) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 18eaaba41b7..31dee02c7e4 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -38,15 +38,33 @@ from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, State from homeassistant.helpers import config_validation as cv, integration_platform, intent from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DOMAIN, TIMER_DATA +from .timers import ( + CancelTimerIntentHandler, + DecreaseTimerIntentHandler, + IncreaseTimerIntentHandler, + PauseTimerIntentHandler, + StartTimerIntentHandler, + TimerManager, + TimerStatusIntentHandler, + UnpauseTimerIntentHandler, + async_register_timer_handler, +) _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +__all__ = [ + "async_register_timer_handler", + "DOMAIN", +] + 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( @@ -74,6 +92,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: NevermindIntentHandler(), ) intent.async_register(hass, SetPositionIntentHandler()) + intent.async_register(hass, StartTimerIntentHandler()) + intent.async_register(hass, CancelTimerIntentHandler()) + 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()) return True diff --git a/homeassistant/components/intent/const.py b/homeassistant/components/intent/const.py index 61b97c20537..56b6d83bade 100644 --- a/homeassistant/components/intent/const.py +++ b/homeassistant/components/intent/const.py @@ -1,3 +1,5 @@ """Constants for the Intent integration.""" DOMAIN = "intent" + +TIMER_DATA = f"{DOMAIN}.timer" diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py new file mode 100644 index 00000000000..5aac199f32b --- /dev/null +++ b/homeassistant/components/intent/timers.py @@ -0,0 +1,812 @@ +"""Timer implementation for intents.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from functools import cached_property +import logging +import time +from typing import Any + +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + area_registry as ar, + config_validation as cv, + device_registry as dr, + intent, +) +from homeassistant.util import ulid + +from .const import TIMER_DATA + +_LOGGER = logging.getLogger(__name__) + +TIMER_NOT_FOUND_RESPONSE = "timer_not_found" +MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched" + + +@dataclass +class TimerInfo: + """Information for a single timer.""" + + id: str + """Unique id of the timer.""" + + name: str | None + """User-provided name for timer.""" + + seconds: int + """Total number of seconds the timer should run for.""" + + device_id: str | None + """Id of the device where the timer was set.""" + + start_hours: int | None + """Number of hours the timer should run as given by the user.""" + + start_minutes: int | None + """Number of minutes the timer should run as given by the user.""" + + start_seconds: int | None + """Number of seconds the timer should run as given by the user.""" + + created_at: int + """Timestamp when timer was created (time.monotonic_ns)""" + + updated_at: int + """Timestamp when timer was last updated (time.monotonic_ns)""" + + language: str + """Language of command used to set the timer.""" + + is_active: bool = True + """True if timer is ticking down.""" + + area_id: str | None = None + """Id of area that the device belongs to.""" + + floor_id: str | None = None + """Id of floor that the device's area belongs to.""" + + @property + def seconds_left(self) -> int: + """Return number of seconds left on the timer.""" + if not self.is_active: + return self.seconds + + now = time.monotonic_ns() + seconds_running = int((now - self.updated_at) / 1e9) + return max(0, self.seconds - seconds_running) + + @cached_property + def name_normalized(self) -> str | None: + """Return normalized timer name.""" + if self.name is None: + return None + + return self.name.strip().casefold() + + def cancel(self) -> None: + """Cancel the timer.""" + self.seconds = 0 + self.updated_at = time.monotonic_ns() + self.is_active = False + + def pause(self) -> None: + """Pause the timer.""" + self.seconds = self.seconds_left + self.updated_at = time.monotonic_ns() + self.is_active = False + + def unpause(self) -> None: + """Unpause the timer.""" + self.updated_at = time.monotonic_ns() + self.is_active = True + + def add_time(self, seconds: int) -> None: + """Add time to the timer. + + Seconds may be negative to remove time instead. + """ + self.seconds = max(0, self.seconds_left + seconds) + self.updated_at = time.monotonic_ns() + + def finish(self) -> None: + """Finish the timer.""" + self.seconds = 0 + self.updated_at = time.monotonic_ns() + self.is_active = False + + +class TimerEventType(StrEnum): + """Event type in timer handler.""" + + STARTED = "started" + """Timer has started.""" + + UPDATED = "updated" + """Timer has been increased, decreased, paused, or unpaused.""" + + CANCELLED = "cancelled" + """Timer has been cancelled.""" + + FINISHED = "finished" + """Timer finished without being cancelled.""" + + +TimerHandler = Callable[[TimerEventType, TimerInfo], None] + + +class TimerNotFoundError(intent.IntentHandleError): + """Error when a timer could not be found by name or start time.""" + + def __init__(self) -> None: + """Initialize error.""" + super().__init__("Timer not found", TIMER_NOT_FOUND_RESPONSE) + + +class MultipleTimersMatchedError(intent.IntentHandleError): + """Error when multiple timers matched name or start time.""" + + def __init__(self) -> None: + """Initialize error.""" + super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE) + + +class TimerManager: + """Manager for intent timers.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize timer manager.""" + self.hass = hass + + # timer id -> timer + self.timers: dict[str, TimerInfo] = {} + self.timer_tasks: dict[str, asyncio.Task] = {} + + self.handlers: list[TimerHandler] = [] + + def register_handler(self, handler: TimerHandler) -> Callable[[], None]: + """Register a timer handler. + + Returns a callable to unregister. + """ + self.handlers.append(handler) + return lambda: self.handlers.remove(handler) + + def start_timer( + self, + hours: int | None, + minutes: int | None, + seconds: int | None, + language: str, + device_id: str | None, + name: str | None = None, + ) -> str: + """Start a timer.""" + total_seconds = 0 + if hours is not None: + total_seconds += 60 * 60 * hours + + if minutes is not None: + total_seconds += 60 * minutes + + if seconds is not None: + total_seconds += seconds + + timer_id = ulid.ulid_now() + created_at = time.monotonic_ns() + timer = TimerInfo( + id=timer_id, + name=name, + start_hours=hours, + start_minutes=minutes, + start_seconds=seconds, + seconds=total_seconds, + language=language, + device_id=device_id, + created_at=created_at, + updated_at=created_at, + ) + + # Fill in area/floor info + device_registry = dr.async_get(self.hass) + if device_id and (device := device_registry.async_get(device_id)): + timer.area_id = device.area_id + area_registry = ar.async_get(self.hass) + if device.area_id and ( + area := area_registry.async_get_area(device.area_id) + ): + timer.floor_id = area.floor_id + + self.timers[timer_id] = timer + self.timer_tasks[timer_id] = self.hass.async_create_background_task( + self._wait_for_timer(timer_id, total_seconds, created_at), + name=f"Timer {timer_id}", + ) + + for handler in self.handlers: + handler(TimerEventType.STARTED, timer) + + _LOGGER.debug( + "Timer started: id=%s, name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s", + timer_id, + name, + hours, + minutes, + seconds, + device_id, + ) + + return timer_id + + async def _wait_for_timer( + self, timer_id: str, seconds: int, updated_at: int + ) -> None: + """Sleep until timer is up. Timer is only finished if it hasn't been updated.""" + try: + await asyncio.sleep(seconds) + if (timer := self.timers.get(timer_id)) and ( + timer.updated_at == updated_at + ): + self._timer_finished(timer_id) + except asyncio.CancelledError: + pass # expected when timer is updated + + def cancel_timer(self, timer_id: str) -> None: + """Cancel a timer.""" + timer = self.timers.pop(timer_id, None) + if timer is None: + raise TimerNotFoundError + + if timer.is_active: + task = self.timer_tasks.pop(timer_id) + task.cancel() + + timer.cancel() + + for handler in self.handlers: + handler(TimerEventType.CANCELLED, timer) + + _LOGGER.debug( + "Timer cancelled: id=%s, name=%s, seconds_left=%s, device_id=%s", + timer_id, + timer.name, + timer.seconds_left, + timer.device_id, + ) + + def add_time(self, timer_id: str, seconds: int) -> None: + """Add time to a timer.""" + timer = self.timers.get(timer_id) + if timer is None: + raise TimerNotFoundError + + if seconds == 0: + # Don't bother cancelling and recreating the timer task + return + + timer.add_time(seconds) + if timer.is_active: + task = self.timer_tasks.pop(timer_id) + task.cancel() + self.timer_tasks[timer_id] = self.hass.async_create_background_task( + self._wait_for_timer(timer_id, timer.seconds, timer.updated_at), + name=f"Timer {timer_id}", + ) + + for handler in self.handlers: + handler(TimerEventType.UPDATED, timer) + + if seconds > 0: + log_verb = "increased" + log_seconds = seconds + else: + log_verb = "decreased" + log_seconds = -seconds + + _LOGGER.debug( + "Timer %s by %s second(s): id=%s, name=%s, seconds_left=%s, device_id=%s", + log_verb, + log_seconds, + timer_id, + timer.name, + timer.seconds_left, + timer.device_id, + ) + + def remove_time(self, timer_id: str, seconds: int) -> None: + """Remove time from a timer.""" + self.add_time(timer_id, -seconds) + + def pause_timer(self, timer_id: str) -> None: + """Pauses a timer.""" + timer = self.timers.get(timer_id) + if timer is None: + raise TimerNotFoundError + + if not timer.is_active: + # Already paused + return + + timer.pause() + task = self.timer_tasks.pop(timer_id) + task.cancel() + + for handler in self.handlers: + handler(TimerEventType.UPDATED, timer) + + _LOGGER.debug( + "Timer paused: id=%s, name=%s, seconds_left=%s, device_id=%s", + timer_id, + timer.name, + timer.seconds_left, + timer.device_id, + ) + + def unpause_timer(self, timer_id: str) -> None: + """Unpause a timer.""" + timer = self.timers.get(timer_id) + if timer is None: + raise TimerNotFoundError + + if timer.is_active: + # Already unpaused + return + + timer.unpause() + self.timer_tasks[timer_id] = self.hass.async_create_background_task( + self._wait_for_timer(timer_id, timer.seconds_left, timer.updated_at), + name=f"Timer {timer.id}", + ) + + for handler in self.handlers: + handler(TimerEventType.UPDATED, timer) + + _LOGGER.debug( + "Timer unpaused: id=%s, name=%s, seconds_left=%s, device_id=%s", + timer_id, + timer.name, + timer.seconds_left, + timer.device_id, + ) + + def _timer_finished(self, timer_id: str) -> None: + """Call event handlers when a timer finishes.""" + timer = self.timers.pop(timer_id) + + timer.finish() + for handler in self.handlers: + handler(TimerEventType.FINISHED, timer) + + _LOGGER.debug( + "Timer finished: id=%s, name=%s, device_id=%s", + timer_id, + timer.name, + timer.device_id, + ) + + +@callback +def async_register_timer_handler( + hass: HomeAssistant, handler: TimerHandler +) -> Callable[[], None]: + """Register a handler for timer events. + + Returns a callable to unregister. + """ + timer_manager: TimerManager = hass.data[TIMER_DATA] + return timer_manager.register_handler(handler) + + +# ----------------------------------------------------------------------------- + + +def _find_timer(hass: HomeAssistant, slots: dict[str, Any]) -> TimerInfo: + """Match a single timer with constraints or raise an error.""" + timer_manager: TimerManager = hass.data[TIMER_DATA] + matching_timers: list[TimerInfo] = list(timer_manager.timers.values()) + has_filter = False + + # Search by name first + name: str | None = None + if "name" in slots: + has_filter = True + name = slots["name"]["value"] + assert name is not None + name_norm = name.strip().casefold() + + matching_timers = [t for t in matching_timers if t.name_normalized == name_norm] + if len(matching_timers) == 1: + # Only 1 match + return matching_timers[0] + + # Use starting time to disambiguate + start_hours: int | None = None + if "start_hours" in slots: + start_hours = int(slots["start_hours"]["value"]) + + start_minutes: int | None = None + if "start_minutes" in slots: + start_minutes = int(slots["start_minutes"]["value"]) + + start_seconds: int | None = None + if "start_seconds" in slots: + start_seconds = int(slots["start_seconds"]["value"]) + + if ( + (start_hours is not None) + or (start_minutes is not None) + or (start_seconds is not None) + ): + has_filter = True + matching_timers = [ + t + for t in matching_timers + if (t.start_hours == start_hours) + and (t.start_minutes == start_minutes) + and (t.start_seconds == start_seconds) + ] + + if len(matching_timers) == 1: + # Only 1 match remaining + return matching_timers[0] + + if (not has_filter) and (len(matching_timers) == 1): + # Only 1 match remaining with no filter + return matching_timers[0] + + # Use device id + device_id: str | None = None + if matching_timers and ("device_id" in slots): + device_id = slots["device_id"]["value"] + assert device_id is not None + matching_device_timers = [ + t for t in matching_timers if (t.device_id == device_id) + ] + if len(matching_device_timers) == 1: + # Only 1 match remaining + return matching_device_timers[0] + + # Try area/floor + device_registry = dr.async_get(hass) + area_registry = ar.async_get(hass) + if ( + (device := device_registry.async_get(device_id)) + and device.area_id + and (area := area_registry.async_get_area(device.area_id)) + ): + # Try area + matching_area_timers = [ + t for t in matching_timers if (t.area_id == area.id) + ] + if len(matching_area_timers) == 1: + # Only 1 match remaining + return matching_area_timers[0] + + # Try floor + matching_floor_timers = [ + t for t in matching_timers if (t.floor_id == area.floor_id) + ] + if len(matching_floor_timers) == 1: + # Only 1 match remaining + return matching_floor_timers[0] + + if matching_timers: + raise MultipleTimersMatchedError + + _LOGGER.warning( + "Timer not found: name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s", + name, + start_hours, + start_minutes, + start_seconds, + device_id, + ) + + raise TimerNotFoundError + + +def _find_timers(hass: HomeAssistant, slots: dict[str, Any]) -> list[TimerInfo]: + """Match multiple timers with constraints or raise an error.""" + timer_manager: TimerManager = hass.data[TIMER_DATA] + matching_timers: list[TimerInfo] = list(timer_manager.timers.values()) + + # Filter by name first + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + assert name is not None + name_norm = name.strip().casefold() + + matching_timers = [t for t in matching_timers if t.name_normalized == name_norm] + if not matching_timers: + # No matches + return matching_timers + + # Use starting time to filter, if present + start_hours: int | None = None + if "start_hours" in slots: + start_hours = int(slots["start_hours"]["value"]) + + start_minutes: int | None = None + if "start_minutes" in slots: + start_minutes = int(slots["start_minutes"]["value"]) + + start_seconds: int | None = None + if "start_seconds" in slots: + start_seconds = int(slots["start_seconds"]["value"]) + + if ( + (start_hours is not None) + or (start_minutes is not None) + or (start_seconds is not None) + ): + matching_timers = [ + t + for t in matching_timers + if (t.start_hours == start_hours) + and (t.start_minutes == start_minutes) + and (t.start_seconds == start_seconds) + ] + if not matching_timers: + # No matches + return matching_timers + + if "device_id" not in slots: + # Can't re-order based on area/floor + return matching_timers + + # Use device id to order remaining timers + device_id: str = slots["device_id"]["value"] + device_registry = dr.async_get(hass) + device = device_registry.async_get(device_id) + if (device is None) or (device.area_id is None): + return matching_timers + + area_registry = ar.async_get(hass) + area = area_registry.async_get_area(device.area_id) + if area is None: + return matching_timers + + def area_floor_sort(timer: TimerInfo) -> int: + """Sort by area, then floor.""" + if timer.area_id == area.id: + return -2 + + if timer.floor_id == area.floor_id: + return -1 + + return 0 + + matching_timers.sort(key=area_floor_sort) + + return matching_timers + + +def _get_total_seconds(slots: dict[str, Any]) -> int: + """Return the total number of seconds from hours/minutes/seconds slots.""" + total_seconds = 0 + if "hours" in slots: + total_seconds += 60 * 60 * int(slots["hours"]["value"]) + + if "minutes" in slots: + total_seconds += 60 * int(slots["minutes"]["value"]) + + if "seconds" in slots: + total_seconds += int(slots["seconds"]["value"]) + + return total_seconds + + +class StartTimerIntentHandler(intent.IntentHandler): + """Intent handler for starting a new timer.""" + + intent_type = intent.INTENT_START_TIMER + slot_schema = { + vol.Required(vol.Any("hours", "minutes", "seconds")): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("device_id"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + device_id: str | None = None + if "device_id" in slots: + device_id = slots["device_id"]["value"] + + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + + hours: int | None = None + if "hours" in slots: + hours = int(slots["hours"]["value"]) + + minutes: int | None = None + if "minutes" in slots: + minutes = int(slots["minutes"]["value"]) + + seconds: int | None = None + if "seconds" in slots: + seconds = int(slots["seconds"]["value"]) + + timer_manager.start_timer( + hours, + minutes, + seconds, + language=intent_obj.language, + device_id=device_id, + name=name, + ) + + return intent_obj.create_response() + + +class CancelTimerIntentHandler(intent.IntentHandler): + """Intent handler for cancelling a timer.""" + + intent_type = intent.INTENT_CANCEL_TIMER + slot_schema = { + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("device_id"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + timer = _find_timer(hass, slots) + timer_manager.cancel_timer(timer.id) + + return intent_obj.create_response() + + +class IncreaseTimerIntentHandler(intent.IntentHandler): + """Intent handler for increasing the time of a timer.""" + + intent_type = intent.INTENT_INCREASE_TIMER + slot_schema = { + vol.Any("hours", "minutes", "seconds"): cv.positive_int, + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("device_id"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + total_seconds = _get_total_seconds(slots) + timer = _find_timer(hass, slots) + timer_manager.add_time(timer.id, total_seconds) + + return intent_obj.create_response() + + +class DecreaseTimerIntentHandler(intent.IntentHandler): + """Intent handler for decreasing the time of a timer.""" + + intent_type = intent.INTENT_DECREASE_TIMER + slot_schema = { + vol.Required(vol.Any("hours", "minutes", "seconds")): cv.positive_int, + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("device_id"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + total_seconds = _get_total_seconds(slots) + timer = _find_timer(hass, slots) + timer_manager.remove_time(timer.id, total_seconds) + + return intent_obj.create_response() + + +class PauseTimerIntentHandler(intent.IntentHandler): + """Intent handler for pausing a running timer.""" + + intent_type = intent.INTENT_PAUSE_TIMER + slot_schema = { + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("device_id"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + timer = _find_timer(hass, slots) + timer_manager.pause_timer(timer.id) + + return intent_obj.create_response() + + +class UnpauseTimerIntentHandler(intent.IntentHandler): + """Intent handler for unpausing a paused timer.""" + + intent_type = intent.INTENT_UNPAUSE_TIMER + slot_schema = { + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("device_id"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + timer = _find_timer(hass, slots) + timer_manager.unpause_timer(timer.id) + + return intent_obj.create_response() + + +class TimerStatusIntentHandler(intent.IntentHandler): + """Intent handler for reporting the status of a timer.""" + + intent_type = intent.INTENT_TIMER_STATUS + slot_schema = { + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("device_id"): cv.string, + } + + 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) + + statuses: list[dict[str, Any]] = [] + for timer in _find_timers(hass, slots): + total_seconds = timer.seconds_left + + minutes, seconds = divmod(total_seconds, 60) + hours, minutes = divmod(minutes, 60) + + statuses.append( + { + ATTR_ID: timer.id, + ATTR_NAME: timer.name or "", + ATTR_DEVICE_ID: timer.device_id or "", + "language": timer.language, + "start_hours": timer.start_hours or 0, + "start_minutes": timer.start_minutes or 0, + "start_seconds": timer.start_seconds or 0, + "is_active": timer.is_active, + "hours_left": hours, + "minutes_left": minutes, + "seconds_left": seconds, + "total_seconds_left": total_seconds, + } + ) + + response = intent_obj.create_response() + response.async_set_speech_slots({"timers": statuses}) + + return response diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index daf0229e8ce..fd06314972d 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -43,6 +43,13 @@ INTENT_TOGGLE = "HassToggle" INTENT_GET_STATE = "HassGetState" INTENT_NEVERMIND = "HassNevermind" INTENT_SET_POSITION = "HassSetPosition" +INTENT_START_TIMER = "HassStartTimer" +INTENT_CANCEL_TIMER = "HassCancelTimer" +INTENT_INCREASE_TIMER = "HassIncreaseTimer" +INTENT_DECREASE_TIMER = "HassDecreaseTimer" +INTENT_PAUSE_TIMER = "HassPauseTimer" +INTENT_UNPAUSE_TIMER = "HassUnpauseTimer" +INTENT_TIMER_STATUS = "HassTimerStatus" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) @@ -57,7 +64,8 @@ SPEECH_TYPE_SSML = "ssml" def async_register(hass: HomeAssistant, handler: IntentHandler) -> None: """Register an intent with Home Assistant.""" if (intents := hass.data.get(DATA_KEY)) is None: - intents = hass.data[DATA_KEY] = {} + intents = {} + hass.data[DATA_KEY] = intents assert handler.intent_type is not None, "intent_type cannot be None" @@ -141,6 +149,11 @@ class InvalidSlotInfo(IntentError): class IntentHandleError(IntentError): """Error while handling intent.""" + def __init__(self, message: str = "", response_key: str | None = None) -> None: + """Initialize error.""" + super().__init__(message) + self.response_key = response_key + class IntentUnexpectedError(IntentError): """Unexpected error while handling intent.""" @@ -1207,6 +1220,7 @@ class IntentResponse: self.failed_results: list[IntentResponseTarget] = [] self.matched_states: list[State] = [] self.unmatched_states: list[State] = [] + self.speech_slots: dict[str, Any] = {} if (self.intent is not None) and (self.intent.category == IntentCategory.QUERY): # speech will be the answer to the query @@ -1282,6 +1296,11 @@ class IntentResponse: self.matched_states = matched_states self.unmatched_states = unmatched_states or [] + @callback + def async_set_speech_slots(self, speech_slots: dict[str, Any]) -> None: + """Set slots that will be used in the response template of the default agent.""" + self.speech_slots = speech_slots + @callback def as_dict(self) -> dict[str, Any]: """Return a dictionary representation of an intent response.""" diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py new file mode 100644 index 00000000000..b88112ab6c8 --- /dev/null +++ b/tests/components/intent/test_timers.py @@ -0,0 +1,1005 @@ +"""Tests for intent timers.""" + +import asyncio + +import pytest + +from homeassistant.components.intent.timers import ( + MultipleTimersMatchedError, + TimerEventType, + TimerInfo, + TimerManager, + TimerNotFoundError, + async_register_timer_handler, +) +from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + floor_registry as fr, + intent, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture +async def init_components(hass: HomeAssistant) -> None: + """Initialize required components for tests.""" + assert await async_setup_component(hass, "intent", {}) + + +async def test_start_finish_timer(hass: HomeAssistant, init_components) -> None: + """Test starting a timer and having it finish.""" + device_id = "test_device" + timer_name = "test timer" + started_event = asyncio.Event() + finished_event = asyncio.Event() + + timer_id: str | None = None + + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_id + + assert timer.name == timer_name + assert timer.device_id == device_id + assert timer.start_hours is None + assert timer.start_minutes is None + assert timer.start_seconds == 0 + assert timer.seconds_left == 0 + + if event_type == TimerEventType.STARTED: + timer_id = timer.id + started_event.set() + elif event_type == TimerEventType.FINISHED: + assert timer.id == timer_id + finished_event.set() + + async_register_timer_handler(hass, handle_timer) + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "name": {"value": timer_name}, + "device_id": {"value": device_id}, + "seconds": {"value": 0}, + }, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await asyncio.gather(started_event.wait(), finished_event.wait()) + + +async def test_cancel_timer(hass: HomeAssistant, init_components) -> None: + """Test cancelling a timer.""" + device_id = "test_device" + timer_name: str | None = None + started_event = asyncio.Event() + cancelled_event = asyncio.Event() + + timer_id: str | None = None + + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_id + + assert timer.device_id == device_id + assert timer.start_hours == 1 + assert timer.start_minutes == 2 + assert timer.start_seconds == 3 + + if timer_name is not None: + assert timer.name == timer_name + + if event_type == TimerEventType.STARTED: + timer_id = timer.id + assert ( + timer.seconds_left + == (60 * 60 * timer.start_hours) + + (60 * timer.start_minutes) + + timer.start_seconds + ) + started_event.set() + elif event_type == TimerEventType.CANCELLED: + assert timer.id == timer_id + assert timer.seconds_left == 0 + cancelled_event.set() + + async_register_timer_handler(hass, handle_timer) + + # Cancel by starting time + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "device_id": {"value": device_id}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + ) + + async with asyncio.timeout(1): + await started_event.wait() + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + { + "start_hours": {"value": 1}, + "start_minutes": {"value": 2}, + "start_seconds": {"value": 3}, + }, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + # Cancel by name + timer_name = "test timer" + started_event.clear() + cancelled_event.clear() + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "device_id": {"value": device_id}, + "name": {"value": timer_name}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + ) + + async with asyncio.timeout(1): + await started_event.wait() + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"name": {"value": timer_name}}, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + +async def test_increase_timer(hass: HomeAssistant, init_components) -> None: + """Test increasing the time of a running timer.""" + device_id = "test_device" + started_event = asyncio.Event() + updated_event = asyncio.Event() + cancelled_event = asyncio.Event() + + timer_name = "test timer" + timer_id: str | None = None + original_total_seconds = -1 + + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_id, original_total_seconds + + assert timer.device_id == device_id + assert timer.start_hours == 1 + assert timer.start_minutes == 2 + assert timer.start_seconds == 3 + + if timer_name is not None: + assert timer.name == timer_name + + if event_type == TimerEventType.STARTED: + timer_id = timer.id + original_total_seconds = ( + (60 * 60 * timer.start_hours) + + (60 * timer.start_minutes) + + timer.start_seconds + ) + started_event.set() + elif event_type == TimerEventType.UPDATED: + assert timer.id == timer_id + + # Timer was increased + assert timer.seconds_left > original_total_seconds + updated_event.set() + elif event_type == TimerEventType.CANCELLED: + assert timer.id == timer_id + cancelled_event.set() + + async_register_timer_handler(hass, handle_timer) + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "device_id": {"value": device_id}, + "name": {"value": timer_name}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + + # Add 30 seconds to the timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_INCREASE_TIMER, + { + "device_id": {"value": device_id}, + "start_hours": {"value": 1}, + "start_minutes": {"value": 2}, + "start_seconds": {"value": 3}, + "hours": {"value": 1}, + "minutes": {"value": 5}, + "seconds": {"value": 30}, + }, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await updated_event.wait() + + # Cancel the timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"name": {"value": timer_name}}, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + +async def test_decrease_timer(hass: HomeAssistant, init_components) -> None: + """Test decreasing the time of a running timer.""" + device_id = "test_device" + started_event = asyncio.Event() + updated_event = asyncio.Event() + cancelled_event = asyncio.Event() + + timer_name = "test timer" + timer_id: str | None = None + original_total_seconds = 0 + + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_id, original_total_seconds + + assert timer.device_id == device_id + assert timer.start_hours == 1 + assert timer.start_minutes == 2 + assert timer.start_seconds == 3 + + if timer_name is not None: + assert timer.name == timer_name + + if event_type == TimerEventType.STARTED: + timer_id = timer.id + original_total_seconds = ( + (60 * 60 * timer.start_hours) + + (60 * timer.start_minutes) + + timer.start_seconds + ) + started_event.set() + elif event_type == TimerEventType.UPDATED: + assert timer.id == timer_id + + # Timer was decreased + assert timer.seconds_left <= (original_total_seconds - 30) + + updated_event.set() + elif event_type == TimerEventType.CANCELLED: + assert timer.id == timer_id + cancelled_event.set() + + async_register_timer_handler(hass, handle_timer) + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "device_id": {"value": device_id}, + "name": {"value": timer_name}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + + # Remove 30 seconds from the timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_DECREASE_TIMER, + { + "device_id": {"value": device_id}, + "start_hours": {"value": 1}, + "start_minutes": {"value": 2}, + "start_seconds": {"value": 3}, + "seconds": {"value": 30}, + }, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + + # Cancel the timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"name": {"value": timer_name}}, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + +async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) -> None: + """Test decreasing the time of a running timer below 0 seconds.""" + started_event = asyncio.Event() + updated_event = asyncio.Event() + finished_event = asyncio.Event() + + timer_id: str | None = None + original_total_seconds = 0 + + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_id, original_total_seconds + + assert timer.device_id is None + assert timer.name is None + assert timer.start_hours == 1 + assert timer.start_minutes == 2 + assert timer.start_seconds == 3 + + if event_type == TimerEventType.STARTED: + timer_id = timer.id + original_total_seconds = ( + (60 * 60 * timer.start_hours) + + (60 * timer.start_minutes) + + timer.start_seconds + ) + started_event.set() + elif event_type == TimerEventType.UPDATED: + assert timer.id == timer_id + + # Timer was decreased below zero + assert timer.seconds_left == 0 + + updated_event.set() + elif event_type == TimerEventType.FINISHED: + assert timer.id == timer_id + finished_event.set() + + async_register_timer_handler(hass, handle_timer) + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + + # Remove more time than was on the timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_DECREASE_TIMER, + { + "start_hours": {"value": 1}, + "start_minutes": {"value": 2}, + "start_seconds": {"value": 3}, + "seconds": {"value": original_total_seconds + 1}, + }, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await asyncio.gather( + started_event.wait(), updated_event.wait(), finished_event.wait() + ) + + +async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None: + """Test finding a timer with the wrong info.""" + # Start a 5 minute timer for pizza + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "pizza"}, "minutes": {"value": 5}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Right name + result = await intent.async_handle( + hass, + "test", + intent.INTENT_INCREASE_TIMER, + {"name": {"value": "PIZZA "}, "minutes": {"value": 1}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Wrong name + with pytest.raises(intent.IntentError): + await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"name": {"value": "does-not-exist"}}, + ) + + # Right start time + result = await intent.async_handle( + hass, + "test", + intent.INTENT_INCREASE_TIMER, + {"start_minutes": {"value": 5}, "minutes": {"value": 1}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Wrong start time + with pytest.raises(intent.IntentError): + await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"start_minutes": {"value": 1}}, + ) + + +async def test_disambiguation( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test finding a timer by disambiguating with area/floor.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + # Alice is upstairs in the study + floor_upstairs = floor_registry.async_create("upstairs") + area_study = area_registry.async_create("study") + area_study = area_registry.async_update( + area_study.id, floor_id=floor_upstairs.floor_id + ) + device_alice_study = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "alice")}, + ) + device_registry.async_update_device(device_alice_study.id, area_id=area_study.id) + + # Bob is downstairs in the kitchen + floor_downstairs = floor_registry.async_create("downstairs") + area_kitchen = area_registry.async_create("kitchen") + area_kitchen = area_registry.async_update( + area_kitchen.id, floor_id=floor_downstairs.floor_id + ) + device_bob_kitchen_1 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "bob")}, + ) + device_registry.async_update_device( + device_bob_kitchen_1.id, area_id=area_kitchen.id + ) + + # Alice: set a 3 minute timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"device_id": {"value": device_alice_study.id}, "minutes": {"value": 3}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Bob: set a 3 minute timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"device_id": {"value": device_bob_kitchen_1.id}, "minutes": {"value": 3}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Alice should hear her timer listed first + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"device_id": {"value": device_alice_study.id}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 2 + assert timers[0].get(ATTR_DEVICE_ID) == device_alice_study.id + assert timers[1].get(ATTR_DEVICE_ID) == device_bob_kitchen_1.id + + # Bob should hear his timer listed first + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"device_id": {"value": device_bob_kitchen_1.id}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 2 + assert timers[0].get(ATTR_DEVICE_ID) == device_bob_kitchen_1.id + assert timers[1].get(ATTR_DEVICE_ID) == device_alice_study.id + + # Listen for timer cancellation + cancelled_event = asyncio.Event() + timer_info: TimerInfo | None = None + + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_info + + if event_type == TimerEventType.CANCELLED: + timer_info = timer + cancelled_event.set() + + async_register_timer_handler(hass, handle_timer) + + # Alice: cancel my timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"device_id": {"value": device_alice_study.id}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + # Verify this is the 3 minute timer from Alice + assert timer_info is not None + assert timer_info.device_id == device_alice_study.id + assert timer_info.start_minutes == 3 + + # Cancel Bob's timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"device_id": {"value": device_bob_kitchen_1.id}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Add two new devices in two new areas, one upstairs and one downstairs + area_bedroom = area_registry.async_create("bedroom") + area_bedroom = area_registry.async_update( + area_bedroom.id, floor_id=floor_upstairs.floor_id + ) + device_alice_bedroom = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "alice-2")}, + ) + device_registry.async_update_device( + device_alice_bedroom.id, area_id=area_bedroom.id + ) + + area_living_room = area_registry.async_create("living_room") + area_living_room = area_registry.async_update( + area_living_room.id, floor_id=floor_downstairs.floor_id + ) + device_bob_living_room = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "bob-2")}, + ) + device_registry.async_update_device( + device_bob_living_room.id, area_id=area_living_room.id + ) + + # Alice: set a 3 minute timer (study) + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"device_id": {"value": device_alice_study.id}, "minutes": {"value": 3}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Alice: set a 3 minute timer (bedroom) + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "device_id": {"value": device_alice_bedroom.id}, + "minutes": {"value": 3}, + }, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Bob: set a 3 minute timer (kitchen) + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"device_id": {"value": device_bob_kitchen_1.id}, "minutes": {"value": 3}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Bob: set a 3 minute timer (living room) + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"device_id": {"value": device_bob_living_room.id}, "minutes": {"value": 3}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Alice should hear the timer in her area first, then on her floor, then + # elsewhere. + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"device_id": {"value": device_alice_study.id}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 4 + assert timers[0].get(ATTR_DEVICE_ID) == device_alice_study.id + assert timers[1].get(ATTR_DEVICE_ID) == device_alice_bedroom.id + assert timers[2].get(ATTR_DEVICE_ID) == device_bob_kitchen_1.id + assert timers[3].get(ATTR_DEVICE_ID) == device_bob_living_room.id + + # Alice cancels the study timer from study + cancelled_event.clear() + timer_info = None + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"device_id": {"value": device_alice_study.id}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + # Verify this is the 3 minute timer from Alice in the study + assert timer_info is not None + assert timer_info.device_id == device_alice_study.id + assert timer_info.start_minutes == 3 + + # Trying to cancel the remaining two timers without area/floor info fails + with pytest.raises(MultipleTimersMatchedError): + await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {}, + ) + + # Alice cancels the bedroom timer from study (same floor) + cancelled_event.clear() + timer_info = None + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"device_id": {"value": device_alice_study.id}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + # Verify this is the 3 minute timer from Alice in the bedroom + assert timer_info is not None + assert timer_info.device_id == device_alice_bedroom.id + assert timer_info.start_minutes == 3 + + # Add a second device in the kitchen + device_bob_kitchen_2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "bob-3")}, + ) + device_registry.async_update_device( + device_bob_kitchen_2.id, area_id=area_kitchen.id + ) + + # Bob cancels the kitchen timer from a different device + cancelled_event.clear() + timer_info = None + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"device_id": {"value": device_bob_kitchen_2.id}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + assert timer_info is not None + assert timer_info.device_id == device_bob_kitchen_1.id + assert timer_info.start_minutes == 3 + + # Bob cancels the living room timer from the kitchen + cancelled_event.clear() + timer_info = None + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"device_id": {"value": device_bob_kitchen_2.id}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + assert timer_info is not None + assert timer_info.device_id == device_bob_living_room.id + assert timer_info.start_minutes == 3 + + +async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None: + """Test pausing and unpausing a running timer.""" + started_event = asyncio.Event() + updated_event = asyncio.Event() + + expected_active = True + + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + if event_type == TimerEventType.STARTED: + started_event.set() + elif event_type == TimerEventType.UPDATED: + assert timer.is_active == expected_active + updated_event.set() + + async_register_timer_handler(hass, handle_timer) + + result = await intent.async_handle( + hass, "test", intent.INTENT_START_TIMER, {"minutes": {"value": 5}} + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + + # Pause the timer + expected_active = False + result = await intent.async_handle(hass, "test", intent.INTENT_PAUSE_TIMER, {}) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await updated_event.wait() + + # Pausing again will not fire the event + updated_event.clear() + result = await intent.async_handle(hass, "test", intent.INTENT_PAUSE_TIMER, {}) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + assert not updated_event.is_set() + + # Unpause the timer + updated_event.clear() + expected_active = True + result = await intent.async_handle(hass, "test", intent.INTENT_UNPAUSE_TIMER, {}) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await updated_event.wait() + + # Unpausing again will not fire the event + updated_event.clear() + result = await intent.async_handle(hass, "test", intent.INTENT_UNPAUSE_TIMER, {}) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + assert not updated_event.is_set() + + +async def test_timer_not_found(hass: HomeAssistant) -> None: + """Test invalid timer ids raise TimerNotFoundError.""" + timer_manager = TimerManager(hass) + + with pytest.raises(TimerNotFoundError): + timer_manager.cancel_timer("does-not-exist") + + with pytest.raises(TimerNotFoundError): + timer_manager.add_time("does-not-exist", 1) + + with pytest.raises(TimerNotFoundError): + timer_manager.remove_time("does-not-exist", 1) + + with pytest.raises(TimerNotFoundError): + timer_manager.pause_timer("does-not-exist") + + with pytest.raises(TimerNotFoundError): + timer_manager.unpause_timer("does-not-exist") + + +async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> None: + """Test getting the status of named timers.""" + started_event = asyncio.Event() + num_started = 0 + + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal num_started + + if event_type == TimerEventType.STARTED: + num_started += 1 + if num_started == 4: + started_event.set() + + async_register_timer_handler(hass, handle_timer) + + # Start timers with names + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "pizza"}, "minutes": {"value": 10}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "pizza"}, "minutes": {"value": 15}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "cookies"}, "minutes": {"value": 20}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "chicken"}, "hours": {"value": 2}, "seconds": {"value": 30}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Wait for all timers to start + async with asyncio.timeout(1): + await started_event.wait() + + # No constraints returns all timers + result = await intent.async_handle(hass, "test", intent.INTENT_TIMER_STATUS, {}) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 4 + assert {t.get(ATTR_NAME) for t in timers} == {"pizza", "cookies", "chicken"} + + # Get status of cookie timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"name": {"value": "cookies"}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 1 + assert timers[0].get(ATTR_NAME) == "cookies" + assert timers[0].get("start_minutes") == 20 + + # Get status of pizza timers + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"name": {"value": "pizza"}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 2 + assert timers[0].get(ATTR_NAME) == "pizza" + assert timers[1].get(ATTR_NAME) == "pizza" + assert {timers[0].get("start_minutes"), timers[1].get("start_minutes")} == {10, 15} + + # Get status of one pizza timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"name": {"value": "pizza"}, "start_minutes": {"value": 10}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 1 + assert timers[0].get(ATTR_NAME) == "pizza" + assert timers[0].get("start_minutes") == 10 + + # Get status of one chicken timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + { + "name": {"value": "chicken"}, + "start_hours": {"value": 2}, + "start_seconds": {"value": 30}, + }, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 1 + assert timers[0].get(ATTR_NAME) == "chicken" + assert timers[0].get("start_hours") == 2 + assert timers[0].get("start_minutes") == 0 + assert timers[0].get("start_seconds") == 30 + + # Wrong name results in an empty list + result = await intent.async_handle( + hass, "test", intent.INTENT_TIMER_STATUS, {"name": {"value": "does-not-exist"}} + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 0 + + # Wrong start time results in an empty list + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + { + "start_hours": {"value": 100}, + "start_minutes": {"value": 100}, + "start_seconds": {"value": 100}, + }, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 0