From f049fbdf77421bc98db40f11182865fa7e155490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 14 Jan 2026 11:12:17 +0000 Subject: [PATCH] Add calendar event_started/event_ended triggers (#159659) --- homeassistant/components/calendar/icons.json | 8 + .../components/calendar/strings.json | 40 +- homeassistant/components/calendar/trigger.py | 203 +++++- .../components/calendar/triggers.yaml | 27 + homeassistant/helpers/target.py | 87 ++- tests/components/calendar/conftest.py | 10 +- tests/components/calendar/test_trigger.py | 658 ++++++++++++++---- 7 files changed, 831 insertions(+), 202 deletions(-) create mode 100644 homeassistant/components/calendar/triggers.yaml diff --git a/homeassistant/components/calendar/icons.json b/homeassistant/components/calendar/icons.json index 804a3a4b04f..e2faf13658c 100644 --- a/homeassistant/components/calendar/icons.json +++ b/homeassistant/components/calendar/icons.json @@ -15,5 +15,13 @@ "get_events": { "service": "mdi:calendar-month" } + }, + "triggers": { + "event_ended": { + "trigger": "mdi:calendar-end" + }, + "event_started": { + "trigger": "mdi:calendar-start" + } } } diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index c8117140d9d..b4169132e86 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -45,6 +45,14 @@ "title": "Detected use of deprecated action calendar.list_events" } }, + "selector": { + "trigger_offset_type": { + "options": { + "after": "After", + "before": "Before" + } + } + }, "services": { "create_event": { "description": "Adds a new calendar event.", @@ -103,5 +111,35 @@ "name": "Get events" } }, - "title": "Calendar" + "title": "Calendar", + "triggers": { + "event_ended": { + "description": "Triggers when a calendar event ends.", + "fields": { + "offset": { + "description": "Offset from the end of the event.", + "name": "Offset" + }, + "offset_type": { + "description": "Whether to trigger before or after the end of the event, if an offset is defined.", + "name": "Offset type" + } + }, + "name": "Calendar event ended" + }, + "event_started": { + "description": "Triggers when a calendar event starts.", + "fields": { + "offset": { + "description": "Offset from the start of the event.", + "name": "Offset" + }, + "offset_type": { + "description": "Whether to trigger before or after the start of the event, if an offset is defined.", + "name": "Offset type" + } + }, + "name": "Calendar event started" + } + } } diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index ec96d23a424..18ab33516e7 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass import datetime @@ -10,8 +11,15 @@ from typing import TYPE_CHECKING, Any, cast import voluptuous as vol -from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_OPTIONS -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_ENTITY_ID, + CONF_EVENT, + CONF_OFFSET, + CONF_OPTIONS, + 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.automation import move_top_level_schema_fields_to_options @@ -20,12 +28,13 @@ from homeassistant.helpers.event import ( async_track_point_in_time, async_track_time_interval, ) +from homeassistant.helpers.target import TargetEntityChangeTracker, TargetSelection from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from . import CalendarEntity, CalendarEvent -from .const import DATA_COMPONENT +from .const import DATA_COMPONENT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,19 +42,35 @@ EVENT_START = "start" EVENT_END = "end" UPDATE_INTERVAL = datetime.timedelta(minutes=15) +CONF_OFFSET_TYPE = "offset_type" +OFFSET_TYPE_BEFORE = "before" +OFFSET_TYPE_AFTER = "after" -_OPTIONS_SCHEMA_DICT = { + +_SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA = { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}), vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period, } -_CONFIG_SCHEMA = vol.Schema( +_SINGLE_ENTITY_EVENT_TRIGGER_SCHEMA = vol.Schema( { - vol.Required(CONF_OPTIONS): _OPTIONS_SCHEMA_DICT, + vol.Required(CONF_OPTIONS): _SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA, }, ) +_EVENT_TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS, default={}): { + vol.Required(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period, + vol.Required(CONF_OFFSET_TYPE, default=OFFSET_TYPE_BEFORE): vol.In( + {OFFSET_TYPE_BEFORE, OFFSET_TYPE_AFTER} + ), + }, + vol.Required(CONF_TARGET): cv.TARGET_FIELDS, + } +) + # mypy: disallow-any-generics @@ -55,6 +80,7 @@ class QueuedCalendarEvent: trigger_time: datetime.datetime event: CalendarEvent + entity_id: str @dataclass @@ -94,7 +120,7 @@ class Timespan: return f"[{self.start}, {self.end})" -type EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]] +type EventFetcher = Callable[[Timespan], Awaitable[list[tuple[str, CalendarEvent]]]] type QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]] @@ -110,15 +136,24 @@ def get_entity(hass: HomeAssistant, entity_id: str) -> CalendarEntity: return entity -def event_fetcher(hass: HomeAssistant, entity_id: str) -> EventFetcher: +def event_fetcher(hass: HomeAssistant, entity_ids: set[str]) -> EventFetcher: """Build an async_get_events wrapper to fetch events during a time span.""" - async def async_get_events(timespan: Timespan) -> list[CalendarEvent]: + async def async_get_events(timespan: Timespan) -> list[tuple[str, CalendarEvent]]: """Return events active in the specified time span.""" - entity = get_entity(hass, entity_id) # Expand by one second to make the end time exclusive end_time = timespan.end + datetime.timedelta(seconds=1) - return await entity.async_get_events(hass, timespan.start, end_time) + + events: list[tuple[str, CalendarEvent]] = [] + for entity_id in entity_ids: + entity = get_entity(hass, entity_id) + events.extend( + (entity_id, event) + for event in await entity.async_get_events( + hass, timespan.start, end_time + ) + ) + return events return async_get_events @@ -142,12 +177,11 @@ def queued_event_fetcher( # Example: For an EVENT_END trigger the event may start during this # time span, but need to be triggered later when the end happens. results = [] - for trigger_time, event in zip( - map(get_trigger_time, active_events), active_events, strict=False - ): + for entity_id, event in active_events: + trigger_time = get_trigger_time(event) if trigger_time not in offset_timespan: continue - results.append(QueuedCalendarEvent(trigger_time + offset, event)) + results.append(QueuedCalendarEvent(trigger_time + offset, event, entity_id)) _LOGGER.debug( "Scan events @ %s%s found %s eligible of %s active", @@ -240,6 +274,7 @@ class CalendarEventListener: _LOGGER.debug("Dispatching event: %s", queued_event.event) payload = { **self._trigger_payload, + ATTR_ENTITY_ID: queued_event.entity_id, "calendar_event": queued_event.event.as_dict(), } self._action_runner(payload, "calendar event state change") @@ -260,8 +295,77 @@ class CalendarEventListener: self._listen_next_calendar_event() -class EventTrigger(Trigger): - """Calendar event trigger.""" +class TargetCalendarEventListener(TargetEntityChangeTracker): + """Helper class to listen to calendar events for target entity changes.""" + + def __init__( + self, + hass: HomeAssistant, + target_selection: TargetSelection, + event_type: str, + offset: datetime.timedelta, + run_action: TriggerActionRunner, + ) -> None: + """Initialize the state 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._event_type = event_type + self._offset = offset + self._run_action = run_action + self._trigger_data = { + "event": event_type, + "offset": offset, + } + + self._pending_listener_task: asyncio.Task[None] | None = None + self._calendar_event_listener: CalendarEventListener | None = None + + @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 calendar events.""" + _LOGGER.debug("Tracking events for calendars: %s", tracked_entities) + if self._calendar_event_listener: + self._calendar_event_listener.async_detach() + self._calendar_event_listener = CalendarEventListener( + self._hass, + self._run_action, + self._trigger_data, + queued_event_fetcher( + event_fetcher(self._hass, tracked_entities), + self._event_type, + self._offset, + ), + ) + await self._calendar_event_listener.async_attach() + + 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 + if self._calendar_event_listener: + self._calendar_event_listener.async_detach() + self._calendar_event_listener = None + + +class SingleEntityEventTrigger(Trigger): + """Legacy single calendar entity event trigger.""" _options: dict[str, Any] @@ -271,7 +375,7 @@ class EventTrigger(Trigger): ) -> ConfigType: """Validate complete config.""" complete_config = move_top_level_schema_fields_to_options( - complete_config, _OPTIONS_SCHEMA_DICT + complete_config, _SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA ) return await super().async_validate_complete_config(hass, complete_config) @@ -280,7 +384,7 @@ class EventTrigger(Trigger): cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - return cast(ConfigType, _CONFIG_SCHEMA(config)) + return cast(ConfigType, _SINGLE_ENTITY_EVENT_TRIGGER_SCHEMA(config)) def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" @@ -311,15 +415,72 @@ class EventTrigger(Trigger): run_action, trigger_data, queued_event_fetcher( - event_fetcher(self._hass, entity_id), event_type, offset + event_fetcher(self._hass, {entity_id}), event_type, offset ), ) await listener.async_attach() return listener.async_detach +class EventTrigger(Trigger): + """Calendar event trigger.""" + + _options: dict[str, Any] + _event_type: str + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return cast(ConfigType, _EVENT_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 + assert config.options is not None + self._target = config.target + self._options = config.options + + async def async_attach_runner( + self, run_action: TriggerActionRunner + ) -> CALLBACK_TYPE: + """Attach a trigger.""" + + offset = self._options[CONF_OFFSET] + offset_type = self._options[CONF_OFFSET_TYPE] + + if offset_type == OFFSET_TYPE_BEFORE: + offset = -offset + + target_selection = TargetSelection(self._target) + if not target_selection.has_any_target: + raise HomeAssistantError(f"No target defined in {self._target}") + listener = TargetCalendarEventListener( + self._hass, target_selection, self._event_type, offset, run_action + ) + return listener.async_setup() + + +class EventStartedTrigger(EventTrigger): + """Calendar event started trigger.""" + + _event_type = EVENT_START + + +class EventEndedTrigger(EventTrigger): + """Calendar event ended trigger.""" + + _event_type = EVENT_END + + TRIGGERS: dict[str, type[Trigger]] = { - "_": EventTrigger, + "_": SingleEntityEventTrigger, + "event_started": EventStartedTrigger, + "event_ended": EventEndedTrigger, } diff --git a/homeassistant/components/calendar/triggers.yaml b/homeassistant/components/calendar/triggers.yaml new file mode 100644 index 00000000000..37599b4515a --- /dev/null +++ b/homeassistant/components/calendar/triggers.yaml @@ -0,0 +1,27 @@ +.trigger_common: &trigger_common + target: + entity: + domain: calendar + fields: + offset: + required: true + default: + days: 0 + hours: 0 + minutes: 0 + seconds: 0 + selector: + duration: + enable_day: true + offset_type: + required: true + default: before + selector: + select: + translation_key: trigger_offset_type + options: + - before + - after + +event_started: *trigger_common +event_ended: *trigger_common diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index b65ed720a82..334b7147e01 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -2,6 +2,7 @@ from __future__ import annotations +import abc from collections.abc import Callable import dataclasses import logging @@ -268,65 +269,47 @@ def async_extract_referenced_entity_ids( return selected -class TargetStateChangeTracker: - """Helper class to manage state change tracking for targets.""" +class TargetEntityChangeTracker(abc.ABC): + """Helper class to manage entity change tracking for targets.""" def __init__( self, hass: HomeAssistant, target_selection: TargetSelection, - action: Callable[[TargetStateChangedData], Any], entity_filter: Callable[[set[str]], set[str]], ) -> None: """Initialize the state change tracker.""" self._hass = hass self._target_selection = target_selection - self._action = action self._entity_filter = entity_filter - self._state_change_unsub: CALLBACK_TYPE | None = None self._registry_unsubs: list[CALLBACK_TYPE] = [] def async_setup(self) -> Callable[[], None]: """Set up the state change tracking.""" self._setup_registry_listeners() - self._track_entities_state_change() + self._handle_target_update() return self._unsubscribe - def _track_entities_state_change(self) -> None: - """Set up state change tracking for currently selected entities.""" + @abc.abstractmethod + @callback + def _handle_entities_update(self, tracked_entities: set[str]) -> None: + """Called when there's an update to the list of entities of the tracked targets.""" + + @callback + def _handle_target_update(self, event: Event[Any] | None = None) -> None: + """Handle updates in the tracked targets.""" selected = async_extract_referenced_entity_ids( self._hass, self._target_selection, expand_group=False ) - - tracked_entities = self._entity_filter( + filtered_entities = self._entity_filter( selected.referenced | selected.indirectly_referenced ) - - @callback - def state_change_listener(event: Event[EventStateChangedData]) -> None: - """Handle state change events.""" - if ( - event.data["entity_id"] in selected.referenced - or event.data["entity_id"] in selected.indirectly_referenced - ): - self._action(TargetStateChangedData(event, tracked_entities)) - - _LOGGER.debug("Tracking state changes for entities: %s", tracked_entities) - self._state_change_unsub = async_track_state_change_event( - self._hass, tracked_entities, state_change_listener - ) + self._handle_entities_update(filtered_entities) def _setup_registry_listeners(self) -> None: """Set up listeners for registry changes that require resubscription.""" - @callback - def resubscribe_state_change_event(event: Event[Any] | None = None) -> None: - """Resubscribe to state change events when registry changes.""" - if self._state_change_unsub: - self._state_change_unsub() - self._track_entities_state_change() - # Subscribe to registry updates that can change the entities to track: # - Entity registry: entity added/removed; entity labels changed; entity area changed. # - Device registry: device labels changed; device area changed. @@ -336,13 +319,13 @@ class TargetStateChangeTracker: # changes don't affect which entities are tracked. self._registry_unsubs = [ self._hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, resubscribe_state_change_event + er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_target_update ), self._hass.bus.async_listen( - dr.EVENT_DEVICE_REGISTRY_UPDATED, resubscribe_state_change_event + dr.EVENT_DEVICE_REGISTRY_UPDATED, self._handle_target_update ), self._hass.bus.async_listen( - ar.EVENT_AREA_REGISTRY_UPDATED, resubscribe_state_change_event + ar.EVENT_AREA_REGISTRY_UPDATED, self._handle_target_update ), ] @@ -351,6 +334,42 @@ class TargetStateChangeTracker: for registry_unsub in self._registry_unsubs: registry_unsub() self._registry_unsubs.clear() + + +class TargetStateChangeTracker(TargetEntityChangeTracker): + """Helper class to manage state change tracking for targets.""" + + def __init__( + self, + hass: HomeAssistant, + target_selection: TargetSelection, + action: Callable[[TargetStateChangedData], Any], + entity_filter: Callable[[set[str]], set[str]], + ) -> None: + """Initialize the state change tracker.""" + super().__init__(hass, target_selection, entity_filter) + self._action = action + self._state_change_unsub: CALLBACK_TYPE | None = None + + def _handle_entities_update(self, tracked_entities: set[str]) -> None: + """Handle the tracked entities.""" + + @callback + def state_change_listener(event: Event[EventStateChangedData]) -> None: + """Handle state change events.""" + if event.data["entity_id"] in tracked_entities: + self._action(TargetStateChangedData(event, tracked_entities)) + + _LOGGER.debug("Tracking state changes for entities: %s", tracked_entities) + if self._state_change_unsub: + self._state_change_unsub() + self._state_change_unsub = async_track_state_change_event( + self._hass, tracked_entities, state_change_listener + ) + + def _unsubscribe(self) -> None: + """Unsubscribe from all events.""" + super()._unsubscribe() if self._state_change_unsub: self._state_change_unsub() self._state_change_unsub = None diff --git a/tests/components/calendar/conftest.py b/tests/components/calendar/conftest.py index ed21f1336c8..2226d66a3bc 100644 --- a/tests/components/calendar/conftest.py +++ b/tests/components/calendar/conftest.py @@ -44,10 +44,16 @@ class MockCalendarEntity(CalendarEntity): _attr_has_entity_name = True - def __init__(self, name: str, events: list[CalendarEvent] | None = None) -> None: + def __init__( + self, + name: str, + events: list[CalendarEvent] | None = None, + unique_id: str | None = None, + ) -> None: """Initialize entity.""" self._attr_name = name.capitalize() self._events = events or [] + self._attr_unique_id = unique_id @property def event(self) -> CalendarEvent | None: @@ -182,6 +188,7 @@ def create_test_entities() -> list[MockCalendarEntity]: location="Future Location", ) ], + unique_id="calendar_1_id", ) entity1.async_get_events = AsyncMock(wraps=entity1.async_get_events) @@ -195,6 +202,7 @@ def create_test_entities() -> list[MockCalendarEntity]: summary="Current Event", ) ], + unique_id="calendar_2_id", ) entity2.async_get_events = AsyncMock(wraps=entity2.async_get_events) diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index b0d7944041d..8d4fcab37d6 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -11,6 +11,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Generator from contextlib import asynccontextmanager +from dataclasses import dataclass import datetime import logging from typing import Any @@ -21,19 +22,141 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import automation, calendar -from homeassistant.components.calendar.trigger import EVENT_END, EVENT_START -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF +from homeassistant.components.calendar.trigger import ( + CONF_OFFSET_TYPE, + EVENT_END, + EVENT_START, + OFFSET_TYPE_AFTER, + OFFSET_TYPE_BEFORE, +) +from homeassistant.const import ( + ATTR_AREA_ID, + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + ATTR_LABEL_ID, + CONF_OFFSET, + CONF_OPTIONS, + CONF_PLATFORM, + CONF_TARGET, + SERVICE_TURN_OFF, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + label_registry as lr, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .conftest import MockCalendarEntity -from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_mock_service, + mock_device_registry, +) _LOGGER = logging.getLogger(__name__) +@dataclass +class TriggerFormat: + """Abstraction for different trigger configuration formats.""" + + id: str + + def get_platform(self, event_type: str) -> str: + """Get the platform string for trigger payload assertions.""" + raise NotImplementedError + + def get_trigger_data( + self, entity_id: str, event_type: str, offset: datetime.timedelta | None = None + ) -> dict[str, Any]: + """Get the trigger configuration data.""" + raise NotImplementedError + + def get_expected_call_data( + self, entity_id: str, event_type: str, calendar_event: dict[str, Any] + ) -> dict[str, Any]: + """Get the expected call data for assertion.""" + return { + "platform": self.get_platform(event_type), + "event": event_type, + "entity_id": entity_id, + "calendar_event": calendar_event, + } + + +@dataclass +class LegacyTriggerFormat(TriggerFormat): + """Legacy trigger format using platform: calendar with entity_id and event.""" + + id: str = "legacy" + + def get_platform(self, event_type: str) -> str: + """Get the platform string for trigger payload assertions.""" + return calendar.DOMAIN + + def get_trigger_data( + self, entity_id: str, event_type: str, offset: datetime.timedelta | None = None + ) -> dict[str, Any]: + """Get the trigger configuration data.""" + trigger_data: dict[str, Any] = { + CONF_PLATFORM: calendar.DOMAIN, + "entity_id": entity_id, + "event": event_type, + } + if offset: + trigger_data[CONF_OFFSET] = offset + return trigger_data + + +@dataclass +class TargetTriggerFormat(TriggerFormat): + """Target trigger format using platform: calendar.event_started/ended with target.""" + + id: str = "target" + + def get_platform(self, event_type: str) -> str: + """Get the platform string for trigger payload assertions.""" + trigger_type = "event_started" if event_type == EVENT_START else "event_ended" + return f"{calendar.DOMAIN}.{trigger_type}" + + def get_trigger_data( + self, entity_id: str, event_type: str, offset: datetime.timedelta | None = None + ) -> dict[str, Any]: + """Get the trigger configuration data.""" + trigger_type = "event_started" if event_type == EVENT_START else "event_ended" + trigger_data: dict[str, Any] = { + CONF_PLATFORM: f"{calendar.DOMAIN}.{trigger_type}", + CONF_TARGET: {"entity_id": entity_id}, + } + if offset: + options: dict[str, Any] = {} + # Convert signed offset to offset + offset_type + if offset < datetime.timedelta(0): + options[CONF_OFFSET] = -offset + options[CONF_OFFSET_TYPE] = OFFSET_TYPE_BEFORE + else: + options[CONF_OFFSET] = offset + options[CONF_OFFSET_TYPE] = OFFSET_TYPE_AFTER + trigger_data[CONF_OPTIONS] = options + return trigger_data + + +TRIGGER_FORMATS = [LegacyTriggerFormat(), TargetTriggerFormat()] +TRIGGER_FORMAT_IDS = [fmt.id for fmt in TRIGGER_FORMATS] + + +@pytest.fixture(params=TRIGGER_FORMATS, ids=TRIGGER_FORMAT_IDS) +def trigger_format(request: pytest.FixtureRequest) -> TriggerFormat: + """Fixture providing both trigger formats for parameterized tests.""" + return request.param + + CALENDAR_ENTITY_ID = "calendar.calendar_2" TEST_AUTOMATION_ACTION = { @@ -41,6 +164,7 @@ TEST_AUTOMATION_ACTION = { "data": { "platform": "{{ trigger.platform }}", "event": "{{ trigger.event }}", + "entity_id": "{{ trigger.entity_id }}", "calendar_event": "{{ trigger.calendar_event }}", }, } @@ -51,6 +175,59 @@ TEST_AUTOMATION_ACTION = { TEST_TIME_ADVANCE_INTERVAL = datetime.timedelta(minutes=1) TEST_UPDATE_INTERVAL = datetime.timedelta(minutes=7) +TARGET_TEST_FIRST_START_CALL_DATA = [ + { + "platform": "calendar.event_started", + "event": "start", + "entity_id": "calendar.calendar_1", + "calendar_event": { + "start": "2022-04-19T11:00:00+00:00", + "end": "2022-04-19T11:30:00+00:00", + "summary": "Event on Calendar 1", + "all_day": False, + }, + } +] +TARGET_TEST_SECOND_START_CALL_DATA = [ + { + "platform": "calendar.event_started", + "event": "start", + "entity_id": "calendar.calendar_2", + "calendar_event": { + "start": "2022-04-19T11:15:00+00:00", + "end": "2022-04-19T11:45:00+00:00", + "summary": "Event on Calendar 2", + "all_day": False, + }, + } +] +TARGET_TEST_FIRST_END_CALL_DATA = [ + { + "platform": "calendar.event_ended", + "event": "end", + "entity_id": "calendar.calendar_1", + "calendar_event": { + "start": "2022-04-19T11:00:00+00:00", + "end": "2022-04-19T11:30:00+00:00", + "summary": "Event on Calendar 1", + "all_day": False, + }, + } +] +TARGET_TEST_SECOND_END_CALL_DATA = [ + { + "platform": "calendar.event_ended", + "event": "end", + "entity_id": "calendar.calendar_2", + "calendar_event": { + "start": "2022-04-19T11:15:00+00:00", + "end": "2022-04-19T11:45:00+00:00", + "summary": "Event on Calendar 2", + "all_day": False, + }, + } +] + class FakeSchedule: """Test fixture class for return events in a specific date range.""" @@ -110,18 +287,65 @@ async def mock_setup_platform( await hass.async_block_till_done() +@pytest.fixture +def target_calendars( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, + label_registry: lr.LabelRegistry, +): + """Associate calendar entities with different targets. + + Sets up the following target structure: + - area_both: An area containing both calendar entities + - label_calendar_1: A label assigned to calendar 1 only + - device_calendar_1: A device associated with calendar 1 + - device_calendar_2: A device associated with calendar 2 + - area_devices: An area containing both devices + """ + area_both = area_registry.async_get_or_create("area_both_calendars") + label_calendar_1 = label_registry.async_create("calendar_1_label") + label_on_devices = label_registry.async_create("label_on_devices") + + device_calendar_1 = dr.DeviceEntry( + id="device_calendar_1", labels=[label_on_devices.label_id] + ) + device_calendar_2 = dr.DeviceEntry( + id="device_calendar_2", labels=[label_on_devices.label_id] + ) + mock_device_registry( + hass, + { + device_calendar_1.id: device_calendar_1, + device_calendar_2.id: device_calendar_2, + }, + ) + + # Associate calendar entities with targets + entity_registry.async_update_entity( + "calendar.calendar_1", + area_id=area_both.id, + labels={label_calendar_1.label_id}, + device_id=device_calendar_1.id, + ) + entity_registry.async_update_entity( + "calendar.calendar_2", + area_id=area_both.id, + device_id=device_calendar_2.id, + ) + + @asynccontextmanager async def create_automation( - hass: HomeAssistant, event_type: str, offset=None + hass: HomeAssistant, + trigger_format: TriggerFormat, + event_type: str, + offset: datetime.timedelta | None = None, ) -> AsyncIterator[None]: - """Register an automation.""" - trigger_data = { - "platform": calendar.DOMAIN, - "entity_id": CALENDAR_ENTITY_ID, - "event": event_type, - } - if offset: - trigger_data["offset"] = offset + """Register an automation using the specified trigger format.""" + trigger_data = trigger_format.get_trigger_data( + CALENDAR_ENTITY_ID, event_type, offset + ) assert await async_setup_component( hass, automation.DOMAIN, @@ -173,13 +397,14 @@ async def test_event_start_trigger( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, + trigger_format: TriggerFormat, ) -> None: """Test the a calendar trigger based on start time.""" event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - async with create_automation(hass, EVENT_START): + async with create_automation(hass, trigger_format, EVENT_START): assert len(calls_data()) == 0 await fake_schedule.fire_until( @@ -187,19 +412,17 @@ async def test_event_start_trigger( ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data, - } + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data + ) ] @pytest.mark.parametrize( - ("offset_str", "offset_delta"), + ("offset_delta"), [ - ("-01:00", datetime.timedelta(hours=-1)), - ("+01:00", datetime.timedelta(hours=1)), + datetime.timedelta(hours=-1), + datetime.timedelta(hours=1), ], ) async def test_event_start_trigger_with_offset( @@ -207,15 +430,17 @@ async def test_event_start_trigger_with_offset( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, - offset_str, - offset_delta, + trigger_format: TriggerFormat, + offset_delta: datetime.timedelta, ) -> None: """Test the a calendar trigger based on start time with an offset.""" event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:30:00+00:00"), ) - async with create_automation(hass, EVENT_START, offset=offset_str): + async with create_automation( + hass, trigger_format, EVENT_START, offset=offset_delta + ): # No calls yet await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:55:00+00:00") + offset_delta, @@ -227,11 +452,9 @@ async def test_event_start_trigger_with_offset( datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta, ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data, - } + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data + ) ] @@ -240,13 +463,14 @@ async def test_event_end_trigger( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, + trigger_format: TriggerFormat, ) -> None: """Test the a calendar trigger based on end time.""" event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), ) - async with create_automation(hass, EVENT_END): + async with create_automation(hass, trigger_format, EVENT_END): # Event started, nothing should fire yet await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:10:00+00:00") @@ -258,19 +482,17 @@ async def test_event_end_trigger( datetime.datetime.fromisoformat("2022-04-19 12:10:00+00:00") ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_END, - "calendar_event": event_data, - } + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_END, event_data + ) ] @pytest.mark.parametrize( - ("offset_str", "offset_delta"), + ("offset_delta"), [ - ("-01:00", datetime.timedelta(hours=-1)), - ("+01:00", datetime.timedelta(hours=1)), + datetime.timedelta(hours=-1), + datetime.timedelta(hours=1), ], ) async def test_event_end_trigger_with_offset( @@ -278,15 +500,15 @@ async def test_event_end_trigger_with_offset( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, - offset_str, - offset_delta, + trigger_format: TriggerFormat, + offset_delta: datetime.timedelta, ) -> None: """Test the a calendar trigger based on end time with an offset.""" event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:30:00+00:00"), ) - async with create_automation(hass, EVENT_END, offset=offset_str): + async with create_automation(hass, trigger_format, EVENT_END, offset=offset_delta): # No calls yet await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta, @@ -298,11 +520,9 @@ async def test_event_end_trigger_with_offset( datetime.datetime.fromisoformat("2022-04-19 12:35:00+00:00") + offset_delta, ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_END, - "calendar_event": event_data, - } + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_END, event_data + ) ] @@ -310,10 +530,14 @@ async def test_calendar_trigger_with_no_events( hass: HomeAssistant, calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + trigger_format: TriggerFormat, ) -> None: """Test a calendar trigger setup with no events.""" - async with create_automation(hass, EVENT_START), create_automation(hass, EVENT_END): + async with ( + create_automation(hass, trigger_format, EVENT_START), + create_automation(hass, trigger_format, EVENT_END), + ): # No calls, at arbitrary times await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00") @@ -326,6 +550,7 @@ async def test_multiple_start_events( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, + trigger_format: TriggerFormat, ) -> None: """Test that a trigger fires for multiple events.""" @@ -337,21 +562,17 @@ async def test_multiple_start_events( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) - async with create_automation(hass, EVENT_START): + async with create_automation(hass, trigger_format, EVENT_START): await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data1, - }, - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data2, - }, + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data1 + ), + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data2 + ), ] @@ -360,6 +581,7 @@ async def test_multiple_end_events( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, + trigger_format: TriggerFormat, ) -> None: """Test that a trigger fires for multiple events.""" @@ -371,22 +593,18 @@ async def test_multiple_end_events( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) - async with create_automation(hass, EVENT_END): + async with create_automation(hass, trigger_format, EVENT_END): await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_END, - "calendar_event": event_data1, - }, - { - "platform": "calendar", - "event": EVENT_END, - "calendar_event": event_data2, - }, + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_END, event_data1 + ), + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_END, event_data2 + ), ] @@ -395,6 +613,7 @@ async def test_multiple_events_sharing_start_time( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, + trigger_format: TriggerFormat, ) -> None: """Test that a trigger fires for every event sharing a start time.""" @@ -406,22 +625,18 @@ async def test_multiple_events_sharing_start_time( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - async with create_automation(hass, EVENT_START): + async with create_automation(hass, trigger_format, EVENT_START): await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:35:00+00:00") ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data1, - }, - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data2, - }, + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data1 + ), + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data2 + ), ] @@ -430,6 +645,7 @@ async def test_overlap_events( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, + trigger_format: TriggerFormat, ) -> None: """Test that a trigger fires for events that overlap.""" @@ -441,22 +657,18 @@ async def test_overlap_events( start=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:45:00+00:00"), ) - async with create_automation(hass, EVENT_START): + async with create_automation(hass, trigger_format, EVENT_START): await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:20:00+00:00") ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data1, - }, - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data2, - }, + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data1 + ), + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data2 + ), ] @@ -507,6 +719,7 @@ async def test_update_next_event( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, + trigger_format: TriggerFormat, ) -> None: """Test detection of a new event after initial trigger is setup.""" @@ -514,7 +727,7 @@ async def test_update_next_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) - async with create_automation(hass, EVENT_START): + async with create_automation(hass, trigger_format, EVENT_START): # No calls before event start await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 10:45:00+00:00") @@ -532,16 +745,12 @@ async def test_update_next_event( datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data2, - }, - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data1, - }, + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data2 + ), + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data1 + ), ] @@ -550,6 +759,7 @@ async def test_update_missed( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, + trigger_format: TriggerFormat, ) -> None: """Test that new events are missed if they arrive outside the update interval.""" @@ -557,7 +767,7 @@ async def test_update_missed( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - async with create_automation(hass, EVENT_START): + async with create_automation(hass, trigger_format, EVENT_START): # Events are refreshed at t+TEST_UPDATE_INTERVAL minutes. A new event is # added, but the next update happens after the event is already over. await fake_schedule.fire_until( @@ -575,11 +785,9 @@ async def test_update_missed( datetime.datetime.fromisoformat("2022-04-19 11:05:00+00:00") ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data1, - }, + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data1 + ), ] @@ -641,22 +849,21 @@ async def test_event_payload( fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, set_time_zone: None, + trigger_format: TriggerFormat, create_data, fire_time, payload_data, ) -> None: """Test the fields in the calendar event payload are set.""" test_entity.create_event(**create_data) - async with create_automation(hass, EVENT_START): + async with create_automation(hass, trigger_format, EVENT_START): assert len(calls_data()) == 0 await fake_schedule.fire_until(fire_time) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": payload_data, - } + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, payload_data + ) ] @@ -666,6 +873,7 @@ async def test_trigger_timestamp_window_edge( fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, freezer: FrozenDateTimeFactory, + trigger_format: TriggerFormat, ) -> None: """Test that events in the edge of a scan are included.""" freezer.move_to("2022-04-19 11:00:00+00:00") @@ -675,18 +883,16 @@ async def test_trigger_timestamp_window_edge( start=datetime.datetime.fromisoformat("2022-04-19 11:14:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - async with create_automation(hass, EVENT_START): + async with create_automation(hass, trigger_format, EVENT_START): assert len(calls_data()) == 0 await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:20:00+00:00") ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data, - } + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data + ) ] @@ -696,6 +902,7 @@ async def test_event_start_trigger_dst( fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, freezer: FrozenDateTimeFactory, + trigger_format: TriggerFormat, ) -> None: """Test a calendar event trigger happening at the start of daylight savings time.""" await hass.config.async_set_time_zone("America/Los_Angeles") @@ -720,7 +927,7 @@ async def test_event_start_trigger_dst( start=datetime.datetime(2023, 3, 12, 3, 30, tzinfo=tzinfo), end=datetime.datetime(2023, 3, 12, 3, 45, tzinfo=tzinfo), ) - async with create_automation(hass, EVENT_START): + async with create_automation(hass, trigger_format, EVENT_START): assert len(calls_data()) == 0 await fake_schedule.fire_until( @@ -728,21 +935,15 @@ async def test_event_start_trigger_dst( ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event1_data, - }, - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event2_data, - }, - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event3_data, - }, + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event1_data + ), + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event2_data + ), + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event3_data + ), ] @@ -751,8 +952,8 @@ async def test_config_entry_reload( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entities: list[MockCalendarEntity], - setup_platform: None, config_entry: MockConfigEntry, + trigger_format: TriggerFormat, ) -> None: """Test the a calendar trigger after a config entry reload. @@ -761,7 +962,7 @@ async def test_config_entry_reload( the automation kept a reference to the specific entity which would be invalid after a config entry was reloaded. """ - async with create_automation(hass, EVENT_START): + async with create_automation(hass, trigger_format, EVENT_START): assert len(calls_data()) == 0 assert await hass.config_entries.async_reload(config_entry.entry_id) @@ -778,11 +979,9 @@ async def test_config_entry_reload( ) assert calls_data() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data, - } + trigger_format.get_expected_call_data( + CALENDAR_ENTITY_ID, EVENT_START, event_data + ) ] @@ -791,12 +990,12 @@ async def test_config_entry_unload( calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entities: list[MockCalendarEntity], - setup_platform: None, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, + trigger_format: TriggerFormat, ) -> None: """Test an automation that references a calendar entity that is unloaded.""" - async with create_automation(hass, EVENT_START): + async with create_automation(hass, trigger_format, EVENT_START): assert len(calls_data()) == 0 assert await hass.config_entries.async_unload(config_entry.entry_id) @@ -806,3 +1005,172 @@ async def test_config_entry_unload( ) assert "Entity does not exist calendar.calendar_2" in caplog.text + + +@pytest.mark.usefixtures("target_calendars") +@pytest.mark.parametrize( + ( + "trigger_target_conf", + "first_start_call_data", + "first_end_call_data", + "second_start_call_data", + "second_end_call_data", + ), + [ + ({}, [], [], [], []), + ( + {ATTR_ENTITY_ID: "calendar.calendar_2"}, + [], + [], + TARGET_TEST_SECOND_START_CALL_DATA, + TARGET_TEST_SECOND_END_CALL_DATA, + ), + ( + {ATTR_ENTITY_ID: ["calendar.calendar_1", "calendar.calendar_2"]}, + TARGET_TEST_FIRST_START_CALL_DATA, + TARGET_TEST_FIRST_END_CALL_DATA, + TARGET_TEST_SECOND_START_CALL_DATA, + TARGET_TEST_SECOND_END_CALL_DATA, + ), + ( + {ATTR_AREA_ID: "area_both_calendars"}, + TARGET_TEST_FIRST_START_CALL_DATA, + TARGET_TEST_FIRST_END_CALL_DATA, + TARGET_TEST_SECOND_START_CALL_DATA, + TARGET_TEST_SECOND_END_CALL_DATA, + ), + ( + {ATTR_LABEL_ID: "calendar_1_label"}, + TARGET_TEST_FIRST_START_CALL_DATA, + TARGET_TEST_FIRST_END_CALL_DATA, + [], + [], + ), + ( + {ATTR_DEVICE_ID: "device_calendar_1"}, + TARGET_TEST_FIRST_START_CALL_DATA, + TARGET_TEST_FIRST_END_CALL_DATA, + [], + [], + ), + ( + {ATTR_DEVICE_ID: "device_calendar_2"}, + [], + [], + TARGET_TEST_SECOND_START_CALL_DATA, + TARGET_TEST_SECOND_END_CALL_DATA, + ), + ( + {ATTR_LABEL_ID: "label_on_devices"}, + TARGET_TEST_FIRST_START_CALL_DATA, + TARGET_TEST_FIRST_END_CALL_DATA, + TARGET_TEST_SECOND_START_CALL_DATA, + TARGET_TEST_SECOND_END_CALL_DATA, + ), + ], +) +async def test_trigger_with_targets( + hass: HomeAssistant, + calls_data: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, + test_entities: list[MockCalendarEntity], + trigger_target_conf: dict[str, Any], + first_start_call_data: list[dict[str, Any]], + first_end_call_data: list[dict[str, Any]], + second_start_call_data: list[dict[str, Any]], + second_end_call_data: list[dict[str, Any]], +) -> None: + """Test that triggers fire for multiple calendar entities with target selector.""" + calendar_1 = test_entities[0] + calendar_2 = test_entities[1] + + calendar_1.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + summary="Event on Calendar 1", + ) + calendar_2.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:45:00+00:00"), + summary="Event on Calendar 2", + ) + + trigger_start = { + CONF_PLATFORM: "calendar.event_started", + CONF_TARGET: {**trigger_target_conf}, + } + trigger_end = { + CONF_PLATFORM: "calendar.event_ended", + CONF_TARGET: {**trigger_target_conf}, + } + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "alias": "start_trigger", + "trigger": trigger_start, + "action": TEST_AUTOMATION_ACTION, + "mode": "queued", + }, + { + "alias": "end_trigger", + "trigger": trigger_end, + "action": TEST_AUTOMATION_ACTION, + "mode": "queued", + }, + ] + }, + ) + await hass.async_block_till_done() + + assert len(calls_data()) == 0 + + # Advance past first event start + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:10:00+00:00") + ) + assert calls_data() == first_start_call_data + + # Advance past second event start + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:20:00+00:00") + ) + assert calls_data() == first_start_call_data + second_start_call_data + + # Advance past first event end + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:40:00+00:00") + ) + assert ( + calls_data() + == first_start_call_data + second_start_call_data + first_end_call_data + ) + + # Advance past second event end + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:50:00+00:00") + ) + assert ( + calls_data() + == first_start_call_data + + second_start_call_data + + first_end_call_data + + second_end_call_data + ) + + # Disable automations to cleanup lingering timers + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "automation.start_trigger"}, + blocking=True, + ) + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "automation.end_trigger"}, + blocking=True, + )