Add timer_list in right place

This commit is contained in:
Michael Hansen
2026-07-20 11:16:16 -05:00
parent 01b230820e
commit 60d37eb559
8 changed files with 73 additions and 259 deletions
@@ -26,19 +26,10 @@ 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.components.timer_list import (
DATA_COMPONENT as TIMER_LIST_DATA_COMPONENT,
)
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.core import Context, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
chat_session,
device_registry as dr,
entity,
entity_registry as er,
)
from homeassistant.helpers import chat_session, entity
from homeassistant.helpers.entity import EntityDescription
from .const import PREANNOUNCE_URL, AssistSatelliteEntityFeature
@@ -160,12 +151,6 @@ 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."""
@@ -744,35 +729,6 @@ 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", "local_timer_list", "stt", "tts"],
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
@@ -287,6 +287,7 @@ class RuntimeEntryData:
if self.device_info.voice_assistant_feature_flags_compat(self.api_version):
needed_platforms.add(Platform.BINARY_SENSOR)
needed_platforms.add(Platform.SELECT)
needed_platforms.add(Platform.TIMER_LIST)
# Make a dict of the EntityInfo by type and send
# them to the listeners for each specific EntityInfo type
@@ -4,7 +4,14 @@
"after_dependencies": ["hassio", "tag", "usb", "zeroconf"],
"codeowners": ["@jesserockz", "@kbx81", "@bdraco"],
"config_flow": true,
"dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"],
"dependencies": [
"assist_pipeline",
"bluetooth",
"intent",
"ffmpeg",
"http",
"timer_list"
],
"dhcp": [
{
"registered_devices": true
+20 -8
View File
@@ -195,10 +195,22 @@ class TimerManager:
@callback
def _get_entity(self, device_id: str) -> TimerListEntity | None:
"""Return the timer_list entity for a device, if it has one."""
"""Return the timer_list entity for a device, if it has one.
The list is provided by the device's own integration, so it may live on
any platform; resolve it by device association rather than assuming the
``timer_list`` platform owns it.
"""
entity_registry = er.async_get(self.hass)
entity_id = entity_registry.async_get_entity_id(
TIMER_LIST_DOMAIN, TIMER_LIST_DOMAIN, device_id
entity_id = next(
(
entry.entity_id
for entry in er.async_entries_for_device(
entity_registry, device_id, include_disabled_entities=True
)
if entry.domain == TIMER_LIST_DOMAIN
),
None,
)
if entity_id is None:
return None
@@ -400,17 +412,17 @@ def _timer_info_from_item(
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").
Only device-linked timer lists (each provided by a satellite's own
integration) are considered, not a user's standalone local_timer_list
helper, which has no associated device.
"""
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:
if registry_entry is None or registry_entry.device_id is None:
continue
device_id = registry_entry.unique_id
device_id = registry_entry.device_id
for item in timer_entity.timers:
if item.status not in (TimerStatus.ACTIVE, TimerStatus.PAUSED):
continue
@@ -1,28 +1,16 @@
"""Local timer list platform."""
from datetime import datetime, timedelta
from functools import partial
from typing import override
from homeassistant.components.timer_list import (
DOMAIN as TIMER_LIST_DOMAIN,
TimerItem,
TimerListEntity,
TimerListEntityFeature,
TimerListEventType,
TimerStatus,
)
from homeassistant.components.timer_list import InMemoryTimerListEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util import dt as dt_util, ulid as ulid_util
from .const import CONF_TIMER_LIST_NAME
_FINISHED_STATUSES = (TimerStatus.FINISHED, TimerStatus.CANCELLED)
MAX_ARCHIVED_TIMERS = 10
# Kept as a named subclass so UI-created lists have a stable, distinct type.
class LocalTimerListEntity(InMemoryTimerListEntity):
"""A standalone, UI-created in-memory timer list."""
async def async_setup_entry(
@@ -39,175 +27,3 @@ async def async_setup_entry(
)
]
)
class LocalTimerListEntity(TimerListEntity):
"""A local, in-memory timer list."""
_attr_supported_features = (
TimerListEntityFeature.START_TIMER
| TimerListEntityFeature.PAUSE_TIMER
| TimerListEntityFeature.CANCEL_TIMER
| TimerListEntityFeature.ADD_TIME
)
def __init__(self, *, name: str, unique_id: str) -> None:
"""Initialize the timer list."""
super().__init__()
self._attr_name = name
self._attr_unique_id = unique_id
self._timers: dict[str, TimerItem] = {}
self._cancel_callbacks: dict[str, CALLBACK_TYPE] = {}
@property
@override
def timers(self) -> list[TimerItem]:
"""Return the timers in the list."""
return list(self._timers.values())
@override
async def async_start_timer(self, *, name: str | None, duration: timedelta) -> str:
"""Create and start a new timer, returning its id."""
now = dt_util.utcnow()
timer_id = ulid_util.ulid_now()
timer = TimerItem(
timer_id=timer_id,
name=name,
status=TimerStatus.ACTIVE,
duration=duration,
created_at=now,
finishes_at=now + duration,
)
self._timers[timer_id] = timer
self._schedule(timer)
self._notify(TimerListEventType.STARTED, timer)
return timer_id
@override
async def async_pause_timer(self, timer_id: str) -> None:
"""Pause an active timer."""
timer = self._get_timer(timer_id)
if timer.status != TimerStatus.ACTIVE or timer.finishes_at is None:
return
timer.remaining = max(timedelta(0), timer.finishes_at - dt_util.utcnow())
timer.finishes_at = None
timer.status = TimerStatus.PAUSED
self._unschedule(timer_id)
self._notify(TimerListEventType.UPDATED, timer)
@override
async def async_unpause_timer(self, timer_id: str) -> None:
"""Resume a paused timer."""
timer = self._get_timer(timer_id)
if timer.status != TimerStatus.PAUSED or timer.remaining is None:
return
timer.finishes_at = dt_util.utcnow() + timer.remaining
timer.remaining = None
timer.status = TimerStatus.ACTIVE
self._schedule(timer)
self._notify(TimerListEventType.UPDATED, timer)
@override
async def async_cancel_timer(self, timer_id: str) -> None:
"""Cancel a timer, archiving it in the ``cancelled`` state."""
timer = self._get_timer(timer_id)
self._unschedule(timer_id)
timer.status = TimerStatus.CANCELLED
timer.finishes_at = None
timer.remaining = None
timer.finished_at = dt_util.utcnow()
self._notify(TimerListEventType.CANCELLED, timer)
self._enforce_archive_limit()
@override
async def async_add_time(self, timer_id: str, duration: timedelta) -> None:
"""Add (or, with a negative duration, remove) time on a timer."""
timer = self._get_timer(timer_id)
if timer.status == TimerStatus.ACTIVE and timer.finishes_at is not None:
now = dt_util.utcnow()
finishes_at = timer.finishes_at + duration
if finishes_at <= now:
self._unschedule(timer_id)
self._async_timer_finished(timer_id, now)
return
timer.finishes_at = finishes_at
self._schedule(timer)
elif timer.status == TimerStatus.PAUSED and timer.remaining is not None:
timer.remaining = max(timedelta(0), timer.remaining + duration)
else:
return
self._notify(TimerListEventType.UPDATED, timer)
@override
async def async_remove_timer(self, timer_id: str) -> None:
"""Remove a timer from the list regardless of its status."""
timer = self._get_timer(timer_id)
self._unschedule(timer_id)
del self._timers[timer_id]
self._notify(TimerListEventType.REMOVED, timer)
def _get_timer(self, timer_id: str) -> TimerItem:
"""Return a timer by id or raise if it does not exist."""
if (timer := self._timers.get(timer_id)) is None:
raise ServiceValidationError(
translation_domain=TIMER_LIST_DOMAIN,
translation_key="timer_not_found",
translation_placeholders={"timer_id": timer_id},
)
return timer
@callback
def _schedule(self, timer: TimerItem) -> None:
"""Schedule (or reschedule) the finish callback for a timer."""
self._unschedule(timer.timer_id)
assert timer.finishes_at is not None
self._cancel_callbacks[timer.timer_id] = async_track_point_in_utc_time(
self.hass,
partial(self._async_timer_finished, timer.timer_id),
timer.finishes_at,
)
@callback
def _unschedule(self, timer_id: str) -> None:
"""Cancel a pending finish callback, if any."""
if cancel := self._cancel_callbacks.pop(timer_id, None):
cancel()
@callback
def _async_timer_finished(self, timer_id: str, now: datetime) -> None:
"""Handle a timer reaching its finish time, archiving it."""
self._cancel_callbacks.pop(timer_id, None)
if (timer := self._timers.get(timer_id)) is None:
return
timer.status = TimerStatus.FINISHED
timer.finishes_at = None
timer.remaining = None
timer.finished_at = dt_util.utcnow()
self._notify(TimerListEventType.FINISHED, timer)
self._enforce_archive_limit()
@callback
def _enforce_archive_limit(self) -> None:
"""Evict the oldest archived timers beyond ``MAX_ARCHIVED_TIMERS``."""
archived = sorted(
(
timer
for timer in self._timers.values()
if timer.status in _FINISHED_STATUSES
),
key=lambda timer: timer.finished_at or dt_util.utcnow(),
)
excess = len(archived) - MAX_ARCHIVED_TIMERS
if excess <= 0:
return
for timer in archived[:excess]:
del self._timers[timer.timer_id]
self._notify(TimerListEventType.REMOVED, timer)
@override
async def async_will_remove_from_hass(self) -> None:
"""Cancel all pending finish callbacks."""
for cancel in self._cancel_callbacks.values():
cancel()
self._cancel_callbacks.clear()
@@ -270,6 +270,10 @@ class TimerListEntity(Entity):
self.async_write_ha_state()
# Imported at the end so the reusable entity can subclass TimerListEntity above.
from .local import InMemoryTimerListEntity as InMemoryTimerListEntity # noqa: E402
async def _async_start_timer(
entity: TimerListEntity, call: ServiceCall
) -> dict[str, Any]:
+31 -13
View File
@@ -48,6 +48,17 @@ async def init_components(hass: HomeAssistant) -> None:
assert await async_setup_component(hass, DOMAIN, {})
def _make_timer_device_id(hass: HomeAssistant) -> str:
"""Create a real device to host a timer_list entity and return its id."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
device = dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", entry.entry_id)},
)
return device.id
async def _register_timer_device(
hass: HomeAssistant, device_id: str, handler: TimerHandler
) -> Callable[[], None]:
@@ -60,6 +71,13 @@ async def _register_timer_device(
await component.async_add_entities(
[LocalTimerListEntity(name=f"{device_id} Timers", unique_id=device_id)]
)
# The list is resolved by device association, so link it to the 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
entity_registry.async_update_entity(entity_id, device_id=device_id)
return async_register_timer_handler(hass, device_id, handler)
@@ -77,7 +95,7 @@ def _get_timer_entity(hass: HomeAssistant, device_id: str) -> TimerListEntity:
async def test_start_finish_timer(hass: HomeAssistant, init_components) -> None:
"""Test starting a timer and having it finish."""
device_id = "test_device"
device_id = _make_timer_device_id(hass)
timer_name = "test timer"
started_event = asyncio.Event()
finished_event = asyncio.Event()
@@ -125,7 +143,7 @@ async def test_start_finish_timer(hass: HomeAssistant, init_components) -> None:
async def test_cancel_timer(hass: HomeAssistant, init_components) -> None:
"""Test cancelling a timer."""
device_id = "test_device"
device_id = _make_timer_device_id(hass)
timer_name: str | None = None
started_event = asyncio.Event()
cancelled_event = asyncio.Event()
@@ -251,7 +269,7 @@ async def test_cancel_timer(hass: HomeAssistant, init_components) -> None:
async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
"""Test increasing the time of a running timer."""
device_id = "test_device"
device_id = _make_timer_device_id(hass)
started_event = asyncio.Event()
updated_event = asyncio.Event()
cancelled_event = asyncio.Event()
@@ -368,7 +386,7 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
async def test_decrease_timer(hass: HomeAssistant, init_components) -> None:
"""Test decreasing the time of a running timer."""
device_id = "test_device"
device_id = _make_timer_device_id(hass)
started_event = asyncio.Event()
updated_event = asyncio.Event()
cancelled_event = asyncio.Event()
@@ -471,7 +489,7 @@ async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) -
started_event = asyncio.Event()
finished_event = asyncio.Event()
device_id = "test_device"
device_id = _make_timer_device_id(hass)
timer_id: str | None = None
original_total_seconds = 0
@@ -538,7 +556,7 @@ async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) -
async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None:
"""Test finding a timer with the wrong info."""
device_id = "test_device"
device_id = _make_timer_device_id(hass)
# No device id
with pytest.raises(TimersNotSupportedError):
@@ -908,7 +926,7 @@ async def test_disambiguation(
async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None:
"""Test pausing and unpausing a running timer."""
device_id = "test_device"
device_id = _make_timer_device_id(hass)
started_event = asyncio.Event()
updated_event = asyncio.Event()
@@ -992,7 +1010,7 @@ async def test_timer_manager_pause_unpause(
# Start a timer
handle_timer = MagicMock()
device_id = "test_device"
device_id = _make_timer_device_id(hass)
await _register_timer_device(hass, device_id, handle_timer)
timer_id = await timer_manager.start_timer(
@@ -1046,7 +1064,7 @@ async def test_timers_not_supported(hass: HomeAssistant, init_components) -> Non
def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None:
pass
device_id = "test_device"
device_id = _make_timer_device_id(hass)
unregister = await _register_timer_device(hass, device_id, handle_timer)
timer_id = await timer_manager.start_timer(
@@ -1070,7 +1088,7 @@ async def test_timers_not_supported(hass: HomeAssistant, init_components) -> Non
async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> None:
"""Test getting the status of named timers."""
device_id = "test_device"
device_id = _make_timer_device_id(hass)
started_event = asyncio.Event()
num_started = 0
@@ -1454,7 +1472,7 @@ async def test_pause_unpause_timer_disambiguate(
hass: HomeAssistant, init_components
) -> None:
"""Test disamgibuating timers by their paused state."""
device_id = "test_device"
device_id = _make_timer_device_id(hass)
started_timer_ids: list[str] = []
paused_timer_ids: list[str] = []
unpaused_timer_ids: list[str] = []
@@ -1557,7 +1575,7 @@ async def test_pause_unpause_timer_disambiguate(
async def test_async_device_supports_timers(hass: HomeAssistant) -> None:
"""Test async_device_supports_timers function."""
device_id = "test_device"
device_id = _make_timer_device_id(hass)
# Before intent initialization
assert not async_device_supports_timers(hass, device_id)
@@ -1578,7 +1596,7 @@ async def test_async_device_supports_timers(hass: HomeAssistant) -> None:
async def test_cancel_all_timers(hass: HomeAssistant, init_components) -> None:
"""Test cancelling all timers."""
device_id = "test_device"
device_id = _make_timer_device_id(hass)
started_event = asyncio.Event()
num_started = 0