Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions homeassistant/components/local_calendar/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -74,15 +81,20 @@ 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

@property
@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(
Expand All @@ -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."""
Expand Down
62 changes: 61 additions & 1 deletion tests/components/local_calendar/test_calendar.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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(
Expand Down Expand Up @@ -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"
Loading