mirror of
https://github.com/home-assistant/core.git
synced 2026-04-02 08:26:41 +01:00
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
308 lines
10 KiB
Python
308 lines
10 KiB
Python
"""Provides triggers for todo platform."""
|
|
|
|
import abc
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
import functools
|
|
import logging
|
|
from typing import TYPE_CHECKING, cast, override
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.const import ATTR_ENTITY_ID, CONF_TARGET
|
|
from homeassistant.core import CALLBACK_TYPE, 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.target import TargetEntityChangeTracker, TargetSelection
|
|
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
|
from homeassistant.helpers.typing import ConfigType
|
|
|
|
from . import TodoItem, TodoListEntity
|
|
from .const import DATA_COMPONENT, DOMAIN, TodoItemStatus
|
|
|
|
ITEM_TRIGGER_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
|
}
|
|
)
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
def get_entity(hass: HomeAssistant, entity_id: str) -> TodoListEntity:
|
|
"""Get the todo entity for the provided entity_id."""
|
|
component: EntityComponent[TodoListEntity] = hass.data[DATA_COMPONENT]
|
|
if not (entity := component.get_entity(entity_id)):
|
|
raise HomeAssistantError(f"Entity does not exist: {entity_id}")
|
|
return entity
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TodoItemChangeEvent:
|
|
"""Data class for todo item change event."""
|
|
|
|
entity_id: str
|
|
items: list[TodoItem] | None
|
|
|
|
|
|
class ItemChangeListener(TargetEntityChangeTracker):
|
|
"""Helper class to listen to todo item changes for target entities."""
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
target_selection: TargetSelection,
|
|
listener: Callable[[TodoItemChangeEvent], None],
|
|
entities_updated: Callable[[set[str]], None],
|
|
) -> None:
|
|
"""Initialize the item change tracker."""
|
|
|
|
def entity_filter(entities: set[str]) -> set[str]:
|
|
return {
|
|
entity_id
|
|
for entity_id in entities
|
|
if split_entity_id(entity_id)[0] == DOMAIN
|
|
}
|
|
|
|
super().__init__(hass, target_selection, entity_filter)
|
|
self._listener = listener
|
|
self._entities_updated = entities_updated
|
|
|
|
self._pending_listener_task: asyncio.Task[None] | None = None
|
|
self._unsubscribe_listeners: list[CALLBACK_TYPE] = []
|
|
|
|
@override
|
|
@callback
|
|
def _handle_entities_update(self, tracked_entities: set[str]) -> None:
|
|
"""Restart the listeners when the list of entities of the tracked targets is updated."""
|
|
if self._pending_listener_task:
|
|
self._pending_listener_task.cancel()
|
|
self._pending_listener_task = self._hass.async_create_task(
|
|
self._start_listening(tracked_entities)
|
|
)
|
|
|
|
async def _start_listening(self, tracked_entities: set[str]) -> None:
|
|
"""Start listening for todo item changes."""
|
|
_LOGGER.debug("Tracking items for todos: %s", tracked_entities)
|
|
for unsub in self._unsubscribe_listeners:
|
|
unsub()
|
|
|
|
self._entities_updated(tracked_entities)
|
|
|
|
def _listener_wrapper(entity_id: str, items: list[TodoItem] | None) -> None:
|
|
self._listener(TodoItemChangeEvent(entity_id=entity_id, items=items))
|
|
|
|
self._unsubscribe_listeners = []
|
|
for entity_id in tracked_entities:
|
|
try:
|
|
entity = get_entity(self._hass, entity_id)
|
|
except HomeAssistantError:
|
|
_LOGGER.debug("Skipping entity %s: not found", entity_id)
|
|
continue
|
|
_listener_wrapper(entity_id, entity.todo_items)
|
|
unsub = entity.async_subscribe_updates(
|
|
functools.partial(_listener_wrapper, entity_id)
|
|
)
|
|
self._unsubscribe_listeners.append(unsub)
|
|
|
|
@override
|
|
@callback
|
|
def _unsubscribe(self) -> None:
|
|
"""Unsubscribe from all events."""
|
|
super()._unsubscribe()
|
|
if self._pending_listener_task:
|
|
self._pending_listener_task.cancel()
|
|
self._pending_listener_task = None
|
|
for unsub in self._unsubscribe_listeners:
|
|
unsub()
|
|
|
|
|
|
class ItemTriggerBase(Trigger, abc.ABC):
|
|
"""todo item trigger base."""
|
|
|
|
@classmethod
|
|
async def async_validate_config(
|
|
cls, hass: HomeAssistant, config: ConfigType
|
|
) -> ConfigType:
|
|
"""Validate config."""
|
|
return cast(ConfigType, ITEM_TRIGGER_SCHEMA(config))
|
|
|
|
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
"""Initialize trigger."""
|
|
super().__init__(hass, config)
|
|
|
|
if TYPE_CHECKING:
|
|
assert config.target is not None
|
|
self._target = config.target
|
|
|
|
async def async_attach_runner(
|
|
self, run_action: TriggerActionRunner
|
|
) -> CALLBACK_TYPE:
|
|
"""Attach a trigger."""
|
|
|
|
target_selection = TargetSelection(self._target)
|
|
if not target_selection.has_any_target:
|
|
raise HomeAssistantError(f"No target defined in {self._target}")
|
|
listener = ItemChangeListener(
|
|
self._hass,
|
|
target_selection,
|
|
functools.partial(self._handle_item_change, run_action=run_action),
|
|
self._handle_entities_updated,
|
|
)
|
|
return listener.async_setup()
|
|
|
|
@callback
|
|
@abc.abstractmethod
|
|
def _handle_item_change(
|
|
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
|
|
) -> None:
|
|
"""Handle todo item change event."""
|
|
|
|
@callback
|
|
@abc.abstractmethod
|
|
def _handle_entities_updated(self, tracked_entities: set[str]) -> None:
|
|
"""Handle entities being added/removed from the target."""
|
|
|
|
|
|
class ItemChangeTriggerBase(ItemTriggerBase):
|
|
"""todo item change trigger base class."""
|
|
|
|
def __init__(
|
|
self, hass: HomeAssistant, config: TriggerConfig, description: str
|
|
) -> None:
|
|
"""Initialize trigger."""
|
|
super().__init__(hass, config)
|
|
self._entity_item_ids: dict[str, set[str] | None] = {}
|
|
self._description = description
|
|
|
|
@abc.abstractmethod
|
|
def _is_matching_item(self, item: TodoItem) -> bool:
|
|
"""Return true if the item matches the trigger condition."""
|
|
|
|
@abc.abstractmethod
|
|
def _get_items_diff(
|
|
self, old_item_ids: set[str], current_item_ids: set[str]
|
|
) -> set[str]:
|
|
"""Return the set of item ids that should be reported for this trigger.
|
|
|
|
The calculation is based on the previous and current matching item ids.
|
|
"""
|
|
|
|
@override
|
|
@callback
|
|
def _handle_item_change(
|
|
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
|
|
) -> None:
|
|
"""Listen for todo item changes."""
|
|
entity_id = event.entity_id
|
|
if event.items is None:
|
|
self._entity_item_ids[entity_id] = None
|
|
return
|
|
|
|
old_item_ids = self._entity_item_ids.get(entity_id)
|
|
current_item_ids = {
|
|
item.uid
|
|
for item in event.items
|
|
if item.uid is not None and self._is_matching_item(item)
|
|
}
|
|
self._entity_item_ids[entity_id] = current_item_ids
|
|
if old_item_ids is None:
|
|
# Entity just became available, so no old items to compare against
|
|
return
|
|
|
|
different_item_ids = self._get_items_diff(old_item_ids, current_item_ids)
|
|
if different_item_ids:
|
|
_LOGGER.debug(
|
|
"Detected %s items with ids %s for entity %s",
|
|
self._description,
|
|
different_item_ids,
|
|
entity_id,
|
|
)
|
|
payload = {
|
|
ATTR_ENTITY_ID: entity_id,
|
|
"item_ids": sorted(different_item_ids),
|
|
}
|
|
run_action(payload, description=f"todo item {self._description} trigger")
|
|
|
|
@override
|
|
@callback
|
|
def _handle_entities_updated(self, tracked_entities: set[str]) -> None:
|
|
"""Clear stale state for entities that left the tracked set."""
|
|
for entity_id in set(self._entity_item_ids) - tracked_entities:
|
|
del self._entity_item_ids[entity_id]
|
|
|
|
|
|
class ItemAddedTrigger(ItemChangeTriggerBase):
|
|
"""todo item added trigger."""
|
|
|
|
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
"""Initialize trigger."""
|
|
super().__init__(hass, config, description="added")
|
|
|
|
@override
|
|
def _is_matching_item(self, item: TodoItem) -> bool:
|
|
"""Return true if the item matches the trigger condition."""
|
|
return True
|
|
|
|
@override
|
|
def _get_items_diff(
|
|
self, old_item_ids: set[str], current_item_ids: set[str]
|
|
) -> set[str]:
|
|
"""Return the set of item ids that match added items."""
|
|
return current_item_ids - old_item_ids
|
|
|
|
|
|
class ItemRemovedTrigger(ItemChangeTriggerBase):
|
|
"""todo item removed trigger."""
|
|
|
|
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
"""Initialize trigger."""
|
|
super().__init__(hass, config, description="removed")
|
|
|
|
@override
|
|
def _is_matching_item(self, item: TodoItem) -> bool:
|
|
"""Return true if the item matches the trigger condition."""
|
|
return True
|
|
|
|
@override
|
|
def _get_items_diff(
|
|
self, old_item_ids: set[str], current_item_ids: set[str]
|
|
) -> set[str]:
|
|
"""Return the set of item ids that match removed items."""
|
|
return old_item_ids - current_item_ids
|
|
|
|
|
|
class ItemCompletedTrigger(ItemChangeTriggerBase):
|
|
"""todo item completed trigger."""
|
|
|
|
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
|
"""Initialize trigger."""
|
|
super().__init__(hass, config, description="completed")
|
|
|
|
@override
|
|
def _is_matching_item(self, item: TodoItem) -> bool:
|
|
"""Return true if the item matches the trigger condition."""
|
|
return item.status == TodoItemStatus.COMPLETED
|
|
|
|
@override
|
|
def _get_items_diff(
|
|
self, old_item_ids: set[str], current_item_ids: set[str]
|
|
) -> set[str]:
|
|
"""Return the set of item ids that match completed items."""
|
|
return current_item_ids - old_item_ids
|
|
|
|
|
|
TRIGGERS: dict[str, type[Trigger]] = {
|
|
"item_added": ItemAddedTrigger,
|
|
"item_completed": ItemCompletedTrigger,
|
|
"item_removed": ItemRemovedTrigger,
|
|
}
|
|
|
|
|
|
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
|
"""Return the triggers for todo platform."""
|
|
return TRIGGERS
|