core/tests/components/intent/test_timers.py

1590 lines
49 KiB
Python

"""Tests for intent timers."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from homeassistant.components.intent.timers import (
MultipleTimersMatchedError,
TimerEventType,
TimerInfo,
TimerManager,
TimerNotFoundError,
TimersNotSupportedError,
_round_time,
async_device_supports_timers,
async_register_timer_handler,
)
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
from homeassistant.core import HomeAssistant, callback
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
@callback
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
assert timer.created_seconds == 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, device_id, handle_timer)
# A device that has been registered to handle timers is required
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"name": {"value": timer_name},
"seconds": {"value": 0},
},
device_id=device_id,
)
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
@callback
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, device_id, handle_timer)
# Cancel by starting time
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"hours": {"value": 1},
"minutes": {"value": 2},
"seconds": {"value": 3},
},
device_id=device_id,
)
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},
},
device_id=device_id,
)
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,
{
"name": {"value": timer_name},
"hours": {"value": 1},
"minutes": {"value": 2},
"seconds": {"value": 3},
},
device_id=device_id,
)
async with asyncio.timeout(1):
await started_event.wait()
result = await intent.async_handle(
hass,
"test",
intent.INTENT_CANCEL_TIMER,
{"name": {"value": timer_name}},
device_id=device_id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
async with asyncio.timeout(1):
await cancelled_event.wait()
# Cancel without a device
timer_name = None
started_event.clear()
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"hours": {"value": 1},
"minutes": {"value": 2},
"seconds": {"value": 3},
},
device_id=device_id,
)
async with asyncio.timeout(1):
await started_event.wait()
result = await intent.async_handle(hass, "test", intent.INTENT_CANCEL_TIMER, {})
assert result.response_type == intent.IntentResponseType.ACTION_DONE
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
seconds_added = 0
@callback
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
)
assert timer.created_seconds == original_total_seconds
started_event.set()
elif event_type == TimerEventType.UPDATED:
assert timer.id == timer_id
# Timer was increased
assert timer.seconds_left > original_total_seconds
assert timer.created_seconds == original_total_seconds + seconds_added
updated_event.set()
elif event_type == TimerEventType.CANCELLED:
assert timer.id == timer_id
cancelled_event.set()
async_register_timer_handler(hass, device_id, handle_timer)
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"name": {"value": timer_name},
"hours": {"value": 1},
"minutes": {"value": 2},
"seconds": {"value": 3},
},
device_id=device_id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
async with asyncio.timeout(1):
await started_event.wait()
# Adding 0 seconds has no effect
seconds_added = 0
result = await intent.async_handle(
hass,
"test",
intent.INTENT_INCREASE_TIMER,
{
"start_hours": {"value": 1},
"start_minutes": {"value": 2},
"start_seconds": {"value": 3},
"hours": {"value": 0},
"minutes": {"value": 0},
"seconds": {"value": 0},
},
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
assert not updated_event.is_set()
# Add 30 seconds to the timer
seconds_added = (1 * 60 * 60) + (5 * 60) + 30
result = await intent.async_handle(
hass,
"test",
intent.INTENT_INCREASE_TIMER,
{
"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
@callback
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)
assert timer.created_seconds == original_total_seconds
updated_event.set()
elif event_type == TimerEventType.CANCELLED:
assert timer.id == timer_id
cancelled_event.set()
async_register_timer_handler(hass, device_id, handle_timer)
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"name": {"value": timer_name},
"hours": {"value": 1},
"minutes": {"value": 2},
"seconds": {"value": 3},
},
device_id=device_id,
)
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,
{
"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()
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 == device_id
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, device_id, handle_timer)
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"hours": {"value": 1},
"minutes": {"value": 2},
"seconds": {"value": 3},
},
device_id=device_id,
)
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."""
device_id = "test_device"
# No device id
with pytest.raises(TimersNotSupportedError):
await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"minutes": {"value": 5}},
device_id=None,
)
# Unregistered device
with pytest.raises(TimersNotSupportedError):
await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"minutes": {"value": 5}},
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
# 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)
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")
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
)
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,
"test",
intent.INTENT_START_TIMER,
{"minutes": {"value": 3}},
device_id=device_alice_study.id,
)
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,
{"minutes": {"value": 3}},
device_id=device_bob_kitchen_1.id,
)
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=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=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
# 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
)
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=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
)
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,
"test",
intent.INTENT_START_TIMER,
{"minutes": {"value": 3}},
device_id=device_alice_study.id,
)
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,
{"minutes": {"value": 3}},
device_id=device_alice_bedroom.id,
)
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,
{"minutes": {"value": 3}},
device_id=device_bob_kitchen_1.id,
)
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,
{"minutes": {"value": 3}},
device_id=device_bob_living_room.id,
)
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=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=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 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)
cancelled_event.clear()
timer_info = None
result = await intent.async_handle(
hass, "test", intent.INTENT_CANCEL_TIMER, {}, device_id=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
)
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
result = await intent.async_handle(
hass, "test", intent.INTENT_CANCEL_TIMER, {}, device_id=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=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."""
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()
elif event_type == TimerEventType.UPDATED:
assert timer.is_active == expected_active
updated_event.set()
async_register_timer_handler(hass, device_id, handle_timer)
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"minutes": {"value": 5}},
device_id=device_id,
)
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 fail because there are no running timers
with pytest.raises(TimerNotFoundError):
await intent.async_handle(hass, "test", intent.INTENT_PAUSE_TIMER, {})
# 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 fail because there are no paused timers
with pytest.raises(TimerNotFoundError):
await intent.async_handle(hass, "test", intent.INTENT_UNPAUSE_TIMER, {})
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_manager_pause_unpause(hass: HomeAssistant) -> None:
"""Test that pausing/unpausing again will not have an affect."""
timer_manager = TimerManager(hass)
# Start a timer
handle_timer = MagicMock()
device_id = "test_device"
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,
)
assert timer_id in timer_manager.timers
assert timer_manager.timers[timer_id].is_active
# Pause
handle_timer.reset_mock()
timer_manager.pause_timer(timer_id)
handle_timer.assert_called_once()
# Pausing again does not call handler
handle_timer.reset_mock()
timer_manager.pause_timer(timer_id)
handle_timer.assert_not_called()
# Unpause
handle_timer.reset_mock()
timer_manager.unpause_timer(timer_id)
handle_timer.assert_called_once()
# Unpausing again does not call handler
handle_timer.reset_mock()
timer_manager.unpause_timer(timer_id)
handle_timer.assert_not_called()
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 not crash
timer_manager.add_time(timer_id, 1)
timer_manager.remove_time(timer_id, 1)
timer_manager.pause_timer(timer_id)
timer_manager.unpause_timer(timer_id)
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
if event_type == TimerEventType.STARTED:
num_started += 1
if num_started == 4:
started_event.set()
async_register_timer_handler(hass, device_id, handle_timer)
# Start timers with names
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"name": {"value": "pizza"}, "minutes": {"value": 10}},
device_id=device_id,
)
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}},
device_id=device_id,
)
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}},
device_id=device_id,
)
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}},
device_id=device_id,
)
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
for handle_device_id in (device_id, None):
result = await intent.async_handle(
hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=handle_device_id
)
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"}},
device_id=device_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) == "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"}},
device_id=device_id,
)
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}},
device_id=device_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"
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},
},
device_id=device_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) == "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"}},
device_id=device_id,
)
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},
},
device_id=device_id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
timers = result.speech_slots.get("timers", [])
assert len(timers) == 0
async def test_area_filter(
hass: HomeAssistant,
init_components,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test targeting timers by area name."""
entry = MockConfigEntry()
entry.add_to_hass(hass)
area_kitchen = area_registry.async_create("kitchen")
device_kitchen = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections=set(),
identifiers={("test", "kitchen-device")},
)
device_registry.async_update_device(device_kitchen.id, area_id=area_kitchen.id)
area_living_room = area_registry.async_create("living room")
device_living_room = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections=set(),
identifiers={("test", "living_room-device")},
)
device_registry.async_update_device(
device_living_room.id, area_id=area_living_room.id
)
started_event = asyncio.Event()
num_timers = 3
num_started = 0
@callback
def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None:
nonlocal num_started
if event_type == TimerEventType.STARTED:
num_started += 1
if num_started == num_timers:
started_event.set()
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(
hass,
"test",
intent.INTENT_START_TIMER,
{"name": {"value": "pizza"}, "minutes": {"value": 10}},
device_id=device_kitchen.id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"name": {"value": "tv"}, "minutes": {"value": 10}},
device_id=device_living_room.id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"name": {"value": "media"}, "minutes": {"value": 15}},
device_id=device_living_room.id,
)
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, {}, 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 (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 (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", [])
assert len(timers) == 2
assert {t.get(ATTR_NAME) for t in timers} == {"tv", "media"}
# Filter by area + name
result = await intent.async_handle(
hass,
"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", [])
assert len(timers) == 1
assert timers[0].get(ATTR_NAME) == "tv"
# Filter by area + time
result = await intent.async_handle(
hass,
"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", [])
assert len(timers) == 1
assert timers[0].get(ATTR_NAME) == "media"
# Filter by area that doesn't exist
result = await intent.async_handle(
hass,
"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", [])
assert len(timers) == 0
# Cancel by area + time
result = await intent.async_handle(
hass,
"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
# Cancel by area
result = await intent.async_handle(
hass,
"test",
intent.INTENT_CANCEL_TIMER,
{"area": {"value": "living room"}},
device_id=device_living_room.id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
# Get status with device missing
with patch(
"homeassistant.helpers.device_registry.DeviceRegistry.async_get",
return_value=None,
):
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) == 1
# Get status with area missing
with patch(
"homeassistant.helpers.area_registry.AreaRegistry.async_get_area",
return_value=None,
):
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) == 1
def test_round_time() -> None:
"""Test lower-precision time rounded."""
# hours
assert _round_time(1, 10, 30) == (1, 0, 0)
assert _round_time(1, 48, 30) == (2, 0, 0)
assert _round_time(2, 25, 30) == (2, 30, 0)
# minutes
assert _round_time(0, 1, 10) == (0, 1, 0)
assert _round_time(0, 1, 48) == (0, 2, 0)
assert _round_time(0, 2, 25) == (0, 2, 30)
# seconds
assert _round_time(0, 0, 6) == (0, 0, 6)
assert _round_time(0, 0, 15) == (0, 0, 10)
assert _round_time(0, 0, 58) == (0, 1, 0)
assert _round_time(0, 0, 25) == (0, 0, 20)
assert _round_time(0, 0, 35) == (0, 0, 30)
async def test_start_timer_with_conversation_command(
hass: HomeAssistant, init_components
) -> None:
"""Test starting a timer with an conversation command and having it finish."""
device_id = "test_device"
timer_name = "test timer"
test_command = "turn on the lights"
agent_id = "test_agent"
mock_handle_timer = MagicMock()
async_register_timer_handler(hass, device_id, mock_handle_timer)
timer_manager = TimerManager(hass)
with pytest.raises(ValueError):
timer_manager.start_timer(
device_id=None,
hours=None,
minutes=5,
seconds=None,
language=hass.config.language,
)
with patch("homeassistant.components.conversation.async_converse") as mock_converse:
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"name": {"value": timer_name},
"seconds": {"value": 0},
"conversation_command": {"value": test_command},
},
device_id=device_id,
conversation_agent_id=agent_id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
# No timer events for delayed commands
mock_handle_timer.assert_not_called()
# Wait for process service call to finish
await hass.async_block_till_done()
mock_converse.assert_called_once()
assert mock_converse.call_args.args[1] == test_command
async def test_pause_unpause_timer_disambiguate(
hass: HomeAssistant, init_components
) -> None:
"""Test disamgibuating timers by their paused state."""
device_id = "test_device"
started_timer_ids: list[str] = []
paused_timer_ids: list[str] = []
unpaused_timer_ids: list[str] = []
started_event = asyncio.Event()
updated_event = asyncio.Event()
@callback
def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None:
if event_type == TimerEventType.STARTED:
started_event.set()
started_timer_ids.append(timer.id)
elif event_type == TimerEventType.UPDATED:
updated_event.set()
if timer.is_active:
unpaused_timer_ids.append(timer.id)
else:
paused_timer_ids.append(timer.id)
async_register_timer_handler(hass, device_id, handle_timer)
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"minutes": {"value": 5}},
device_id=device_id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
async with asyncio.timeout(1):
await started_event.wait()
# Pause the 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):
await updated_event.wait()
# Start another timer
started_event.clear()
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"minutes": {"value": 10}},
device_id=device_id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
async with asyncio.timeout(1):
await started_event.wait()
assert len(started_timer_ids) == 2
# We can pause the more recent timer without more information because the
# first one is paused.
updated_event.clear()
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):
await updated_event.wait()
assert len(paused_timer_ids) == 2
assert paused_timer_ids[1] == started_timer_ids[1]
# We have to explicitly unpause now
updated_event.clear()
result = await intent.async_handle(
hass,
"test",
intent.INTENT_UNPAUSE_TIMER,
{"start_minutes": {"value": 10}},
device_id=device_id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
async with asyncio.timeout(1):
await updated_event.wait()
assert len(unpaused_timer_ids) == 1
assert unpaused_timer_ids[0] == started_timer_ids[1]
# We can resume the older timer without more information because the
# second one is running.
updated_event.clear()
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):
await updated_event.wait()
assert len(unpaused_timer_ids) == 2
assert unpaused_timer_ids[1] == started_timer_ids[0]
async def test_async_device_supports_timers(hass: HomeAssistant) -> None:
"""Test async_device_supports_timers function."""
device_id = "test_device"
# Before intent initialization
assert not async_device_supports_timers(hass, device_id)
# After intent initialization
assert await async_setup_component(hass, "intent", {})
assert not async_device_supports_timers(hass, device_id)
@callback
def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None:
pass
async_register_timer_handler(hass, device_id, handle_timer)
# After handler registration
assert async_device_supports_timers(hass, device_id)