diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 76610a98423766..cd52e903c01603 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -10,6 +10,7 @@ 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 @@ 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 @@ def __init__( 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 @@ def __init__( @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 @@ def events_in_range() -> list[CalendarEvent]: 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.""" diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index cc2a1385a4fbd6..2afac82b275007 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -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 @@ event_fields, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_empty_calendar( @@ -1158,3 +1163,58 @@ 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" + + for offset in (0, 1, 30, 59): + freezer.move_to( + datetime.datetime(2026, 7, 29, 8, 0, tzinfo=datetime.UTC) + + datetime.timedelta(seconds=offset) + ) + 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, f"off at 02:00:{offset:02d}" + assert state.attributes["message"] == "Second"