mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +00:00
Add calendar trigger offsets (#70963)
* Add support for calendar trigger offsets * Add offset end test * Update homeassistant/components/calendar/trigger.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Always include offset in trigger data Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
7b2947bad7
commit
285fdeb581
@ -11,7 +11,7 @@ from homeassistant.components.automation import (
|
|||||||
AutomationActionType,
|
AutomationActionType,
|
||||||
AutomationTriggerInfo,
|
AutomationTriggerInfo,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_PLATFORM
|
from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_PLATFORM
|
||||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
@ -36,6 +36,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
|
|||||||
vol.Required(CONF_PLATFORM): DOMAIN,
|
vol.Required(CONF_PLATFORM): DOMAIN,
|
||||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||||
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
|
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
|
||||||
|
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -50,12 +51,14 @@ class CalendarEventListener:
|
|||||||
trigger_data: dict[str, Any],
|
trigger_data: dict[str, Any],
|
||||||
entity: CalendarEntity,
|
entity: CalendarEntity,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
|
offset: datetime.timedelta,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize CalendarEventListener."""
|
"""Initialize CalendarEventListener."""
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._job = job
|
self._job = job
|
||||||
self._trigger_data = trigger_data
|
self._trigger_data = trigger_data
|
||||||
self._entity = entity
|
self._entity = entity
|
||||||
|
self._offset = offset
|
||||||
self._unsub_event: CALLBACK_TYPE | None = None
|
self._unsub_event: CALLBACK_TYPE | None = None
|
||||||
self._unsub_refresh: CALLBACK_TYPE | None = None
|
self._unsub_refresh: CALLBACK_TYPE | None = None
|
||||||
# Upcoming set of events with their trigger time
|
# Upcoming set of events with their trigger time
|
||||||
@ -81,10 +84,18 @@ class CalendarEventListener:
|
|||||||
|
|
||||||
async def _fetch_events(self, last_endtime: datetime.datetime) -> None:
|
async def _fetch_events(self, last_endtime: datetime.datetime) -> None:
|
||||||
"""Update the set of eligible events."""
|
"""Update the set of eligible events."""
|
||||||
|
# Use a sliding window for selecting in scope events in the next interval. The event
|
||||||
|
# search range is offset, then the fire time of the returned events are offset again below.
|
||||||
# Event time ranges are exclusive so the end time is expanded by 1sec
|
# Event time ranges are exclusive so the end time is expanded by 1sec
|
||||||
end_time = last_endtime + UPDATE_INTERVAL + datetime.timedelta(seconds=1)
|
start_time = last_endtime - self._offset
|
||||||
_LOGGER.debug("Fetching events between %s, %s", last_endtime, end_time)
|
end_time = start_time + UPDATE_INTERVAL + datetime.timedelta(seconds=1)
|
||||||
events = await self._entity.async_get_events(self._hass, last_endtime, end_time)
|
_LOGGER.debug(
|
||||||
|
"Fetching events between %s, %s (offset=%s)",
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
self._offset,
|
||||||
|
)
|
||||||
|
events = await self._entity.async_get_events(self._hass, start_time, end_time)
|
||||||
|
|
||||||
# Build list of events and the appropriate time to trigger an alarm. The
|
# Build list of events and the appropriate time to trigger an alarm. The
|
||||||
# returned events may have already started but matched the start/end time
|
# returned events may have already started but matched the start/end time
|
||||||
@ -92,13 +103,14 @@ class CalendarEventListener:
|
|||||||
# trigger time.
|
# trigger time.
|
||||||
event_list = []
|
event_list = []
|
||||||
for event in events:
|
for event in events:
|
||||||
event_time = (
|
event_fire_time = (
|
||||||
event.start_datetime_local
|
event.start_datetime_local
|
||||||
if self._event_type == EVENT_START
|
if self._event_type == EVENT_START
|
||||||
else event.end_datetime_local
|
else event.end_datetime_local
|
||||||
)
|
)
|
||||||
if event_time > last_endtime:
|
event_fire_time += self._offset
|
||||||
event_list.append((event_time, event))
|
if event_fire_time > last_endtime:
|
||||||
|
event_list.append((event_fire_time, event))
|
||||||
event_list.sort(key=lambda x: x[0])
|
event_list.sort(key=lambda x: x[0])
|
||||||
self._events = event_list
|
self._events = event_list
|
||||||
_LOGGER.debug("Populated event list %s", self._events)
|
_LOGGER.debug("Populated event list %s", self._events)
|
||||||
@ -109,12 +121,12 @@ class CalendarEventListener:
|
|||||||
if not self._events:
|
if not self._events:
|
||||||
return
|
return
|
||||||
|
|
||||||
(event_datetime, _event) = self._events[0]
|
(event_fire_time, _event) = self._events[0]
|
||||||
_LOGGER.debug("Scheduling next event trigger @ %s", event_datetime)
|
_LOGGER.debug("Scheduled alarm for %s", event_fire_time)
|
||||||
self._unsub_event = async_track_point_in_utc_time(
|
self._unsub_event = async_track_point_in_utc_time(
|
||||||
self._hass,
|
self._hass,
|
||||||
self._handle_calendar_event,
|
self._handle_calendar_event,
|
||||||
event_datetime,
|
event_fire_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _clear_event_listener(self) -> None:
|
def _clear_event_listener(self) -> None:
|
||||||
@ -160,6 +172,7 @@ async def async_attach_trigger(
|
|||||||
"""Attach trigger for the specified calendar."""
|
"""Attach trigger for the specified calendar."""
|
||||||
entity_id = config[CONF_ENTITY_ID]
|
entity_id = config[CONF_ENTITY_ID]
|
||||||
event_type = config[CONF_EVENT]
|
event_type = config[CONF_EVENT]
|
||||||
|
offset = config[CONF_OFFSET]
|
||||||
|
|
||||||
component: EntityComponent = hass.data[DOMAIN]
|
component: EntityComponent = hass.data[DOMAIN]
|
||||||
if not (entity := component.get_entity(entity_id)) or not isinstance(
|
if not (entity := component.get_entity(entity_id)) or not isinstance(
|
||||||
@ -173,10 +186,10 @@ async def async_attach_trigger(
|
|||||||
**automation_info["trigger_data"],
|
**automation_info["trigger_data"],
|
||||||
"platform": DOMAIN,
|
"platform": DOMAIN,
|
||||||
"event": event_type,
|
"event": event_type,
|
||||||
|
"offset": offset,
|
||||||
}
|
}
|
||||||
|
|
||||||
listener = CalendarEventListener(
|
listener = CalendarEventListener(
|
||||||
hass, HassJob(action), trigger_data, entity, event_type
|
hass, HassJob(action), trigger_data, entity, event_type, offset
|
||||||
)
|
)
|
||||||
await listener.async_attach()
|
await listener.async_attach()
|
||||||
return listener.async_detach
|
return listener.async_detach
|
||||||
|
@ -126,13 +126,15 @@ async def setup_calendar(hass: HomeAssistant, fake_schedule: FakeSchedule) -> No
|
|||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
|
||||||
async def create_automation(hass: HomeAssistant, event_type: str) -> None:
|
async def create_automation(hass: HomeAssistant, event_type: str, offset=None) -> None:
|
||||||
"""Register an automation."""
|
"""Register an automation."""
|
||||||
trigger_data = {
|
trigger_data = {
|
||||||
"platform": calendar.DOMAIN,
|
"platform": calendar.DOMAIN,
|
||||||
"entity_id": CALENDAR_ENTITY_ID,
|
"entity_id": CALENDAR_ENTITY_ID,
|
||||||
"event": event_type,
|
"event": event_type,
|
||||||
}
|
}
|
||||||
|
if offset:
|
||||||
|
trigger_data["offset"] = offset
|
||||||
assert await async_setup_component(
|
assert await async_setup_component(
|
||||||
hass,
|
hass,
|
||||||
automation.DOMAIN,
|
automation.DOMAIN,
|
||||||
@ -178,7 +180,43 @@ async def test_event_start_trigger(hass, calls, fake_schedule):
|
|||||||
assert len(calls()) == 0
|
assert len(calls()) == 0
|
||||||
|
|
||||||
await fake_schedule.fire_until(
|
await fake_schedule.fire_until(
|
||||||
datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00")
|
datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"),
|
||||||
|
)
|
||||||
|
assert calls() == [
|
||||||
|
{
|
||||||
|
"platform": "calendar",
|
||||||
|
"event": EVENT_START,
|
||||||
|
"calendar_event": event_data,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"offset_str, offset_delta",
|
||||||
|
[
|
||||||
|
("-01:00", datetime.timedelta(hours=-1)),
|
||||||
|
("+01:00", datetime.timedelta(hours=1)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_event_start_trigger_with_offset(
|
||||||
|
hass, calls, fake_schedule, offset_str, offset_delta
|
||||||
|
):
|
||||||
|
"""Test the a calendar trigger based on start time with an offset."""
|
||||||
|
event_data = fake_schedule.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"),
|
||||||
|
)
|
||||||
|
await create_automation(hass, EVENT_START, offset=offset_str)
|
||||||
|
|
||||||
|
# No calls yet
|
||||||
|
await fake_schedule.fire_until(
|
||||||
|
datetime.datetime.fromisoformat("2022-04-19 11:55:00+00:00") + offset_delta,
|
||||||
|
)
|
||||||
|
assert len(calls()) == 0
|
||||||
|
|
||||||
|
# Event has started w/ offset
|
||||||
|
await fake_schedule.fire_until(
|
||||||
|
datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta,
|
||||||
)
|
)
|
||||||
assert calls() == [
|
assert calls() == [
|
||||||
{
|
{
|
||||||
@ -216,6 +254,42 @@ async def test_event_end_trigger(hass, calls, fake_schedule):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"offset_str, offset_delta",
|
||||||
|
[
|
||||||
|
("-01:00", datetime.timedelta(hours=-1)),
|
||||||
|
("+01:00", datetime.timedelta(hours=1)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_event_end_trigger_with_offset(
|
||||||
|
hass, calls, fake_schedule, offset_str, offset_delta
|
||||||
|
):
|
||||||
|
"""Test the a calendar trigger based on end time with an offset."""
|
||||||
|
event_data = fake_schedule.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"),
|
||||||
|
)
|
||||||
|
await create_automation(hass, EVENT_END, offset=offset_str)
|
||||||
|
|
||||||
|
# No calls yet
|
||||||
|
await fake_schedule.fire_until(
|
||||||
|
datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta,
|
||||||
|
)
|
||||||
|
assert len(calls()) == 0
|
||||||
|
|
||||||
|
# Event has started w/ offset
|
||||||
|
await fake_schedule.fire_until(
|
||||||
|
datetime.datetime.fromisoformat("2022-04-19 12:35:00+00:00") + offset_delta,
|
||||||
|
)
|
||||||
|
assert calls() == [
|
||||||
|
{
|
||||||
|
"platform": "calendar",
|
||||||
|
"event": EVENT_END,
|
||||||
|
"calendar_event": event_data,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_calendar_trigger_with_no_events(hass, calls, fake_schedule):
|
async def test_calendar_trigger_with_no_events(hass, calls, fake_schedule):
|
||||||
"""Test a calendar trigger setup with no events."""
|
"""Test a calendar trigger setup with no events."""
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user