Require registered device id for all timer intents (#117946)

* Require device id when registering timer handlers

* Require device id for timer intents

* Raise errors for unregistered device ids

* Add callback

* Add types for callback to __all__

* Clean up

* More clean up
This commit is contained in:
Michael Hansen 2024-05-24 12:55:52 -05:00 committed by GitHub
parent 77e385db52
commit 5be15c94bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 333 additions and 79 deletions

View File

@ -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",
]

View File

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

View File

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