diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 86a49e6b0c6..10e1bb44295 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -1,9 +1,10 @@ """Calendar platform for a Remote Calendar.""" -from datetime import datetime +from datetime import datetime, timedelta import logging from ical.event import Event +from ical.timeline import Timeline, materialize_timeline from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -20,6 +21,14 @@ _LOGGER = logging.getLogger(__name__) # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 +# Every coordinator update refresh, we materialize a timeline of upcoming +# events for determining state. This is done in the background to avoid blocking +# the event loop. When a state update happens we can scan for active events on +# the materialized timeline. These parameters control the maximum lookahead +# window and number of events we materialize from the calendar. +MAX_LOOKAHEAD_EVENTS = 20 +MAX_LOOKAHEAD_TIME = timedelta(days=365) + async def async_setup_entry( hass: HomeAssistant, @@ -48,12 +57,18 @@ class RemoteCalendarEntity( super().__init__(coordinator) self._attr_name = entry.data[CONF_CALENDAR_NAME] self._attr_unique_id = entry.entry_id - self._event: CalendarEvent | None = None + self._timeline: Timeline | None = None @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - return self._event + if self._timeline is None: + return None + now = dt_util.now() + events = self._timeline.active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime @@ -79,14 +94,18 @@ class RemoteCalendarEntity( """ await super().async_update() - def next_event() -> CalendarEvent | None: + def _get_timeline() -> Timeline | None: + """Return a materialized timeline with upcoming events.""" now = dt_util.now() - events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) - if event := next(events, None): - return _get_calendar_event(event) - return None + timeline = self.coordinator.data.timeline_tz(now.tzinfo) + return materialize_timeline( + timeline, + start=now, + stop=now + MAX_LOOKAHEAD_TIME, + max_number_of_events=MAX_LOOKAHEAD_EVENTS, + ) - self._event = await self.hass.async_add_executor_job(next_event) + self._timeline = await self.hass.async_add_executor_job(_get_timeline) def _get_calendar_event(event: Event) -> CalendarEvent: diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py index a0c18383369..ea52d961414 100644 --- a/tests/components/remote_calendar/test_calendar.py +++ b/tests/components/remote_calendar/test_calendar.py @@ -4,6 +4,7 @@ from datetime import datetime import pathlib import textwrap +from freezegun.api import FrozenDateTimeFactory from httpx import Response import pytest import respx @@ -21,7 +22,7 @@ from .conftest import ( event_fields, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed # Test data files with known calendars from various sources. You can add a new file # in the testdata directory and add it will be parsed and tested. @@ -422,3 +423,110 @@ async def test_calendar_examples( await setup_integration(hass, config_entry) events = await get_events("1997-07-14T00:00:00", "2025-07-01T00:00:00") assert events == snapshot + + +@respx.mock +@pytest.mark.freeze_time("2023-01-01 09:59:00+00:00") +async def test_event_lifecycle( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the lifecycle of an event from upcoming to active to finished.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Test Event + DTSTART:20230101T100000Z + DTEND:20230101T110000Z + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + + await setup_integration(hass, config_entry) + + # An upcoming event is off + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == STATE_OFF + assert state.attributes.get("message") == "Test Event" + + # Advance time to the start of the event + freezer.move_to(datetime.fromisoformat("2023-01-01T10:00:00+00:00")) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # The event is active + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == STATE_ON + assert state.attributes.get("message") == "Test Event" + + # Advance time to the end of the event + freezer.move_to(datetime.fromisoformat("2023-01-01T11:00:00+00:00")) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # The event is finished + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == STATE_OFF + + +@respx.mock +@pytest.mark.freeze_time("2023-01-01 09:59:00+00:00") +async def test_event_edge_during_refresh_interval( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the lifecycle of multiple sequential events.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Event One + DTSTART:20230101T100000Z + DTEND:20230101T110000Z + END:VEVENT + BEGIN:VEVENT + SUMMARY:Event Two + DTSTART:20230102T190000Z + DTEND:20230102T200000Z + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + + await setup_integration(hass, config_entry) + + # Event One is upcoming + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == STATE_OFF + assert state.attributes.get("message") == "Event One" + + # Advance time to after the end of the first event + freezer.move_to(datetime.fromisoformat("2023-01-01T11:01:00+00:00")) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Event Two is upcoming + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == STATE_OFF + assert state.attributes.get("message") == "Event Two"