diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index feac4ef05d9..6dbe98429f3 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -45,6 +45,8 @@ from .timers import ( IncreaseTimerIntentHandler, PauseTimerIntentHandler, StartTimerIntentHandler, + TimerEventType, + TimerInfo, TimerManager, TimerStatusIntentHandler, UnpauseTimerIntentHandler, @@ -57,6 +59,8 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) __all__ = [ "async_register_timer_handler", + "TimerInfo", + "TimerEventType", "DOMAIN", ] diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 837f4117c41..1c41d9aa0df 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -29,6 +29,7 @@ _LOGGER = logging.getLogger(__name__) TIMER_NOT_FOUND_RESPONSE = "timer_not_found" MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched" +NO_TIMER_SUPPORT_RESPONSE = "no_timer_support" @dataclass @@ -44,7 +45,7 @@ class TimerInfo: seconds: int """Total number of seconds the timer should run for.""" - device_id: str | None + device_id: str """Id of the device where the timer was set.""" start_hours: int | None @@ -159,6 +160,17 @@ class MultipleTimersMatchedError(intent.IntentHandleError): super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE) +class TimersNotSupportedError(intent.IntentHandleError): + """Error when a timer intent is used from a device that isn't registered to handle timer events.""" + + def __init__(self, device_id: str | None = None) -> None: + """Initialize error.""" + super().__init__( + f"Device does not support timers: device_id={device_id}", + NO_TIMER_SUPPORT_RESPONSE, + ) + + class TimerManager: """Manager for intent timers.""" @@ -170,26 +182,36 @@ class TimerManager: self.timers: dict[str, TimerInfo] = {} self.timer_tasks: dict[str, asyncio.Task] = {} - self.handlers: list[TimerHandler] = [] + # device_id -> handler + self.handlers: dict[str, TimerHandler] = {} - def register_handler(self, handler: TimerHandler) -> Callable[[], None]: + def register_handler( + self, device_id: str, handler: TimerHandler + ) -> Callable[[], None]: """Register a timer handler. Returns a callable to unregister. """ - self.handlers.append(handler) - return lambda: self.handlers.remove(handler) + self.handlers[device_id] = handler + + def unregister() -> None: + self.handlers.pop(device_id) + + return unregister def start_timer( self, + device_id: str, hours: int | None, minutes: int | None, seconds: int | None, language: str, - device_id: str | None, name: str | None = None, ) -> str: """Start a timer.""" + if not self.is_timer_device(device_id): + raise TimersNotSupportedError(device_id) + total_seconds = 0 if hours is not None: total_seconds += 60 * 60 * hours @@ -232,9 +254,7 @@ class TimerManager: name=f"Timer {timer_id}", ) - for handler in self.handlers: - handler(TimerEventType.STARTED, timer) - + self.handlers[timer.device_id](TimerEventType.STARTED, timer) _LOGGER.debug( "Timer started: id=%s, name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s", timer_id, @@ -266,15 +286,16 @@ class TimerManager: if timer is None: raise TimerNotFoundError + if not self.is_timer_device(timer.device_id): + raise TimersNotSupportedError(timer.device_id) + if timer.is_active: task = self.timer_tasks.pop(timer_id) task.cancel() timer.cancel() - for handler in self.handlers: - handler(TimerEventType.CANCELLED, timer) - + self.handlers[timer.device_id](TimerEventType.CANCELLED, timer) _LOGGER.debug( "Timer cancelled: id=%s, name=%s, seconds_left=%s, device_id=%s", timer_id, @@ -289,6 +310,9 @@ class TimerManager: if timer is None: raise TimerNotFoundError + if not self.is_timer_device(timer.device_id): + raise TimersNotSupportedError(timer.device_id) + if seconds == 0: # Don't bother cancelling and recreating the timer task return @@ -302,8 +326,7 @@ class TimerManager: name=f"Timer {timer_id}", ) - for handler in self.handlers: - handler(TimerEventType.UPDATED, timer) + self.handlers[timer.device_id](TimerEventType.UPDATED, timer) if seconds > 0: log_verb = "increased" @@ -332,6 +355,9 @@ class TimerManager: if timer is None: raise TimerNotFoundError + if not self.is_timer_device(timer.device_id): + raise TimersNotSupportedError(timer.device_id) + if not timer.is_active: # Already paused return @@ -340,9 +366,7 @@ class TimerManager: task = self.timer_tasks.pop(timer_id) task.cancel() - for handler in self.handlers: - handler(TimerEventType.UPDATED, timer) - + self.handlers[timer.device_id](TimerEventType.UPDATED, timer) _LOGGER.debug( "Timer paused: id=%s, name=%s, seconds_left=%s, device_id=%s", timer_id, @@ -357,6 +381,9 @@ class TimerManager: if timer is None: raise TimerNotFoundError + if not self.is_timer_device(timer.device_id): + raise TimersNotSupportedError(timer.device_id) + if timer.is_active: # Already unpaused return @@ -367,9 +394,7 @@ class TimerManager: name=f"Timer {timer.id}", ) - for handler in self.handlers: - handler(TimerEventType.UPDATED, timer) - + self.handlers[timer.device_id](TimerEventType.UPDATED, timer) _LOGGER.debug( "Timer unpaused: id=%s, name=%s, seconds_left=%s, device_id=%s", timer_id, @@ -383,9 +408,8 @@ class TimerManager: timer = self.timers.pop(timer_id) timer.finish() - for handler in self.handlers: - handler(TimerEventType.FINISHED, timer) + self.handlers[timer.device_id](TimerEventType.FINISHED, timer) _LOGGER.debug( "Timer finished: id=%s, name=%s, device_id=%s", timer_id, @@ -393,24 +417,28 @@ class TimerManager: timer.device_id, ) + def is_timer_device(self, device_id: str) -> bool: + """Return True if device has been registered to handle timer events.""" + return device_id in self.handlers + @callback def async_register_timer_handler( - hass: HomeAssistant, handler: TimerHandler + hass: HomeAssistant, device_id: str, 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) + return timer_manager.register_handler(device_id, handler) # ----------------------------------------------------------------------------- def _find_timer( - hass: HomeAssistant, slots: dict[str, Any], device_id: str | None + hass: HomeAssistant, device_id: str, slots: dict[str, Any] ) -> TimerInfo: """Match a single timer with constraints or raise an error.""" timer_manager: TimerManager = hass.data[TIMER_DATA] @@ -479,7 +507,7 @@ def _find_timer( return matching_timers[0] # Use device id - if matching_timers and device_id: + if matching_timers: matching_device_timers = [ t for t in matching_timers if (t.device_id == device_id) ] @@ -528,7 +556,7 @@ def _find_timer( def _find_timers( - hass: HomeAssistant, slots: dict[str, Any], device_id: str | None + hass: HomeAssistant, device_id: str, slots: dict[str, Any] ) -> list[TimerInfo]: """Match multiple timers with constraints or raise an error.""" timer_manager: TimerManager = hass.data[TIMER_DATA] @@ -587,10 +615,6 @@ def _find_timers( # No matches return matching_timers - if not device_id: - # Can't re-order based on area/floor - return matching_timers - # Use device id to order remaining timers device_registry = dr.async_get(hass) device = device_registry.async_get(device_id) @@ -702,6 +726,12 @@ class StartTimerIntentHandler(intent.IntentHandler): timer_manager: TimerManager = hass.data[TIMER_DATA] slots = self.async_validate_slots(intent_obj.slots) + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + name: str | None = None if "name" in slots: name = slots["name"]["value"] @@ -719,11 +749,11 @@ class StartTimerIntentHandler(intent.IntentHandler): seconds = int(slots["seconds"]["value"]) timer_manager.start_timer( + intent_obj.device_id, hours, minutes, seconds, language=intent_obj.language, - device_id=intent_obj.device_id, name=name, ) @@ -747,9 +777,16 @@ class CancelTimerIntentHandler(intent.IntentHandler): timer_manager: TimerManager = hass.data[TIMER_DATA] slots = self.async_validate_slots(intent_obj.slots) - timer = _find_timer(hass, slots, intent_obj.device_id) - timer_manager.cancel_timer(timer.id) + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + assert intent_obj.device_id is not None + + timer = _find_timer(hass, intent_obj.device_id, slots) + timer_manager.cancel_timer(timer.id) return intent_obj.create_response() @@ -771,10 +808,17 @@ class IncreaseTimerIntentHandler(intent.IntentHandler): 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, intent_obj.device_id) - timer_manager.add_time(timer.id, total_seconds) + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + assert intent_obj.device_id is not None + + total_seconds = _get_total_seconds(slots) + timer = _find_timer(hass, intent_obj.device_id, slots) + timer_manager.add_time(timer.id, total_seconds) return intent_obj.create_response() @@ -796,10 +840,17 @@ class DecreaseTimerIntentHandler(intent.IntentHandler): 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, intent_obj.device_id) - timer_manager.remove_time(timer.id, total_seconds) + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + assert intent_obj.device_id is not None + + total_seconds = _get_total_seconds(slots) + timer = _find_timer(hass, intent_obj.device_id, slots) + timer_manager.remove_time(timer.id, total_seconds) return intent_obj.create_response() @@ -820,9 +871,16 @@ class PauseTimerIntentHandler(intent.IntentHandler): timer_manager: TimerManager = hass.data[TIMER_DATA] slots = self.async_validate_slots(intent_obj.slots) - timer = _find_timer(hass, slots, intent_obj.device_id) - timer_manager.pause_timer(timer.id) + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + assert intent_obj.device_id is not None + + timer = _find_timer(hass, intent_obj.device_id, slots) + timer_manager.pause_timer(timer.id) return intent_obj.create_response() @@ -843,9 +901,16 @@ class UnpauseTimerIntentHandler(intent.IntentHandler): timer_manager: TimerManager = hass.data[TIMER_DATA] slots = self.async_validate_slots(intent_obj.slots) - timer = _find_timer(hass, slots, intent_obj.device_id) - timer_manager.unpause_timer(timer.id) + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + assert intent_obj.device_id is not None + + timer = _find_timer(hass, intent_obj.device_id, slots) + timer_manager.unpause_timer(timer.id) return intent_obj.create_response() @@ -863,10 +928,19 @@ class TimerStatusIntentHandler(intent.IntentHandler): 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) + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + + assert intent_obj.device_id is not None + statuses: list[dict[str, Any]] = [] - for timer in _find_timers(hass, slots, intent_obj.device_id): + for timer in _find_timers(hass, intent_obj.device_id, slots): total_seconds = timer.seconds_left minutes, seconds = divmod(total_seconds, 60) diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py index 71b2b7e256d..46e8548bee6 100644 --- a/tests/components/intent/test_timers.py +++ b/tests/components/intent/test_timers.py @@ -11,11 +11,12 @@ from homeassistant.components.intent.timers import ( TimerInfo, TimerManager, TimerNotFoundError, + TimersNotSupportedError, _round_time, async_register_timer_handler, ) from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -42,6 +43,7 @@ async def test_start_finish_timer(hass: HomeAssistant, init_components) -> None: timer_id: str | None = None + @callback def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: nonlocal timer_id @@ -59,7 +61,7 @@ async def test_start_finish_timer(hass: HomeAssistant, init_components) -> None: assert timer.id == timer_id finished_event.set() - async_register_timer_handler(hass, handle_timer) + async_register_timer_handler(hass, device_id, handle_timer) result = await intent.async_handle( hass, @@ -87,6 +89,7 @@ async def test_cancel_timer(hass: HomeAssistant, init_components) -> None: timer_id: str | None = None + @callback def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: nonlocal timer_id @@ -112,7 +115,7 @@ async def test_cancel_timer(hass: HomeAssistant, init_components) -> None: assert timer.seconds_left == 0 cancelled_event.set() - async_register_timer_handler(hass, handle_timer) + async_register_timer_handler(hass, device_id, handle_timer) # Cancel by starting time result = await intent.async_handle( @@ -139,6 +142,7 @@ async def test_cancel_timer(hass: HomeAssistant, init_components) -> None: "start_minutes": {"value": 2}, "start_seconds": {"value": 3}, }, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -172,6 +176,7 @@ async def test_cancel_timer(hass: HomeAssistant, init_components) -> None: "test", intent.INTENT_CANCEL_TIMER, {"name": {"value": timer_name}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -191,6 +196,7 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None: timer_id: str | None = None original_total_seconds = -1 + @callback def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: nonlocal timer_id, original_total_seconds @@ -220,7 +226,7 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None: assert timer.id == timer_id cancelled_event.set() - async_register_timer_handler(hass, handle_timer) + async_register_timer_handler(hass, device_id, handle_timer) result = await intent.async_handle( hass, @@ -286,6 +292,7 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None: "test", intent.INTENT_CANCEL_TIMER, {"name": {"value": timer_name}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -305,6 +312,7 @@ async def test_decrease_timer(hass: HomeAssistant, init_components) -> None: timer_id: str | None = None original_total_seconds = 0 + @callback def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: nonlocal timer_id, original_total_seconds @@ -335,7 +343,7 @@ async def test_decrease_timer(hass: HomeAssistant, init_components) -> None: assert timer.id == timer_id cancelled_event.set() - async_register_timer_handler(hass, handle_timer) + async_register_timer_handler(hass, device_id, handle_timer) result = await intent.async_handle( hass, @@ -380,6 +388,7 @@ async def test_decrease_timer(hass: HomeAssistant, init_components) -> None: "test", intent.INTENT_CANCEL_TIMER, {"name": {"value": timer_name}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -394,13 +403,15 @@ async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) - updated_event = asyncio.Event() finished_event = asyncio.Event() + device_id = "test_device" timer_id: str | None = None original_total_seconds = 0 + @callback def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: nonlocal timer_id, original_total_seconds - assert timer.device_id is None + assert timer.device_id == device_id assert timer.name is None assert timer.start_hours == 1 assert timer.start_minutes == 2 @@ -425,7 +436,7 @@ async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) - assert timer.id == timer_id finished_event.set() - async_register_timer_handler(hass, handle_timer) + async_register_timer_handler(hass, device_id, handle_timer) result = await intent.async_handle( hass, @@ -436,6 +447,7 @@ async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) - "minutes": {"value": 2}, "seconds": {"value": 3}, }, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -454,6 +466,7 @@ async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) - "start_seconds": {"value": 3}, "seconds": {"value": original_total_seconds + 1}, }, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -466,12 +479,60 @@ async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) - async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None: """Test finding a timer with the wrong info.""" + device_id = "test_device" + + for intent_name in ( + intent.INTENT_START_TIMER, + intent.INTENT_CANCEL_TIMER, + intent.INTENT_PAUSE_TIMER, + intent.INTENT_UNPAUSE_TIMER, + intent.INTENT_INCREASE_TIMER, + intent.INTENT_DECREASE_TIMER, + intent.INTENT_TIMER_STATUS, + ): + if intent_name in ( + intent.INTENT_START_TIMER, + intent.INTENT_INCREASE_TIMER, + intent.INTENT_DECREASE_TIMER, + ): + slots = {"minutes": {"value": 5}} + else: + slots = {} + + # No device id + with pytest.raises(TimersNotSupportedError): + await intent.async_handle( + hass, + "test", + intent_name, + slots, + device_id=None, + ) + + # Unregistered device + with pytest.raises(TimersNotSupportedError): + await intent.async_handle( + hass, + "test", + intent_name, + slots, + device_id=device_id, + ) + + # Must register a handler before we can do anything with timers + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + pass + + async_register_timer_handler(hass, device_id, handle_timer) + # Start a 5 minute timer for pizza result = await intent.async_handle( hass, "test", intent.INTENT_START_TIMER, {"name": {"value": "pizza"}, "minutes": {"value": 5}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -481,6 +542,7 @@ async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None: "test", intent.INTENT_INCREASE_TIMER, {"name": {"value": "PIZZA "}, "minutes": {"value": 1}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -491,6 +553,7 @@ async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None: "test", intent.INTENT_CANCEL_TIMER, {"name": {"value": "does-not-exist"}}, + device_id=device_id, ) # Right start time @@ -499,6 +562,7 @@ async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None: "test", intent.INTENT_INCREASE_TIMER, {"start_minutes": {"value": 5}, "minutes": {"value": 1}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -509,6 +573,7 @@ async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None: "test", intent.INTENT_CANCEL_TIMER, {"start_minutes": {"value": 1}}, + device_id=device_id, ) @@ -523,6 +588,17 @@ async def test_disambiguation( entry = MockConfigEntry() entry.add_to_hass(hass) + cancelled_event = asyncio.Event() + timer_info: TimerInfo | None = None + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_info + + if event_type == TimerEventType.CANCELLED: + timer_info = timer + cancelled_event.set() + # Alice is upstairs in the study floor_upstairs = floor_registry.async_create("upstairs") area_study = area_registry.async_create("study") @@ -551,6 +627,9 @@ async def test_disambiguation( device_bob_kitchen_1.id, area_id=area_kitchen.id ) + async_register_timer_handler(hass, device_alice_study.id, handle_timer) + async_register_timer_handler(hass, device_bob_kitchen_1.id, handle_timer) + # Alice: set a 3 minute timer result = await intent.async_handle( hass, @@ -591,20 +670,9 @@ async def test_disambiguation( 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 + cancelled_event.clear() + timer_info = None result = await intent.async_handle( hass, "test", intent.INTENT_CANCEL_TIMER, {}, device_id=device_alice_study.id ) @@ -651,6 +719,9 @@ async def test_disambiguation( device_bob_living_room.id, area_id=area_living_room.id ) + async_register_timer_handler(hass, device_alice_bedroom.id, handle_timer) + async_register_timer_handler(hass, device_bob_living_room.id, handle_timer) + # Alice: set a 3 minute timer (study) result = await intent.async_handle( hass, @@ -720,13 +791,23 @@ async def test_disambiguation( 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 + # Trying to cancel the remaining two timers from a disconnected area fails + area_garage = area_registry.async_create("garage") + device_garage = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "garage")}, + ) + device_registry.async_update_device(device_garage.id, area_id=area_garage.id) + async_register_timer_handler(hass, device_garage.id, handle_timer) + with pytest.raises(MultipleTimersMatchedError): await intent.async_handle( hass, "test", intent.INTENT_CANCEL_TIMER, {}, + device_id=device_garage.id, ) # Alice cancels the bedroom timer from study (same floor) @@ -755,6 +836,8 @@ async def test_disambiguation( device_bob_kitchen_2.id, area_id=area_kitchen.id ) + async_register_timer_handler(hass, device_bob_kitchen_2.id, handle_timer) + # Bob cancels the kitchen timer from a different device cancelled_event.clear() timer_info = None @@ -788,11 +871,14 @@ async def test_disambiguation( async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None: """Test pausing and unpausing a running timer.""" + device_id = "test_device" + started_event = asyncio.Event() updated_event = asyncio.Event() expected_active = True + @callback def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: if event_type == TimerEventType.STARTED: started_event.set() @@ -800,10 +886,14 @@ async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None assert timer.is_active == expected_active updated_event.set() - async_register_timer_handler(hass, handle_timer) + async_register_timer_handler(hass, device_id, handle_timer) result = await intent.async_handle( - hass, "test", intent.INTENT_START_TIMER, {"minutes": {"value": 5}} + hass, + "test", + intent.INTENT_START_TIMER, + {"minutes": {"value": 5}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -812,7 +902,9 @@ async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None # Pause the timer expected_active = False - result = await intent.async_handle(hass, "test", intent.INTENT_PAUSE_TIMER, {}) + result = await intent.async_handle( + hass, "test", intent.INTENT_PAUSE_TIMER, {}, device_id=device_id + ) assert result.response_type == intent.IntentResponseType.ACTION_DONE async with asyncio.timeout(1): @@ -820,14 +912,18 @@ async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None # Pausing again will not fire the event updated_event.clear() - result = await intent.async_handle(hass, "test", intent.INTENT_PAUSE_TIMER, {}) + result = await intent.async_handle( + hass, "test", intent.INTENT_PAUSE_TIMER, {}, device_id=device_id + ) 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, {}) + result = await intent.async_handle( + hass, "test", intent.INTENT_UNPAUSE_TIMER, {}, device_id=device_id + ) assert result.response_type == intent.IntentResponseType.ACTION_DONE async with asyncio.timeout(1): @@ -835,7 +931,9 @@ async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None # Unpausing again will not fire the event updated_event.clear() - result = await intent.async_handle(hass, "test", intent.INTENT_UNPAUSE_TIMER, {}) + result = await intent.async_handle( + hass, "test", intent.INTENT_UNPAUSE_TIMER, {}, device_id=device_id + ) assert result.response_type == intent.IntentResponseType.ACTION_DONE assert not updated_event.is_set() @@ -860,11 +958,63 @@ async def test_timer_not_found(hass: HomeAssistant) -> None: timer_manager.unpause_timer("does-not-exist") +async def test_timers_not_supported(hass: HomeAssistant) -> None: + """Test unregistered device ids raise TimersNotSupportedError.""" + timer_manager = TimerManager(hass) + + with pytest.raises(TimersNotSupportedError): + timer_manager.start_timer( + "does-not-exist", + hours=None, + minutes=5, + seconds=None, + language=hass.config.language, + ) + + # Start a timer + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + pass + + device_id = "test_device" + unregister = timer_manager.register_handler(device_id, handle_timer) + + timer_id = timer_manager.start_timer( + device_id, + hours=None, + minutes=5, + seconds=None, + language=hass.config.language, + ) + + # Unregister handler so device no longer "supports" timers + unregister() + + # All operations on the timer should fail now + with pytest.raises(TimersNotSupportedError): + timer_manager.add_time(timer_id, 1) + + with pytest.raises(TimersNotSupportedError): + timer_manager.remove_time(timer_id, 1) + + with pytest.raises(TimersNotSupportedError): + timer_manager.pause_timer(timer_id) + + with pytest.raises(TimersNotSupportedError): + timer_manager.unpause_timer(timer_id) + + with pytest.raises(TimersNotSupportedError): + timer_manager.cancel_timer(timer_id) + + async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> None: """Test getting the status of named timers.""" + device_id = "test_device" + started_event = asyncio.Event() num_started = 0 + @callback def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: nonlocal num_started @@ -873,7 +1023,7 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> if num_started == 4: started_event.set() - async_register_timer_handler(hass, handle_timer) + async_register_timer_handler(hass, device_id, handle_timer) # Start timers with names result = await intent.async_handle( @@ -881,6 +1031,7 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> "test", intent.INTENT_START_TIMER, {"name": {"value": "pizza"}, "minutes": {"value": 10}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -889,6 +1040,7 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> "test", intent.INTENT_START_TIMER, {"name": {"value": "pizza"}, "minutes": {"value": 15}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -897,6 +1049,7 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> "test", intent.INTENT_START_TIMER, {"name": {"value": "cookies"}, "minutes": {"value": 20}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -905,6 +1058,7 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> "test", intent.INTENT_START_TIMER, {"name": {"value": "chicken"}, "hours": {"value": 2}, "seconds": {"value": 30}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -913,7 +1067,9 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> await started_event.wait() # No constraints returns all timers - result = await intent.async_handle(hass, "test", intent.INTENT_TIMER_STATUS, {}) + result = await intent.async_handle( + hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=device_id + ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) assert len(timers) == 4 @@ -925,6 +1081,7 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> "test", intent.INTENT_TIMER_STATUS, {"name": {"value": "cookies"}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) @@ -938,6 +1095,7 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> "test", intent.INTENT_TIMER_STATUS, {"name": {"value": "pizza"}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) @@ -952,6 +1110,7 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> "test", intent.INTENT_TIMER_STATUS, {"name": {"value": "pizza"}, "start_minutes": {"value": 10}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) @@ -969,6 +1128,7 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> "start_hours": {"value": 2}, "start_seconds": {"value": 30}, }, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) @@ -980,7 +1140,11 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> # Wrong name results in an empty list result = await intent.async_handle( - hass, "test", intent.INTENT_TIMER_STATUS, {"name": {"value": "does-not-exist"}} + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"name": {"value": "does-not-exist"}}, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) @@ -996,6 +1160,7 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> "start_minutes": {"value": 100}, "start_seconds": {"value": 100}, }, + device_id=device_id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) @@ -1034,6 +1199,7 @@ async def test_area_filter( num_timers = 3 num_started = 0 + @callback def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: nonlocal num_started @@ -1042,7 +1208,8 @@ async def test_area_filter( if num_started == num_timers: started_event.set() - async_register_timer_handler(hass, handle_timer) + async_register_timer_handler(hass, device_kitchen.id, handle_timer) + async_register_timer_handler(hass, device_living_room.id, handle_timer) # Start timers in different areas result = await intent.async_handle( @@ -1077,30 +1244,34 @@ async def test_area_filter( await started_event.wait() # No constraints returns all timers - result = await intent.async_handle(hass, "test", intent.INTENT_TIMER_STATUS, {}) + result = await intent.async_handle( + hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=device_kitchen.id + ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) assert len(timers) == num_timers assert {t.get(ATTR_NAME) for t in timers} == {"pizza", "tv", "media"} - # Filter by area (kitchen) + # Filter by area (target kitchen from living room) result = await intent.async_handle( hass, "test", intent.INTENT_TIMER_STATUS, {"area": {"value": "kitchen"}}, + device_id=device_living_room.id, ) 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" - # Filter by area (living room) + # Filter by area (target living room from kitchen) result = await intent.async_handle( hass, "test", intent.INTENT_TIMER_STATUS, {"area": {"value": "living room"}}, + device_id=device_kitchen.id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) @@ -1113,6 +1284,7 @@ async def test_area_filter( "test", intent.INTENT_TIMER_STATUS, {"area": {"value": "living room"}, "name": {"value": "tv"}}, + device_id=device_kitchen.id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) @@ -1125,6 +1297,7 @@ async def test_area_filter( "test", intent.INTENT_TIMER_STATUS, {"area": {"value": "living room"}, "start_minutes": {"value": 15}}, + device_id=device_kitchen.id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) @@ -1137,6 +1310,7 @@ async def test_area_filter( "test", intent.INTENT_TIMER_STATUS, {"area": {"value": "does-not-exist"}}, + device_id=device_kitchen.id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE timers = result.speech_slots.get("timers", []) @@ -1148,6 +1322,7 @@ async def test_area_filter( "test", intent.INTENT_CANCEL_TIMER, {"area": {"value": "living room"}, "start_minutes": {"value": 15}}, + device_id=device_living_room.id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE @@ -1157,6 +1332,7 @@ async def test_area_filter( "test", intent.INTENT_CANCEL_TIMER, {"area": {"value": "living room"}}, + device_id=device_living_room.id, ) assert result.response_type == intent.IntentResponseType.ACTION_DONE