Fix remote calendar event handling of events within the same update period (#163186)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Allen Porter
2026-02-18 03:52:35 -08:00
committed by GitHub
parent 728de32d75
commit b26483e09e
2 changed files with 137 additions and 10 deletions

View File

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

View File

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