Back voice timers with per-satellite timer_list entities

Voice timers (start/cancel/pause/etc.) now delegate to a timer_list
entity created automatically for each assist_satellite device, instead
of intent's private in-memory TimerManager dict. This gets voice
timers persistence-adjacent semantics (archiving, get_timers,
automation triggers, the websocket API) for free.

Drops conversation_command timers and leaves mobile_app's timer
support broken for now (xfail'd, no assist_satellite entity to hang a
timer_list off of yet) — see TIMER_LIST_POC_TRADEOFFS.md for these and
other trade-offs made to keep this POC reviewable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Hansen
2026-07-20 11:16:16 -05:00
co-authored by Claude Sonnet 5
parent c3b9815fe2
commit 01b230820e
11 changed files with 468 additions and 626 deletions
+68
View File
@@ -0,0 +1,68 @@
# Timer list POC — known trade-offs
This branch is a proof of concept that replaces `intent`'s hand-rolled,
in-memory voice-timer storage with real `timer_list` entities (one
auto-created per `assist_satellite` device). It intentionally cuts scope in
several places so the PR stays reviewable. These are called out here so
reviewers and whoever picks up follow-on work don't have to rediscover them.
## Dropped / deferred features
- **`conversation_command` timers are gone.** The old system supported
device-less timers that ran a delayed conversation command instead of
notifying a device. This doesn't fit the entity-per-device model and needs
its own design; it was removed rather than shimmed in.
- **`mobile_app` timer support is broken.** `mobile_app` registers a device
for timer push-notifications but has no `AssistSatelliteEntity`, so it
never gets an auto-created `timer_list` entity. Starting a timer for a
mobile_app device now raises `TimersNotSupportedError`. Its tests are
marked `xfail` with the reason recorded inline
(`tests/components/mobile_app/test_timers.py`). Fix: give mobile_app
devices a `timer_list` entity the same way `assist_satellite` does.
- **No `cancel_all_timers` / `clear_finished_timers` services.** Removed from
`timer_list`/`local_timer_list` earlier in this branch. Archived timers can
only be removed individually or via the automatic retention limit below.
## Behavior changes from the old voice-timer system
- **Finish action is always "archive."** Timers no longer support
remove-on-finish or auto-restart; every finished/cancelled timer is kept
(as `finished`/`cancelled`) until removed.
- **Archived timers are capped at 10 per entity**, oldest evicted first, with
no way to configure the limit.
- **`created_seconds` no longer grows.** In the old system, adding time to a
timer past its original length grew the value reported to satellites as
the timer's nominal duration. The new `TimerItem.duration` is fixed at
creation, so `TimerInfo.created_seconds` (and the `total_seconds` field
sent to esphome/wyoming satellites) reflects the *original* duration even
after time is added.
- **Removing more time than remains skips the "updated" event.** The timer
transitions straight to `finished`, matching `LocalTimerListEntity`'s
existing behavior, rather than emitting an intermediate zero-duration
update before finishing (as the old system did).
## Design shortcuts
- **Auto-created timer list entities aren't linked to their device.** They
show up as standalone entities named `"{device name} Timers"` rather than
nested under the satellite's device in the UI (no `DeviceInfo` set). Purely
cosmetic; the lookup logic doesn't depend on device linkage and this can be
added later.
- **Auto-created vs. user-created entities are told apart implicitly.**
Both live in the `timer_list` domain; the code distinguishes "this is a
satellite's auto-created list" from "this is a user's `local_timer_list`
helper" purely by the entity registry's `platform` field (`"timer_list"`
vs. `"local_timer_list"`). There's no explicit flag — if that convention
ever changes, voice matching (`_all_timer_infos` in
`homeassistant/components/intent/timers.py`) silently stops seeing the
right entities.
- **No cleanup of orphaned timer list entities.** If a satellite device is
removed and re-added with a new device ID, its old `timer_list` entity
(keyed by the old device ID) is never cleaned up.
- **New always-on dependency edges.** `intent` (a `system`-type integration,
always loaded) now depends on `timer_list`, so `timer_list.async_setup`
(services + websocket commands, no entities) runs on every Home Assistant
install regardless of whether voice timers are used. `assist_satellite`
now depends on `local_timer_list`.
- **No user-facing control over the auto-created lists** — no config entry,
options flow, or way to opt a satellite out of getting one.
@@ -26,10 +26,19 @@ from homeassistant.components.assist_pipeline import (
async_pipeline_from_audio_stream,
vad,
)
from homeassistant.components.local_timer_list import LocalTimerListEntity
from homeassistant.components.media_player import async_process_play_media_url
from homeassistant.core import Context, callback
from homeassistant.components.timer_list import (
DATA_COMPONENT as TIMER_LIST_DATA_COMPONENT,
)
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session, entity
from homeassistant.helpers import (
chat_session,
device_registry as dr,
entity,
entity_registry as er,
)
from homeassistant.helpers.entity import EntityDescription
from .const import PREANNOUNCE_URL, AssistSatelliteEntityFeature
@@ -151,6 +160,12 @@ class AssistSatelliteEntity(entity.Entity):
"""Return state of the entity."""
return self.__assist_satellite_state
@override
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
await _async_ensure_timer_list_entity(self.hass, self.registry_entry)
@property
def pipeline_entity_id(self) -> str | None:
"""Entity ID of the pipeline to use for the next conversation."""
@@ -729,6 +744,35 @@ class AssistSatelliteEntity(entity.Entity):
)
async def _async_ensure_timer_list_entity(
hass: HomeAssistant, registry_entry: er.RegistryEntry | None
) -> None:
"""Create a timer_list entity for this satellite's device, if it lacks one.
Always attempts to add the entity: the entity registry reconciles by
``unique_id`` (the device id), so this safely re-attaches to the existing
registry entry on restart rather than creating a duplicate. Checking for
an existing *registry* entry first would be wrong, since registry entries
persist across restarts even though the live entity does not.
"""
if registry_entry is None or registry_entry.device_id is None:
return
device_id = registry_entry.device_id
device_registry = dr.async_get(hass)
device = device_registry.async_get(device_id)
device_name = device.name_by_user or device.name if device else None
await hass.data[TIMER_LIST_DATA_COMPONENT].async_add_entities(
[
LocalTimerListEntity(
name=f"{device_name} Timers" if device_name else "Timers",
unique_id=device_id,
)
]
)
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
"""Collect list reference names recursively."""
if isinstance(expression, Group):
@@ -2,7 +2,7 @@
"domain": "assist_satellite",
"name": "Assist Satellite",
"codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"],
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
"dependencies": ["assist_pipeline", "http", "local_timer_list", "stt", "tts"],
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
@@ -3,7 +3,7 @@
"name": "Intent",
"codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"],
"config_flow": false,
"dependencies": ["http"],
"dependencies": ["http", "timer_list"],
"documentation": "https://www.home-assistant.io/integrations/intent",
"integration_type": "system",
"quality_scale": "internal"
+215 -386
View File
@@ -1,25 +1,43 @@
"""Timer implementation for intents."""
"""Timer implementation for intents.
Timers are stored and scheduled by a ``timer_list`` entity per device (see
``homeassistant.components.assist_satellite``, which creates one for every
satellite device). ``TimerManager`` resolves a device's entity on demand and
delegates to it, and bridges the entity's generic update events back to the
legacy per-device ``TimerHandler`` callbacks used by
wyoming/esphome/voip/mobile_app to play sounds or send notifications.
"""
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from enum import StrEnum
from functools import partial
import logging
import time
from typing import Any, override
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.timer_list import (
DATA_COMPONENT as TIMER_LIST_DATA_COMPONENT,
DOMAIN as TIMER_LIST_DOMAIN,
TimerItem,
TimerListEntity,
TimerListEvent,
TimerListEventType,
TimerStatus,
)
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import (
area_registry as ar,
config_validation as cv,
device_registry as dr,
entity_registry as er,
intent,
)
from homeassistant.util import ulid as ulid_util
from homeassistant.util import dt as dt_util
from .const import TIMER_DATA
@@ -28,12 +46,15 @@ _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"
NO_TIMER_COMMAND_RESPONSE = "no_timer_command"
@dataclass
class TimerInfo:
"""Information for a single timer."""
"""Snapshot of a single timer, for voice matching and reporting.
Built on demand from a ``timer_list`` entity's ``TimerItem``; timers are
no longer stored here, so mutating a ``TimerInfo`` has no effect.
"""
id: str
"""Unique id of the timer."""
@@ -42,31 +63,25 @@ class TimerInfo:
"""User-provided name for timer."""
seconds: int
"""Total number of seconds the timer should run for."""
"""Number of seconds left on the timer, as of this snapshot."""
device_id: str | None
"""Id of the device where the timer was set.
device_id: str
"""Id of the device whose timer list this timer belongs to."""
May be None only if conversation_command is set.
"""
start_hours: int
"""Number of hours in the timer's original duration, normalized."""
start_hours: int | None
"""Number of hours the timer should run as given by the user."""
start_minutes: int
"""Number of minutes in the timer's original duration, normalized."""
start_minutes: int | None
"""Number of minutes the timer should run as given by the user."""
start_seconds: int
"""Number of seconds in the timer's original duration, normalized."""
start_seconds: int | None
"""Number of seconds the timer should run as given by the user."""
created_at: int
"""Timestamp when timer was created (time.monotonic_ns)"""
updated_at: int
"""Timestamp when timer was last updated (time.monotonic_ns)"""
created_seconds: int
"""Number of seconds on the timer when it was created."""
language: str
"""Language of command used to set the timer."""
"""Language configured for Home Assistant."""
is_active: bool = True
"""True if timer is ticking down."""
@@ -80,81 +95,16 @@ class TimerInfo:
floor_id: str | None = None
"""Id of floor that the device's area belongs to."""
conversation_command: str | None = None
"""Text of conversation command to execute when timer is finished.
This command must be in the language used to set the timer.
"""
conversation_agent_id: str | None = None
"""Id of the conversation agent used to set the timer.
This agent will be used to execute the conversation command.
"""
_created_seconds: int = 0
"""Number of seconds on the timer when it was created."""
def __post_init__(self) -> None:
"""Post initialization."""
self._created_seconds = self.seconds
@property
def seconds_left(self) -> int:
"""Return number of seconds left on the timer."""
if not self.is_active:
return self.seconds
now = time.monotonic_ns()
seconds_running = int((now - self.updated_at) / 1e9)
return max(0, self.seconds - seconds_running)
@property
def created_seconds(self) -> int:
"""Return number of seconds on the timer when it was created.
This value is increased if time is added to the timer, exceeding its
original created_seconds.
"""
return self._created_seconds
return self.seconds
@cached_property
def name_normalized(self) -> str:
"""Return normalized timer name."""
return _normalize_name(self.name or "")
def cancel(self) -> None:
"""Cancel the timer."""
self.seconds = 0
self.updated_at = time.monotonic_ns()
self.is_active = False
def pause(self) -> None:
"""Pause the timer."""
self.seconds = self.seconds_left
self.updated_at = time.monotonic_ns()
self.is_active = False
def unpause(self) -> None:
"""Unpause the timer."""
self.updated_at = time.monotonic_ns()
self.is_active = True
def add_time(self, seconds: int) -> None:
"""Add time to the timer.
Seconds may be negative to remove time instead.
"""
self.seconds = max(0, self.seconds_left + seconds)
self._created_seconds = max(self._created_seconds, self.seconds)
self.updated_at = time.monotonic_ns()
def finish(self) -> None:
"""Finish the timer."""
self.seconds = 0
self.updated_at = time.monotonic_ns()
self.is_active = False
class TimerEventType(StrEnum):
"""Event type in timer handler."""
@@ -174,6 +124,13 @@ class TimerEventType(StrEnum):
type TimerHandler = Callable[[TimerEventType, TimerInfo], None]
_EVENT_TYPE_MAP: dict[TimerListEventType, TimerEventType] = {
TimerListEventType.STARTED: TimerEventType.STARTED,
TimerListEventType.UPDATED: TimerEventType.UPDATED,
TimerListEventType.CANCELLED: TimerEventType.CANCELLED,
TimerListEventType.FINISHED: TimerEventType.FINISHED,
}
class TimerNotFoundError(intent.IntentHandleError):
"""Error when a timer could not be found by name or start time."""
@@ -191,17 +148,6 @@ class MultipleTimersMatchedError(intent.IntentHandleError):
super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE)
class NoTimerCommandError(intent.IntentHandleError):
"""Error when a conversation command does not match any intent."""
def __init__(self, command: str) -> None:
"""Initialize error."""
super().__init__(
f"Intent not recognized: {command}",
NO_TIMER_COMMAND_RESPONSE,
)
class TimersNotSupportedError(intent.IntentHandleError):
"""Error when a timer intent is used from an unregistered device.
@@ -223,13 +169,12 @@ class TimerManager:
"""Initialize timer manager."""
self.hass = hass
# timer id -> timer
self.timers: dict[str, TimerInfo] = {}
self.timer_tasks: dict[str, asyncio.Task] = {}
# device_id -> handler
self.handlers: dict[str, TimerHandler] = {}
# entity_id -> unsubscribe, so each entity's events are bridged once
self._subscriptions: dict[str, CALLBACK_TYPE] = {}
def register_handler(
self, device_id: str, handler: TimerHandler
) -> Callable[[], None]:
@@ -244,7 +189,45 @@ class TimerManager:
return unregister
def start_timer(
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 _get_entity(self, device_id: str) -> TimerListEntity | None:
"""Return the timer_list entity for a device, if it has one."""
entity_registry = er.async_get(self.hass)
entity_id = entity_registry.async_get_entity_id(
TIMER_LIST_DOMAIN, TIMER_LIST_DOMAIN, device_id
)
if entity_id is None:
return None
component = self.hass.data[TIMER_LIST_DATA_COMPONENT]
timer_entity = component.get_entity(entity_id)
if timer_entity is None:
return None
if entity_id not in self._subscriptions:
self._subscriptions[entity_id] = timer_entity.async_subscribe_updates(
partial(self._async_handle_timer_list_event, device_id)
)
return timer_entity
@callback
def _async_handle_timer_list_event(
self, device_id: str, event: TimerListEvent
) -> None:
"""Bridge a timer_list event to the legacy per-device timer handler."""
event_type = _EVENT_TYPE_MAP.get(event.event_type)
handler = self.handlers.get(device_id)
if event_type is None or handler is None:
return
handler(event_type, _timer_info_from_item(self.hass, device_id, event.item))
async def start_timer(
self,
device_id: str | None,
hours: int | None,
@@ -252,64 +235,23 @@ class TimerManager:
seconds: int | None,
language: str,
name: str | None = None,
conversation_command: str | None = None,
conversation_agent_id: str | None = None,
) -> str:
"""Start a timer."""
if (not conversation_command) and (device_id is None):
raise ValueError("Conversation command must be set if no device id")
if (not conversation_command) and (
(device_id is None) or (not self.is_timer_device(device_id))
):
if device_id is None or (entity := self._get_entity(device_id)) is None:
raise TimersNotSupportedError(device_id)
total_seconds = 0
if hours is not None:
total_seconds += 60 * 60 * hours
if minutes is not None:
total_seconds += 60 * minutes
if seconds is not None:
total_seconds += seconds
timer_id = ulid_util.ulid_now()
created_at = time.monotonic_ns()
timer = TimerInfo(
id=timer_id,
name=name,
start_hours=hours,
start_minutes=minutes,
start_seconds=seconds,
seconds=total_seconds,
language=language,
device_id=device_id,
created_at=created_at,
updated_at=created_at,
conversation_command=conversation_command,
conversation_agent_id=conversation_agent_id,
timer_id = await entity.async_start_timer(
name=name, duration=timedelta(seconds=total_seconds)
)
# Fill in area/floor info
device_registry = dr.async_get(self.hass)
if device_id and (device := device_registry.async_get(device_id)):
timer.area_id = device.area_id
area_registry = ar.async_get(self.hass)
if device.area_id and (
area := area_registry.async_get_area(device.area_id)
):
timer.area_name = _normalize_name(area.name)
timer.floor_id = area.floor_id
self.timers[timer_id] = timer
self.timer_tasks[timer_id] = self.hass.async_create_background_task(
self._wait_for_timer(timer_id, total_seconds, created_at),
name=f"Timer {timer_id}",
)
if (not timer.conversation_command) and (timer.device_id in self.handlers):
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",
@@ -323,170 +265,56 @@ class TimerManager:
return timer_id
async def _wait_for_timer(
self, timer_id: str, seconds: int, updated_at: int
) -> None:
"""Sleep until timer is up. Timer is only finished if it hasn't been updated."""
try:
await asyncio.sleep(seconds)
if (timer := self.timers.get(timer_id)) and (
timer.updated_at == updated_at
):
self._timer_finished(timer_id)
except asyncio.CancelledError:
pass # expected when timer is updated
def cancel_timer(self, timer_id: str) -> None:
async def cancel_timer(self, device_id: str, timer_id: str) -> None:
"""Cancel a timer."""
timer = self.timers.pop(timer_id, None)
if timer is None:
entity = self._get_entity(device_id)
if entity is None:
raise TimerNotFoundError
await entity.async_cancel_timer(timer_id)
_LOGGER.debug("Timer cancelled: id=%s, device_id=%s", timer_id, device_id)
if timer.is_active:
task = self.timer_tasks.pop(timer_id)
task.cancel()
timer.cancel()
if (not timer.conversation_command) and (timer.device_id in self.handlers):
self.handlers[timer.device_id](TimerEventType.CANCELLED, timer)
_LOGGER.debug(
"Timer cancelled: id=%s, name=%s, seconds_left=%s, device_id=%s",
timer_id,
timer.name,
timer.seconds_left,
timer.device_id,
)
def add_time(self, timer_id: str, seconds: int) -> None:
async def add_time(self, device_id: str, timer_id: str, seconds: int) -> None:
"""Add time to a timer."""
timer = self.timers.get(timer_id)
if timer is None:
raise TimerNotFoundError
if seconds == 0:
# Don't bother cancelling and recreating the timer task
# Don't bother rescheduling
return
timer.add_time(seconds)
if timer.is_active:
task = self.timer_tasks.pop(timer_id)
task.cancel()
self.timer_tasks[timer_id] = self.hass.async_create_background_task(
self._wait_for_timer(timer_id, timer.seconds, timer.updated_at),
name=f"Timer {timer_id}",
)
if (not timer.conversation_command) and (timer.device_id in self.handlers):
self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
entity = self._get_entity(device_id)
if entity is None:
raise TimerNotFoundError
await entity.async_add_time(timer_id, timedelta(seconds=seconds))
if seconds > 0:
log_verb = "increased"
log_seconds = seconds
log_verb, log_seconds = "increased", seconds
else:
log_verb = "decreased"
log_seconds = -seconds
log_verb, log_seconds = "decreased", -seconds
_LOGGER.debug(
"Timer %s by %s second(s): id=%s, name=%s, seconds_left=%s, device_id=%s",
"Timer %s by %s second(s): id=%s, device_id=%s",
log_verb,
log_seconds,
timer_id,
timer.name,
timer.seconds_left,
timer.device_id,
device_id,
)
def remove_time(self, timer_id: str, seconds: int) -> None:
async def remove_time(self, device_id: str, timer_id: str, seconds: int) -> None:
"""Remove time from a timer."""
self.add_time(timer_id, -seconds)
await self.add_time(device_id, timer_id, -seconds)
def pause_timer(self, timer_id: str) -> None:
"""Pauses a timer."""
timer = self.timers.get(timer_id)
if timer is None:
async def pause_timer(self, device_id: str, timer_id: str) -> None:
"""Pause a timer."""
entity = self._get_entity(device_id)
if entity is None:
raise TimerNotFoundError
await entity.async_pause_timer(timer_id)
_LOGGER.debug("Timer paused: id=%s, device_id=%s", timer_id, device_id)
if not timer.is_active:
# Already paused
return
timer.pause()
task = self.timer_tasks.pop(timer_id)
task.cancel()
if (not timer.conversation_command) and (timer.device_id in self.handlers):
self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
_LOGGER.debug(
"Timer paused: id=%s, name=%s, seconds_left=%s, device_id=%s",
timer_id,
timer.name,
timer.seconds_left,
timer.device_id,
)
def unpause_timer(self, timer_id: str) -> None:
async def unpause_timer(self, device_id: str, timer_id: str) -> None:
"""Unpause a timer."""
timer = self.timers.get(timer_id)
if timer is None:
entity = self._get_entity(device_id)
if entity is None:
raise TimerNotFoundError
if timer.is_active:
# Already unpaused
return
timer.unpause()
self.timer_tasks[timer_id] = self.hass.async_create_background_task(
self._wait_for_timer(timer_id, timer.seconds_left, timer.updated_at),
name=f"Timer {timer.id}",
)
if (not timer.conversation_command) and (timer.device_id in self.handlers):
self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
_LOGGER.debug(
"Timer unpaused: id=%s, name=%s, seconds_left=%s, device_id=%s",
timer_id,
timer.name,
timer.seconds_left,
timer.device_id,
)
def _timer_finished(self, timer_id: str) -> None:
"""Call event handlers when a timer finishes."""
timer = self.timers.pop(timer_id)
timer.finish()
if timer.conversation_command:
from homeassistant.components.conversation import ( # noqa: PLC0415
async_converse,
)
self.hass.async_create_background_task(
async_converse(
self.hass,
timer.conversation_command,
conversation_id=None,
context=Context(),
language=timer.language,
agent_id=timer.conversation_agent_id,
device_id=timer.device_id,
),
"timer assist command",
)
elif timer.device_id in self.handlers:
self.handlers[timer.device_id](TimerEventType.FINISHED, timer)
_LOGGER.debug(
"Timer finished: id=%s, name=%s, device_id=%s",
timer_id,
timer.name,
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
await entity.async_unpause_timer(timer_id)
_LOGGER.debug("Timer unpaused: id=%s, device_id=%s", timer_id, device_id)
@callback
@@ -513,6 +341,83 @@ def async_register_timer_handler(
# -----------------------------------------------------------------------------
def _normalize_start_time(
hours: int | None, minutes: int | None, seconds: int | None
) -> tuple[int, int, int]:
"""Normalize an hours/minutes/seconds breakdown to a canonical form.
Used to compare a timer's original duration against a voice command's
start-time slots regardless of which of hours/minutes/seconds were
explicitly given (e.g. "the 5 minute timer" and "the 0 hour 5 minute 0
second timer" both normalize to the same (0, 5, 0)).
"""
total_seconds = (60 * 60 * (hours or 0)) + (60 * (minutes or 0)) + (seconds or 0)
total_minutes, norm_seconds = divmod(total_seconds, 60)
norm_hours, norm_minutes = divmod(total_minutes, 60)
return norm_hours, norm_minutes, norm_seconds
def _timer_info_from_item(
hass: HomeAssistant, device_id: str, item: TimerItem
) -> TimerInfo:
"""Build a TimerInfo snapshot from a timer_list entity's TimerItem."""
total_seconds = int(item.duration.total_seconds())
start_hours, start_minutes, start_seconds = _normalize_start_time(
None, None, total_seconds
)
area_id: str | None = None
area_name: str | None = None
floor_id: str | None = None
device_registry = dr.async_get(hass)
if device := device_registry.async_get(device_id):
area_id = device.area_id
if device.area_id:
area_registry = ar.async_get(hass)
if area := area_registry.async_get_area(device.area_id):
area_name = _normalize_name(area.name)
floor_id = area.floor_id
return TimerInfo(
id=item.timer_id,
name=item.name,
# Rounded (not truncated): a snapshot taken microseconds after the
# timer started should still read as the full nominal duration.
seconds=round(item.remaining_at(dt_util.utcnow()).total_seconds()),
device_id=device_id,
start_hours=start_hours,
start_minutes=start_minutes,
start_seconds=start_seconds,
created_seconds=total_seconds,
language=hass.config.language,
is_active=item.status == TimerStatus.ACTIVE,
area_id=area_id,
area_name=area_name,
floor_id=floor_id,
)
def _all_timer_infos(hass: HomeAssistant) -> list[TimerInfo]:
"""Return snapshots of all active/paused timers across satellite devices.
Only considers timer_list entities auto-created for a satellite device
(registry platform == "timer_list"), not a user's manually-created
local_timer_list helper (registry platform == "local_timer_list").
"""
component = hass.data[TIMER_LIST_DATA_COMPONENT]
infos: list[TimerInfo] = []
for timer_entity in component.entities:
registry_entry = timer_entity.registry_entry
if registry_entry is None or registry_entry.platform != TIMER_LIST_DOMAIN:
continue
device_id = registry_entry.unique_id
for item in timer_entity.timers:
if item.status not in (TimerStatus.ACTIVE, TimerStatus.PAUSED):
continue
infos.append(_timer_info_from_item(hass, device_id, item))
return infos
class FindTimerFilter(StrEnum):
"""Type of filter to apply when finding a timer."""
@@ -527,12 +432,7 @@ def _find_timer(
find_filter: FindTimerFilter | None = None,
) -> TimerInfo:
"""Match a single timer with constraints or raise an error."""
timer_manager: TimerManager = hass.data[TIMER_DATA]
# Ignore delayed command timers
matching_timers: list[TimerInfo] = [
t for t in timer_manager.timers.values() if not t.conversation_command
]
matching_timers: list[TimerInfo] = _all_timer_infos(hass)
has_filter = False
if find_filter:
@@ -592,12 +492,11 @@ def _find_timer(
or (start_seconds is not None)
):
has_filter = True
norm_start = _normalize_start_time(start_hours, start_minutes, start_seconds)
matching_timers = [
t
for t in matching_timers
if (t.start_hours == start_hours)
and (t.start_minutes == start_minutes)
and (t.start_seconds == start_seconds)
if (t.start_hours, t.start_minutes, t.start_seconds) == norm_start
]
if len(matching_timers) == 1:
@@ -662,12 +561,7 @@ def _find_timers(
hass: HomeAssistant, device_id: str | None, slots: dict[str, Any]
) -> list[TimerInfo]:
"""Match multiple timers with constraints or raise an error."""
timer_manager: TimerManager = hass.data[TIMER_DATA]
# Ignore delayed command timers
matching_timers: list[TimerInfo] = [
t for t in timer_manager.timers.values() if not t.conversation_command
]
matching_timers: list[TimerInfo] = _all_timer_infos(hass)
# Filter by name first
name: str | None = None
@@ -711,12 +605,11 @@ def _find_timers(
or (start_minutes is not None)
or (start_seconds is not None)
):
norm_start = _normalize_start_time(start_hours, start_minutes, start_seconds)
matching_timers = [
t
for t in matching_timers
if (t.start_hours == start_hours)
and (t.start_minutes == start_minutes)
and (t.start_seconds == start_seconds)
if (t.start_hours, t.start_minutes, t.start_seconds) == norm_start
]
if not matching_timers:
# No matches
@@ -829,7 +722,6 @@ class StartTimerIntentHandler(intent.IntentHandler):
slot_schema = {
vol.Required(vol.Any("hours", "minutes", "seconds")): cv.positive_int,
vol.Optional("name"): cv.string,
vol.Optional("conversation_command"): cv.string,
}
@override
@@ -839,25 +731,6 @@ class StartTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots)
conversation_command: str | None = None
if "conversation_command" in slots:
conversation_command = slots["conversation_command"]["value"].strip()
if (not conversation_command) and (
not (
intent_obj.device_id
and timer_manager.is_timer_device(intent_obj.device_id)
)
):
# Fail early if this is not a delayed command
raise TimersNotSupportedError(intent_obj.device_id)
# Validate conversation command if provided
if conversation_command and not await self._validate_conversation_command(
intent_obj, conversation_command
):
raise NoTimerCommandError(conversation_command)
name: str | None = None
if "name" in slots:
name = slots["name"]["value"]
@@ -874,61 +747,17 @@ class StartTimerIntentHandler(intent.IntentHandler):
if "seconds" in slots:
seconds = int(slots["seconds"]["value"])
timer_manager.start_timer(
await timer_manager.start_timer(
intent_obj.device_id,
hours,
minutes,
seconds,
language=intent_obj.language,
name=name,
conversation_command=conversation_command,
conversation_agent_id=intent_obj.conversation_agent_id,
)
return intent_obj.create_response()
async def _validate_conversation_command(
self, intent_obj: intent.Intent, conversation_command: str
) -> bool:
"""Validate that a conversation command can be executed."""
from homeassistant.components.conversation import ( # noqa: PLC0415
ConversationInput,
async_get_agent,
default_agent,
)
# Only validate if using the default agent
conversation_agent = async_get_agent(
intent_obj.hass, intent_obj.conversation_agent_id
)
if conversation_agent is None or not isinstance(
conversation_agent, default_agent.DefaultAgent
):
return True # Skip validation
test_input = ConversationInput(
text=conversation_command,
context=intent_obj.context,
conversation_id=None,
device_id=intent_obj.device_id,
satellite_id=intent_obj.satellite_id,
language=intent_obj.language,
agent_id=conversation_agent.entity_id,
)
# check for sentence trigger
if (
await conversation_agent.async_recognize_sentence_trigger(test_input)
) is not None:
return True
# check for intent
if (await conversation_agent.async_recognize_intent(test_input)) is not None:
return True
return False
class CancelTimerIntentHandler(intent.IntentHandler):
"""Intent handler for cancelling a timer."""
@@ -949,7 +778,7 @@ class CancelTimerIntentHandler(intent.IntentHandler):
slots = self.async_validate_slots(intent_obj.slots)
timer = _find_timer(hass, intent_obj.device_id, slots)
timer_manager.cancel_timer(timer.id)
await timer_manager.cancel_timer(timer.device_id, timer.id)
return intent_obj.create_response()
@@ -971,7 +800,7 @@ class CancelAllTimersIntentHandler(intent.IntentHandler):
canceled = 0
for timer in _find_timers(hass, intent_obj.device_id, slots):
timer_manager.cancel_timer(timer.id)
await timer_manager.cancel_timer(timer.device_id, timer.id)
canceled += 1
response = intent_obj.create_response()
@@ -1005,7 +834,7 @@ class IncreaseTimerIntentHandler(intent.IntentHandler):
total_seconds = _get_total_seconds(slots)
timer = _find_timer(hass, intent_obj.device_id, slots)
timer_manager.add_time(timer.id, total_seconds)
await timer_manager.add_time(timer.device_id, timer.id, total_seconds)
return intent_obj.create_response()
@@ -1030,7 +859,7 @@ class DecreaseTimerIntentHandler(intent.IntentHandler):
total_seconds = _get_total_seconds(slots)
timer = _find_timer(hass, intent_obj.device_id, slots)
timer_manager.remove_time(timer.id, total_seconds)
await timer_manager.remove_time(timer.device_id, timer.id, total_seconds)
return intent_obj.create_response()
@@ -1055,7 +884,7 @@ class PauseTimerIntentHandler(intent.IntentHandler):
timer = _find_timer(
hass, intent_obj.device_id, slots, find_filter=FindTimerFilter.ONLY_ACTIVE
)
timer_manager.pause_timer(timer.id)
await timer_manager.pause_timer(timer.device_id, timer.id)
return intent_obj.create_response()
@@ -1080,7 +909,7 @@ class UnpauseTimerIntentHandler(intent.IntentHandler):
timer = _find_timer(
hass, intent_obj.device_id, slots, find_filter=FindTimerFilter.ONLY_INACTIVE
)
timer_manager.unpause_timer(timer.id)
await timer_manager.unpause_timer(timer.device_id, timer.id)
return intent_obj.create_response()
@@ -1117,11 +946,11 @@ class TimerStatusIntentHandler(intent.IntentHandler):
{
ATTR_ID: timer.id,
ATTR_NAME: timer.name or "",
ATTR_DEVICE_ID: timer.device_id or "",
ATTR_DEVICE_ID: timer.device_id,
"language": timer.language,
"start_hours": timer.start_hours or 0,
"start_minutes": timer.start_minutes or 0,
"start_seconds": timer.start_seconds or 0,
"start_hours": timer.start_hours,
"start_minutes": timer.start_minutes,
"start_seconds": timer.start_seconds,
"is_active": timer.is_active,
"hours_left": hours,
"minutes_left": minutes,
@@ -8,6 +8,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .timer_list import LocalTimerListEntity
__all__ = ["LocalTimerListEntity"]
PLATFORMS = [Platform.TIMER_LIST]
@@ -31,7 +31,14 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the local timer list entity from a config entry."""
async_add_entities([LocalTimerListEntity(config_entry)])
async_add_entities(
[
LocalTimerListEntity(
name=config_entry.data[CONF_TIMER_LIST_NAME],
unique_id=config_entry.entry_id,
)
]
)
class LocalTimerListEntity(TimerListEntity):
@@ -44,11 +51,11 @@ class LocalTimerListEntity(TimerListEntity):
| TimerListEntityFeature.ADD_TIME
)
def __init__(self, config_entry: ConfigEntry) -> None:
def __init__(self, *, name: str, unique_id: str) -> None:
"""Initialize the timer list."""
super().__init__()
self._attr_name = config_entry.data[CONF_TIMER_LIST_NAME]
self._attr_unique_id = config_entry.entry_id
self._attr_name = name
self._attr_unique_id = unique_id
self._timers: dict[str, TimerItem] = {}
self._cancel_callbacks: dict[str, CALLBACK_TYPE] = {}
@@ -806,10 +806,10 @@ async def test_timer_events(
True,
)
# Increase timer beyond original time and check total_seconds has increased
# Increase timer beyond original time. created_seconds (the timer's
# original duration) stays fixed; only seconds_left grows.
mock_client.send_voice_assistant_timer_event.reset_mock()
total_seconds += 5 * 60
await intent_helper.async_handle(
hass,
"test",
+107 -227
View File
@@ -1,17 +1,17 @@
"""Tests for intent timers."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from collections.abc import Callable
from unittest.mock import MagicMock, patch
import pytest
from homeassistant.components import conversation
from homeassistant.components.intent import DOMAIN
from homeassistant.components.intent.timers import (
TIMER_DATA,
MultipleTimersMatchedError,
NoTimerCommandError,
TimerEventType,
TimerHandler,
TimerInfo,
TimerManager,
TimerNotFoundError,
@@ -20,11 +20,19 @@ from homeassistant.components.intent.timers import (
async_device_supports_timers,
async_register_timer_handler,
)
from homeassistant.components.local_timer_list import LocalTimerListEntity
from homeassistant.components.timer_list import (
DATA_COMPONENT as TIMER_LIST_DATA_COMPONENT,
DOMAIN as TIMER_LIST_DOMAIN,
TimerListEntity,
TimerStatus,
)
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,
entity_registry as er,
floor_registry as fr,
intent,
)
@@ -37,10 +45,36 @@ from tests.common import MockConfigEntry
async def init_components(hass: HomeAssistant) -> None:
"""Initialize required components for tests."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "conversation", {})
assert await async_setup_component(hass, DOMAIN, {})
async def _register_timer_device(
hass: HomeAssistant, device_id: str, handler: TimerHandler
) -> Callable[[], None]:
"""Give a device a timer_list entity and register its timer handler.
Mirrors what homeassistant.components.assist_satellite does for real
satellite entities.
"""
component = hass.data[TIMER_LIST_DATA_COMPONENT]
await component.async_add_entities(
[LocalTimerListEntity(name=f"{device_id} Timers", unique_id=device_id)]
)
return async_register_timer_handler(hass, device_id, handler)
def _get_timer_entity(hass: HomeAssistant, device_id: str) -> TimerListEntity:
"""Return the timer_list entity created for a device."""
entity_registry = er.async_get(hass)
entity_id = entity_registry.async_get_entity_id(
TIMER_LIST_DOMAIN, TIMER_LIST_DOMAIN, device_id
)
assert entity_id is not None
timer_entity = hass.data[TIMER_LIST_DATA_COMPONENT].get_entity(entity_id)
assert timer_entity is not None
return timer_entity
async def test_start_finish_timer(hass: HomeAssistant, init_components) -> None:
"""Test starting a timer and having it finish."""
device_id = "test_device"
@@ -56,8 +90,8 @@ async def test_start_finish_timer(hass: HomeAssistant, init_components) -> None:
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_hours == 0
assert timer.start_minutes == 0
assert timer.start_seconds == 0
assert timer.seconds_left == 0
assert timer.created_seconds == 0
@@ -69,7 +103,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, device_id, handle_timer)
await _register_timer_device(hass, device_id, handle_timer)
# A device that has been registered to handle timers is required
result = await intent.async_handle(
@@ -124,7 +158,7 @@ async def test_cancel_timer(hass: HomeAssistant, init_components) -> None:
assert timer.seconds_left == 0
cancelled_event.set()
async_register_timer_handler(hass, device_id, handle_timer)
await _register_timer_device(hass, device_id, handle_timer)
# Cancel by starting time
result = await intent.async_handle(
@@ -225,7 +259,6 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
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:
@@ -251,15 +284,17 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
elif event_type == TimerEventType.UPDATED:
assert timer.id == timer_id
# Timer was increased
# Timer was increased. created_seconds reflects the timer's
# original duration and does not grow past it, unlike the old
# in-memory TimerManager.
assert timer.seconds_left > original_total_seconds
assert timer.created_seconds == original_total_seconds + seconds_added
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)
await _register_timer_device(hass, device_id, handle_timer)
result = await intent.async_handle(
hass,
@@ -280,7 +315,6 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
await started_event.wait()
# Adding 0 seconds has no effect
seconds_added = 0
result = await intent.async_handle(
hass,
"test",
@@ -298,8 +332,7 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
assert result.response_type is intent.IntentResponseType.ACTION_DONE
assert not updated_event.is_set()
# Add 30 seconds to the timer
seconds_added = (1 * 60 * 60) + (5 * 60) + 30
# Add 1 hour, 5 minutes, and 30 seconds to the timer
result = await intent.async_handle(
hass,
"test",
@@ -376,7 +409,7 @@ async def test_decrease_timer(hass: HomeAssistant, init_components) -> None:
assert timer.id == timer_id
cancelled_event.set()
async_register_timer_handler(hass, device_id, handle_timer)
await _register_timer_device(hass, device_id, handle_timer)
result = await intent.async_handle(
hass,
@@ -429,9 +462,13 @@ async def test_decrease_timer(hass: HomeAssistant, init_components) -> None:
async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) -> None:
"""Test decreasing the time of a running timer below 0 seconds."""
"""Test decreasing the time of a running timer below 0 seconds.
Removing more time than remains finishes the timer immediately (see
LocalTimerListEntity.async_add_time), rather than emitting an
intermediate "updated" event at 0 seconds.
"""
started_event = asyncio.Event()
updated_event = asyncio.Event()
finished_event = asyncio.Event()
device_id = "test_device"
@@ -456,18 +493,12 @@ async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) -
+ 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
assert timer.seconds_left == 0
finished_event.set()
async_register_timer_handler(hass, device_id, handle_timer)
await _register_timer_device(hass, device_id, handle_timer)
result = await intent.async_handle(
hass,
@@ -502,9 +533,7 @@ async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) -
assert result.response_type is intent.IntentResponseType.ACTION_DONE
async with asyncio.timeout(1):
await asyncio.gather(
started_event.wait(), updated_event.wait(), finished_event.wait()
)
await asyncio.gather(started_event.wait(), finished_event.wait())
async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None:
@@ -536,7 +565,7 @@ async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None:
def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None:
pass
async_register_timer_handler(hass, device_id, handle_timer)
await _register_timer_device(hass, device_id, handle_timer)
# Start a 5 minute timer for pizza
result = await intent.async_handle(
@@ -635,8 +664,8 @@ 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)
await _register_timer_device(hass, device_alice_study.id, handle_timer)
await _register_timer_device(hass, device_bob_kitchen_1.id, handle_timer)
# Alice: set a 3 minute timer
result = await intent.async_handle(
@@ -727,8 +756,8 @@ 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)
await _register_timer_device(hass, device_alice_bedroom.id, handle_timer)
await _register_timer_device(hass, device_bob_living_room.id, handle_timer)
# Alice: set a 3 minute timer (study)
result = await intent.async_handle(
@@ -807,7 +836,7 @@ async def test_disambiguation(
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)
await _register_timer_device(hass, device_garage.id, handle_timer)
with pytest.raises(MultipleTimersMatchedError):
await intent.async_handle(
@@ -844,7 +873,7 @@ 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)
await _register_timer_device(hass, device_bob_kitchen_2.id, handle_timer)
# Bob cancels the kitchen timer from a different device
cancelled_event.clear()
@@ -894,7 +923,7 @@ 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, device_id, handle_timer)
await _register_timer_device(hass, device_id, handle_timer)
result = await intent.async_handle(
hass,
@@ -934,37 +963,39 @@ async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None
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)
async def test_timer_not_found(hass: HomeAssistant, init_components) -> None:
"""Test invalid device/timer ids raise TimerNotFoundError."""
timer_manager: TimerManager = hass.data[TIMER_DATA]
with pytest.raises(TimerNotFoundError):
timer_manager.cancel_timer("does-not-exist")
await timer_manager.cancel_timer("does-not-exist", "does-not-exist")
with pytest.raises(TimerNotFoundError):
timer_manager.add_time("does-not-exist", 1)
await timer_manager.add_time("does-not-exist", "does-not-exist", 1)
with pytest.raises(TimerNotFoundError):
timer_manager.remove_time("does-not-exist", 1)
await timer_manager.remove_time("does-not-exist", "does-not-exist", 1)
with pytest.raises(TimerNotFoundError):
timer_manager.pause_timer("does-not-exist")
await timer_manager.pause_timer("does-not-exist", "does-not-exist")
with pytest.raises(TimerNotFoundError):
timer_manager.unpause_timer("does-not-exist")
await timer_manager.unpause_timer("does-not-exist", "does-not-exist")
async def test_timer_manager_pause_unpause(hass: HomeAssistant) -> None:
async def test_timer_manager_pause_unpause(
hass: HomeAssistant, init_components
) -> None:
"""Test that pausing/unpausing again will not have an affect."""
timer_manager = TimerManager(hass)
timer_manager: TimerManager = hass.data[TIMER_DATA]
# Start a timer
handle_timer = MagicMock()
device_id = "test_device"
timer_manager.register_handler(device_id, handle_timer)
await _register_timer_device(hass, device_id, handle_timer)
timer_id = timer_manager.start_timer(
timer_id = await timer_manager.start_timer(
device_id,
hours=None,
minutes=5,
@@ -972,36 +1003,37 @@ async def test_timer_manager_pause_unpause(hass: HomeAssistant) -> None:
language=hass.config.language,
)
assert timer_id in timer_manager.timers
assert timer_manager.timers[timer_id].is_active
timer_entity = _get_timer_entity(hass, device_id)
(timer_item,) = [t for t in timer_entity.timers if t.timer_id == timer_id]
assert timer_item.status == TimerStatus.ACTIVE
# Pause
handle_timer.reset_mock()
timer_manager.pause_timer(timer_id)
await timer_manager.pause_timer(device_id, timer_id)
handle_timer.assert_called_once()
# Pausing again does not call handler
handle_timer.reset_mock()
timer_manager.pause_timer(timer_id)
await timer_manager.pause_timer(device_id, timer_id)
handle_timer.assert_not_called()
# Unpause
handle_timer.reset_mock()
timer_manager.unpause_timer(timer_id)
await timer_manager.unpause_timer(device_id, timer_id)
handle_timer.assert_called_once()
# Unpausing again does not call handler
handle_timer.reset_mock()
timer_manager.unpause_timer(timer_id)
await timer_manager.unpause_timer(device_id, timer_id)
handle_timer.assert_not_called()
async def test_timers_not_supported(hass: HomeAssistant) -> None:
async def test_timers_not_supported(hass: HomeAssistant, init_components) -> None:
"""Test unregistered device ids raise TimersNotSupportedError."""
timer_manager = TimerManager(hass)
timer_manager: TimerManager = hass.data[TIMER_DATA]
with pytest.raises(TimersNotSupportedError):
timer_manager.start_timer(
await timer_manager.start_timer(
"does-not-exist",
hours=None,
minutes=5,
@@ -1015,9 +1047,9 @@ async def test_timers_not_supported(hass: HomeAssistant) -> None:
pass
device_id = "test_device"
unregister = timer_manager.register_handler(device_id, handle_timer)
unregister = await _register_timer_device(hass, device_id, handle_timer)
timer_id = timer_manager.start_timer(
timer_id = await timer_manager.start_timer(
device_id,
hours=None,
minutes=5,
@@ -1025,19 +1057,15 @@ async def test_timers_not_supported(hass: HomeAssistant) -> None:
language=hass.config.language,
)
# Unregister handler so device no longer "supports" timers
# Unregister the handler; the timer_list entity (and its timer) still
# exist, so operations continue to work.
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)
await timer_manager.add_time(device_id, timer_id, 1)
await timer_manager.remove_time(device_id, timer_id, 1)
await timer_manager.pause_timer(device_id, timer_id)
await timer_manager.unpause_timer(device_id, timer_id)
await timer_manager.cancel_timer(device_id, timer_id)
async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> None:
@@ -1056,7 +1084,7 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) ->
if num_started == 4:
started_event.set()
async_register_timer_handler(hass, device_id, handle_timer)
await _register_timer_device(hass, device_id, handle_timer)
# Start timers with names
result = await intent.async_handle(
@@ -1242,8 +1270,8 @@ async def test_area_filter(
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)
await _register_timer_device(hass, device_kitchen.id, handle_timer)
await _register_timer_device(hass, device_living_room.id, handle_timer)
# Start timers in different areas
result = await intent.async_handle(
@@ -1422,154 +1450,6 @@ def test_round_time() -> None:
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 is 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_start_timer_with_sentence_trigger_validation(
hass: HomeAssistant, init_components
) -> None:
"""Test timer with conversation command validates sentence triggers."""
device_id = "test_device"
timer_name = "test timer"
test_command = "turn on the lights"
agent_id = None # Default agent
with patch(
"homeassistant.components.conversation.async_get_agent"
) as mock_get_agent:
mock_agent = MagicMock(spec=conversation.default_agent.DefaultAgent)
mock_agent.async_recognize_sentence_trigger = AsyncMock(
return_value=MagicMock(),
)
mock_agent.async_recognize_intent = AsyncMock(return_value=None)
mock_get_agent.return_value = mock_agent
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"name": {"value": timer_name},
"seconds": {"value": 5},
"conversation_command": {"value": test_command},
},
device_id=device_id,
conversation_agent_id=agent_id,
)
assert result.response_type is intent.IntentResponseType.ACTION_DONE
# Verify the sentence trigger was checked
mock_agent.async_recognize_sentence_trigger.assert_called_once()
# Verify timer was created successfully
timer_manager = hass.data[TIMER_DATA]
assert len(timer_manager.timers) == 1
async def test_start_timer_with_invalid_conversation_command(
hass: HomeAssistant, init_components
) -> None:
"""Test starting a timer with an invalid conversation command fails validation."""
device_id = "test_device"
timer_name = "test timer"
invalid_command = "invalid command that does not exist"
agent_id = None # Default agent
with pytest.raises(NoTimerCommandError):
await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"name": {"value": timer_name},
"seconds": {"value": 5},
"conversation_command": {"value": invalid_command},
},
device_id=device_id,
conversation_agent_id=agent_id,
)
# Verify no timer was created
timer_manager = hass.data[TIMER_DATA]
assert len(timer_manager.timers) == 0
async def test_start_timer_with_conversation_command_skip_validation(
hass: HomeAssistant, init_components
) -> None:
"""Test timer with conversation command skips validation for non-default agents."""
device_id = "test_device"
timer_name = "test timer"
invalid_command = "invalid command that does not exist"
agent_id = "conversation.test_llm_agent"
# This should NOT raise an error because validation is
# skipped for all non-default agents
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"name": {"value": timer_name},
"seconds": {"value": 5},
"conversation_command": {"value": invalid_command},
},
device_id=device_id,
conversation_agent_id=agent_id,
)
assert result.response_type is intent.IntentResponseType.ACTION_DONE
# Verify timer was created successfully despite invalid command
timer_manager = hass.data[TIMER_DATA]
assert len(timer_manager.timers) == 1
async def test_pause_unpause_timer_disambiguate(
hass: HomeAssistant, init_components
) -> None:
@@ -1594,7 +1474,7 @@ async def test_pause_unpause_timer_disambiguate(
else:
paused_timer_ids.append(timer.id)
async_register_timer_handler(hass, device_id, handle_timer)
await _register_timer_device(hass, device_id, handle_timer)
result = await intent.async_handle(
hass,
@@ -1690,7 +1570,7 @@ async def test_async_device_supports_timers(hass: HomeAssistant) -> None:
def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None:
pass
async_register_timer_handler(hass, device_id, handle_timer)
await _register_timer_device(hass, device_id, handle_timer)
# After handler registration
assert async_device_supports_timers(hass, device_id)
@@ -1712,7 +1592,7 @@ async def test_cancel_all_timers(hass: HomeAssistant, init_components) -> None:
if num_started == 3:
started_event.set()
async_register_timer_handler(hass, device_id, handle_timer)
await _register_timer_device(hass, device_id, handle_timer)
# Start timers
result = await intent.async_handle(
@@ -1803,8 +1683,8 @@ async def test_cancel_all_timers_area(
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)
await _register_timer_device(hass, device_kitchen.id, handle_timer)
await _register_timer_device(hass, device_living_room.id, handle_timer)
# Start timers in different areas
result = await intent.async_handle(
@@ -9,6 +9,17 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent as intent_helper
@pytest.mark.xfail(
reason=(
"Voice timers now require a timer_list entity for the device "
"(created automatically for assist_satellite entities). mobile_app "
"registers for timer events but has no assist_satellite entity, so "
"it has no timer_list entity yet and starting a timer raises "
"TimersNotSupportedError. Tracked as a known gap to fix by giving "
"mobile_app devices a timer_list entity the same way."
),
strict=True,
)
@pytest.mark.parametrize(
("intent_args", "message"),
[
+2 -3
View File
@@ -1115,12 +1115,11 @@ async def test_handle_timer_noop_when_client_disconnected(
id="test-timer",
name="test",
seconds=30,
device_id=None,
device_id="test_device",
start_hours=0,
start_minutes=0,
start_seconds=30,
created_at=0,
updated_at=0,
created_seconds=30,
language="en",
),
)