mirror of
https://github.com/home-assistant/core.git
synced 2025-12-25 09:18:27 +00:00
Compare commits
1 Commits
dev
...
calendar_t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d82b610c |
@@ -15,5 +15,13 @@
|
||||
"get_events": {
|
||||
"service": "mdi:calendar-month"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"event_ended": {
|
||||
"trigger": "mdi:calendar-end"
|
||||
},
|
||||
"event_started": {
|
||||
"trigger": "mdi:calendar-start"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
27
homeassistant/components/calendar/triggers.yaml
Normal file
27
homeassistant/components/calendar/triggers.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user