mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
Recompute the local calendar event instead of caching it (#178763)
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user