diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index e8416e7a239f..04d7b2d861cb 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -7,7 +7,7 @@ from typing import Any, override from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream -from ical.event import Event +from ical.event import Event, EventStatus from ical.exceptions import CalendarParseError from ical.store import EventStore, EventStoreError from ical.timeline import Timeline, materialize_timeline @@ -92,7 +92,11 @@ class LocalCalendarEntity(CalendarEntity): """Return the next upcoming event.""" if self._timeline is None: return None - events = self._timeline.active_after(dt_util.now()) + events = ( + event + for event in self._timeline.active_after(dt_util.now()) + if not _is_cancelled(event) + ) if event := next(events, None): return _get_calendar_event(event) return None @@ -108,7 +112,11 @@ class LocalCalendarEntity(CalendarEntity): start_date, end_date, ) - return [_get_calendar_event(event) for event in events] + return [ + _get_calendar_event(event) + for event in events + if not _is_cancelled(event) + ] return await self.hass.async_add_executor_job(events_in_range) @@ -229,14 +237,22 @@ def _parse_event(event: dict[str, Any]) -> Event: raise probatio.Invalid("Error parsing event input fields") from err +def _is_cancelled(event: Event) -> bool: + """Return whether an event has been called off. + + rfc5545 keeps a cancelled event in the calendar rather than deleting it, + and an imported calendar can carry one. A calendar entity does not return + such events. + """ + return event.status == EventStatus.CANCELLED + + def _get_status(event: Event) -> CalendarEventStatus | None: """Return the status of an event, if a calendar entity reports that status. - ical models the full rfc5545 set, which includes cancelled, and an imported - calendar can contain such an event. A calendar entity does not report a - cancelled status, so anything outside the supported set maps to no status. - ical's enum is a plain (str, Enum) rather than a StrEnum, so its value has - to be read explicitly. + Cancelled events are filtered out before this, so they never reach here. + ical's enum is a plain (str, Enum) rather than a StrEnum, so its value has to + be read explicitly. """ if event.status is None: return None diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index a1ba41e4bfc1..b476d05be928 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -1238,11 +1238,6 @@ END:VCALENDAR pytest.param( ICS_WITH_STATUS.format(status="CONFIRMED"), "confirmed", id="confirmed" ), - pytest.param( - ICS_WITH_STATUS.format(status="CANCELLED"), - None, - id="cancelled_is_not_reported", - ), ], ) @pytest.mark.usefixtures("setup_integration") @@ -1254,3 +1249,92 @@ async def test_event_status( events = await get_events("1997-07-13T00:00:00", "1997-07-16T00:00:00") assert len(events) == 1 assert events[0]["status"] == expected_status + + +@pytest.mark.parametrize("ics_content", [ICS_WITH_STATUS.format(status="CANCELLED")]) +@pytest.mark.usefixtures("setup_integration") +async def test_cancelled_event_is_not_returned(get_events: GetEventsFn) -> None: + """Test that an event called off is not returned by the API.""" + events = await get_events("1997-07-13T00:00:00", "1997-07-16T00:00:00") + + assert events == [] + + +ONGOING_CANCELLED_ICS = """BEGIN:VCALENDAR +PRODID:-//homeassistant.io//local_calendar 1.0//EN +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20260729T014500 +DTEND:20260729T021500 +SUMMARY:Called off +UID:called-off +STATUS:CANCELLED +END:VEVENT +END:VCALENDAR +""" + + +@pytest.mark.parametrize("ics_content", [ONGOING_CANCELLED_ICS]) +async def test_cancelled_event_does_not_turn_the_entity_on( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, +) -> None: + """Test that an event called off is not picked up as the current event. + + The event would be ongoing at this time were it not cancelled, so this + covers the state path rather than the API one. + """ + freezer.move_to("2026-07-29 07:50:20+00:00") # 01:50:20 in America/Regina + + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == STATE_OFF + + +CANCELLED_OCCURRENCE_ICS = """BEGIN:VCALENDAR +PRODID:-//homeassistant.io//local_calendar 1.0//EN +VERSION:2.0 +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261002 +DTEND;VALUE=DATE:20261003 +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Daily series +UID:daily-series +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261003 +DTEND;VALUE=DATE:20261004 +RECURRENCE-ID;VALUE=DATE:20261003 +SUMMARY:Daily series +UID:daily-series +STATUS:CANCELLED +END:VEVENT +END:VCALENDAR +""" + + +@pytest.mark.parametrize("ics_content", [CANCELLED_OCCURRENCE_ICS]) +@pytest.mark.usefixtures("setup_integration") +async def test_cancelled_occurrence_of_a_series_is_not_returned( + get_events: GetEventsFn, +) -> None: + """Test that only the cancelled occurrence of a recurring series is dropped. + + The filtering has to happen after the series is expanded: dropping the + cancelled VEVENT before expansion would remove the override, and the RRULE + would then produce that day as an ordinary event again. + """ + events = await get_events("2026-10-01T00:00:00", "2026-10-08T00:00:00") + + assert [event["start"] for event in events] == [ + {"date": "2026-10-02"}, + {"date": "2026-10-04"}, + {"date": "2026-10-05"}, + {"date": "2026-10-06"}, + ]