diff --git a/homeassistant/components/todo/trigger.py b/homeassistant/components/todo/trigger.py index d04b56412921..d0b77f0d8475 100644 --- a/homeassistant/components/todo/trigger.py +++ b/homeassistant/components/todo/trigger.py @@ -10,11 +10,24 @@ from typing import TYPE_CHECKING, cast, override import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS, CONF_TARGET -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_OPTIONS, + CONF_TARGET, + STATE_UNAVAILABLE, +) +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + EventStateChangedData, + HomeAssistant, + callback, + split_entity_id, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.target import TargetEntityChangeTracker, TargetSelection from homeassistant.helpers.trigger import ( Trigger, @@ -79,6 +92,7 @@ class ItemChangeListener(TargetEntityChangeTracker): self._pending_listener_task: asyncio.Task[None] | None = None self._unsubscribe_listeners: list[CALLBACK_TYPE] = [] + self._state_change_unsub: CALLBACK_TYPE | None = None @override @callback @@ -114,6 +128,31 @@ class ItemChangeListener(TargetEntityChangeTracker): ) self._unsubscribe_listeners.append(unsub) + if self._state_change_unsub: + self._state_change_unsub() + self._state_change_unsub = None + if tracked_entities: + self._state_change_unsub = async_track_state_change_event( + self._hass, tracked_entities, self._async_todo_entity_state_changed + ) + + @callback + def _async_todo_entity_state_changed( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle entities becoming available. + + This is required so that when the config entry is reloaded, + we start listening on the newly created classes. + """ + new_state = event.data["new_state"] + old_state = event.data["old_state"] + if new_state is None or new_state.state == STATE_UNAVAILABLE: + return + if old_state is not None and old_state.state != STATE_UNAVAILABLE: + return + self._handle_entities_update(self._referenced_entities()) + @override @callback def _unsubscribe(self) -> None: @@ -122,6 +161,9 @@ class ItemChangeListener(TargetEntityChangeTracker): if self._pending_listener_task: self._pending_listener_task.cancel() self._pending_listener_task = None + if self._state_change_unsub: + self._state_change_unsub() + self._state_change_unsub = None for unsub in self._unsubscribe_listeners: unsub() diff --git a/tests/components/todo/test_trigger.py b/tests/components/todo/test_trigger.py index e63493f1bb8b..94be49c4571f 100644 --- a/tests/components/todo/test_trigger.py +++ b/tests/components/todo/test_trigger.py @@ -12,6 +12,7 @@ from homeassistant.components.todo import ( TodoListEntityFeature, ) from homeassistant.components.todo.const import ATTR_ITEM, ATTR_STATUS, TodoServices +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_AREA_ID, ATTR_DEVICE_ID, @@ -31,11 +32,17 @@ from homeassistant.helpers import ( floor_registry as fr, label_registry as lr, ) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component -from . import MockTodoListEntity, create_mock_platform +from . import TEST_DOMAIN, MockTodoListEntity, create_mock_platform -from tests.common import async_mock_service, mock_device_registry +from tests.common import ( + MockPlatform, + async_mock_service, + mock_device_registry, + mock_platform, +) from tests.components.common import assert_trigger_options_supported TODO_ENTITY_ID1 = "todo.list_one" @@ -474,6 +481,83 @@ async def test_new_entity_added_to_target_fires_triggers( ) +async def test_item_change_triggers_after_config_entry_reload( + hass: HomeAssistant, + service_calls: list[ServiceCall], +) -> None: + """Test triggers still fire after the todo list's config entry is reloaded. + + Reload recreates the entity object without changing the registry entry, so + the trigger must re-subscribe rather than keep the old object. + """ + await _setup_automation(hass, {CONF_ENTITY_ID: TODO_ENTITY_ID1}) + + config_entry = hass.config_entries.async_entries(TEST_DOMAIN)[0] + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + async_add_entities( + [ + _make_entity( + TODO_ENTITY_ID1, + unique_id="list_one", + items=[ + TodoItem( + summary="existing_item", + uid="existing_id", + status=TodoItemStatus.NEEDS_ACTION, + ) + ], + ), + _make_entity(TODO_ENTITY_ID2, unique_id="list_two"), + ] + ) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + assert await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + await _add_item(hass, TODO_ENTITY_ID1, "item_after_reload") + _assert_service_calls( + service_calls, + [{"platform": "todo.item_added", "entity_id": TODO_ENTITY_ID1}], + ) + item_id = service_calls[0].data["item_ids"][0] + service_calls.clear() + + await _complete_item(hass, TODO_ENTITY_ID1, item_id) + _assert_service_calls( + service_calls, + [ + { + "platform": "todo.item_completed", + "entity_id": TODO_ENTITY_ID1, + "item_ids": [item_id], + } + ], + ) + service_calls.clear() + + await _remove_item(hass, TODO_ENTITY_ID1, item_id) + _assert_service_calls( + service_calls, + [ + { + "platform": "todo.item_removed", + "entity_id": TODO_ENTITY_ID1, + "item_ids": [item_id], + } + ], + ) + + async def test_trigger_skips_missing_entity( hass: HomeAssistant, service_calls: list[ServiceCall],