mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 03:03:17 +01:00
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:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user