diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index f6d512dde648..24b27e363f3f 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -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): diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json index 160c832974ec..2f5797593a25 100644 --- a/homeassistant/components/assist_satellite/manifest.json +++ b/homeassistant/components/assist_satellite/manifest.json @@ -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", diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 8ee0527661a3..4bf1aa694ec0 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -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 diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d09e7d7f6481..760128758e5c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -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 diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 8e16fb286ec2..6d536bfce9db 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -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 diff --git a/homeassistant/components/local_timer_list/timer_list.py b/homeassistant/components/local_timer_list/timer_list.py index 51082df4ea68..59a8b6f79aad 100644 --- a/homeassistant/components/local_timer_list/timer_list.py +++ b/homeassistant/components/local_timer_list/timer_list.py @@ -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() diff --git a/homeassistant/components/timer_list/__init__.py b/homeassistant/components/timer_list/__init__.py index a146b87a9ae3..983ad4ec32a8 100644 --- a/homeassistant/components/timer_list/__init__.py +++ b/homeassistant/components/timer_list/__init__.py @@ -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]: diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py index 7afbd8330591..ad021b6e25f8 100644 --- a/tests/components/intent/test_timers.py +++ b/tests/components/intent/test_timers.py @@ -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