Compare commits

...

1 Commits

Author SHA1 Message Date
abmantis
32d82b610c Add calendar event_started/event_ended triggers 2025-12-23 21:43:29 +00:00
7 changed files with 755 additions and 112 deletions

View File

@@ -15,5 +15,13 @@
"get_events": {
"service": "mdi:calendar-month"
}
},
"triggers": {
"event_ended": {
"trigger": "mdi:calendar-end"
},
"event_started": {
"trigger": "mdi:calendar-start"
}
}
}

View File

@@ -1,4 +1,10 @@
{
"common": {
"trigger_event_offset_description": "Offset from the event time.",
"trigger_event_offset_name": "Offset",
"trigger_event_offset_type_description": "Whether to trigger before or after the event time, if an offset is defined.",
"trigger_event_offset_type_name": "Offset type"
},
"entity_component": {
"_": {
"name": "[%key:component::calendar::title%]",
@@ -45,6 +51,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 +117,35 @@
"name": "Get events"
}
},
"title": "Calendar"
"title": "Calendar",
"triggers": {
"event_ended": {
"description": "Triggers when a calendar event ends.",
"fields": {
"offset": {
"description": "[%key:component::calendar::common::trigger_event_offset_description%]",
"name": "[%key:component::calendar::common::trigger_event_offset_name%]"
},
"offset_type": {
"description": "[%key:component::calendar::common::trigger_event_offset_type_description%]",
"name": "[%key:component::calendar::common::trigger_event_offset_type_name%]"
}
},
"name": "Calendar event ended"
},
"event_started": {
"description": "Triggers when a calendar event starts.",
"fields": {
"offset": {
"description": "[%key:component::calendar::common::trigger_event_offset_description%]",
"name": "[%key:component::calendar::common::trigger_event_offset_name%]"
},
"offset_type": {
"description": "[%key:component::calendar::common::trigger_event_offset_type_description%]",
"name": "[%key:component::calendar::common::trigger_event_offset_type_name%]"
}
},
"name": "Calendar event started"
}
}
}

View File

@@ -10,8 +10,14 @@ 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 (
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 +26,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 +40,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_OPTIONS_SCHEMA_DICT = {
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_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS): _OPTIONS_SCHEMA_DICT,
vol.Required(CONF_OPTIONS): _SINGLE_ENTITY_OPTIONS_SCHEMA_DICT,
},
)
_EVENT_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
vol.Optional(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
@@ -110,15 +133,19 @@ 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]:
"""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[CalendarEvent] = []
for entity_id in entity_ids:
entity = get_entity(hass, entity_id)
events.extend(await entity.async_get_events(hass, timespan.start, end_time))
return events
return async_get_events
@@ -260,8 +287,68 @@ 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._calendar_event_listener: CalendarEventListener | None = None
def _handle_entities(self, tracked_entities: set[str]) -> None:
"""Handle the tracked entities."""
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._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 +358,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_OPTIONS_SCHEMA_DICT
)
return await super().async_validate_complete_config(hass, complete_config)
@@ -280,7 +367,7 @@ class EventTrigger(Trigger):
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _CONFIG_SCHEMA(config))
return cast(ConfigType, _SINGLE_ENTITY_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
@@ -311,15 +398,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.get(CONF_OFFSET_TYPE, OFFSET_TYPE_BEFORE)
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,
}

View File

@@ -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

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import abc
from collections.abc import Callable
import dataclasses
import logging
@@ -268,65 +269,46 @@ 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_changes()
return self._unsubscribe
def _track_entities_state_change(self) -> None:
"""Set up state change tracking for currently selected entities."""
@abc.abstractmethod
def _handle_entities(self, tracked_entities: set[str]) -> None:
"""Handle the tracked entities."""
@callback
def _handle_changes(self, event: Event[Any] | None = None) -> None:
"""Handle changes in the target."""
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(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 +318,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_changes
),
self._hass.bus.async_listen(
dr.EVENT_DEVICE_REGISTRY_UPDATED, resubscribe_state_change_event
dr.EVENT_DEVICE_REGISTRY_UPDATED, self._handle_changes
),
self._hass.bus.async_listen(
ar.EVENT_AREA_REGISTRY_UPDATED, resubscribe_state_change_event
ar.EVENT_AREA_REGISTRY_UPDATED, self._handle_changes
),
]
@@ -351,6 +333,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(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

View File

@@ -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)

View File

@@ -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,130 @@ 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
@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 = {
@@ -51,6 +163,55 @@ 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",
"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",
"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",
"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",
"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 +271,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 +381,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(
@@ -188,7 +397,7 @@ async def test_event_start_trigger(
assert calls_data() == [
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data,
}
@@ -196,10 +405,10 @@ async def test_event_start_trigger(
@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 +416,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,
@@ -228,7 +439,7 @@ async def test_event_start_trigger_with_offset(
)
assert calls_data() == [
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data,
}
@@ -240,13 +451,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")
@@ -259,7 +471,7 @@ async def test_event_end_trigger(
)
assert calls_data() == [
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_END),
"event": EVENT_END,
"calendar_event": event_data,
}
@@ -267,10 +479,10 @@ async def test_event_end_trigger(
@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 +490,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,
@@ -299,7 +511,7 @@ async def test_event_end_trigger_with_offset(
)
assert calls_data() == [
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_END),
"event": EVENT_END,
"calendar_event": event_data,
}
@@ -310,10 +522,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 +542,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,18 +554,18 @@ 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",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data1,
},
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data2,
},
@@ -360,6 +577,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,19 +589,19 @@ 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",
"platform": trigger_format.get_platform(EVENT_END),
"event": EVENT_END,
"calendar_event": event_data1,
},
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_END),
"event": EVENT_END,
"calendar_event": 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,19 +625,19 @@ 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",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data1,
},
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data2,
},
@@ -430,6 +649,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,19 +661,19 @@ 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",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data1,
},
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data2,
},
@@ -507,6 +727,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 +735,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")
@@ -533,12 +754,12 @@ async def test_update_next_event(
)
assert calls_data() == [
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data2,
},
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data1,
},
@@ -550,6 +771,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 +779,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(
@@ -576,7 +798,7 @@ async def test_update_missed(
)
assert calls_data() == [
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data1,
},
@@ -641,19 +863,20 @@ 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",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": payload_data,
}
@@ -666,6 +889,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,7 +899,7 @@ 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(
@@ -683,7 +907,7 @@ async def test_trigger_timestamp_window_edge(
)
assert calls_data() == [
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data,
}
@@ -696,6 +920,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 +945,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(
@@ -729,17 +954,17 @@ async def test_event_start_trigger_dst(
assert calls_data() == [
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event1_data,
},
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event2_data,
},
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event3_data,
},
@@ -751,8 +976,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 +986,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)
@@ -779,7 +1004,7 @@ async def test_config_entry_reload(
assert calls_data() == [
{
"platform": "calendar",
"platform": trigger_format.get_platform(EVENT_START),
"event": EVENT_START,
"calendar_event": event_data,
}
@@ -791,12 +1016,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 +1031,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,
)