From 6e566daff650c0a1890dbb4f7e1b88cb342010e6 Mon Sep 17 00:00:00 2001 From: Rodrigo Pinheiro <217476753+soldier2008@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:55:36 -0300 Subject: [PATCH] Recompute the local calendar event instead of caching it The entity cached the upcoming event and only refreshed it in async_update, so the alarm scheduled for the end of the current event wrote the state while still holding the finished one. With two adjacent events that turned the calendar off until the next platform poll picked the new one up. Materializing a bounded timeline on update and scanning it on access is what remote_calendar already does since #163186. --- .../components/local_calendar/calendar.py | 30 ++++++--- .../local_calendar/test_calendar.py | 62 ++++++++++++++++++- 2 files changed, 83 insertions(+), 9 deletions(-) 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"