Recompute the local calendar event instead of caching it (#178763)

This commit is contained in:
soldier2008
2026-08-21 15:06:14 -04:00
committed by GitHub
parent fde9c535ac
commit b7016f7379
2 changed files with 81 additions and 9 deletions
@@ -10,6 +10,7 @@ from ical.calendar_stream import IcsCalendarStream
from ical.event import Event
from ical.exceptions import CalendarParseError
from ical.store import EventStore, EventStoreError
from ical.timeline import Timeline, materialize_timeline
from ical.types import Range, Recur
import voluptuous as vol
@@ -34,6 +35,12 @@ _LOGGER = logging.getLogger(__name__)
PRODID = "-//homeassistant.io//local_calendar 1.0//EN"
# Materialize a bounded timeline of upcoming events on every update so the
# state can be recomputed synchronously, without walking recurrence rules in
# the event loop. Mirrors what remote_calendar does.
MAX_LOOKAHEAD_EVENTS = 20
MAX_LOOKAHEAD_TIME = timedelta(days=365)
async def async_setup_entry(
hass: HomeAssistant,
@@ -74,7 +81,7 @@ class LocalCalendarEntity(CalendarEntity):
self._store = store
self._calendar = calendar
self._calendar_lock = asyncio.Lock()
self._event: CalendarEvent | None = None
self._timeline: Timeline | None = None
self._attr_name = name
self._attr_unique_id = unique_id
@@ -82,7 +89,12 @@ class LocalCalendarEntity(CalendarEntity):
@override
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
return self._event
if self._timeline is None:
return None
events = self._timeline.active_after(dt_util.now())
if event := next(events, None):
return _get_calendar_event(event)
return None
@override
async def async_get_events(
@@ -102,14 +114,16 @@ class LocalCalendarEntity(CalendarEntity):
async def async_update(self) -> None:
"""Update entity state with the next upcoming event."""
def next_event() -> CalendarEvent | None:
def _get_timeline() -> Timeline:
now = dt_util.now()
events = self._calendar.timeline_tz(now.tzinfo).active_after(now)
if event := next(events, None):
return _get_calendar_event(event)
return None
return materialize_timeline(
self._calendar.timeline_tz(now.tzinfo),
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)
async def _async_store(self) -> None:
"""Persist the calendar to disk."""
@@ -1,13 +1,18 @@
"""Tests for calendar platform of local calendar."""
import datetime
from datetime import timedelta
import textwrap
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.local_calendar.const import DOMAIN
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.template import DATE_STR_FORMAT
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .conftest import (
@@ -18,7 +23,7 @@ from .conftest import (
event_fields,
)
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_empty_calendar(
@@ -1158,3 +1163,56 @@ async def test_invalid_event_duration(
"end": {"dateTime": "1997-07-14T11:30:00-06:00"},
}
]
ADJACENT_EVENTS_ICS = """BEGIN:VCALENDAR
PRODID:-//homeassistant.io//local_calendar 1.0//EN
VERSION:2.0
BEGIN:VEVENT
DTSTART:20260729T014500
DTEND:20260729T020000
SUMMARY:First
UID:first
END:VEVENT
BEGIN:VEVENT
DTSTART:20260729T020000
DTEND:20260729T021500
SUMMARY:Second
UID:second
END:VEVENT
END:VCALENDAR
"""
@pytest.mark.parametrize("ics_content", [ADJACENT_EVENTS_ICS])
async def test_adjacent_events_stay_on(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
config_entry: MockConfigEntry,
) -> None:
"""Test the state stays on when one event ends as the next one begins.
The scan interval is widened so the platform poll cannot reach the boundary
first: what is under test is the alarm scheduled for the end of the current
event, which has to be able to pick up the next one on its own.
"""
freezer.move_to("2026-07-29 07:50:20+00:00") # 01:50:20 in America/Regina
config_entry.add_to_hass(hass)
with patch("homeassistant.components.calendar.SCAN_INTERVAL", timedelta(hours=1)):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY)
assert state.state == STATE_ON
assert state.attributes["message"] == "First"
# 02:00:00 in America/Regina, the moment the first event ends and the
# second begins.
freezer.move_to("2026-07-29 08:00:00+00:00")
async_fire_time_changed(hass, dt_util.utcnow())
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY)
assert state.state == STATE_ON
assert state.attributes["message"] == "Second"