diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index e2ad8e1abad6..a1de80ec3e25 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -31,11 +31,9 @@ OFFSET = "!!" def _get_status(vevent: CalendarObjectResource) -> CalendarEventStatus | None: """Return the rfc5545 STATUS of a VEVENT, if a calendar entity reports it. - Anything outside the supported set is dropped rather than passed on, which - covers both the cancelled status a calendar entity does not report and the - iana-tokens and x-names that rfc5545 also permits here: reporting no status - at all is closer to the truth than reporting one the consumer cannot - interpret. + Cancelled events never reach this, they are filtered out before an event is + built. Anything else outside the supported set is dropped: rfc5545 also + permits iana-tokens and x-names here, which a consumer cannot interpret. """ if (value := get_attr_value(vevent, "status")) is None: return None @@ -46,6 +44,17 @@ def _get_status(vevent: CalendarObjectResource) -> CalendarEventStatus | None: return None +def _is_cancelled(vevent: CalendarObjectResource) -> bool: + """Return whether a VEVENT has been called off. + + rfc5545 keeps a cancelled event in the calendar rather than deleting it, so + a calendar has to be read to find out. A calendar entity does not return + such events. + """ + value = get_attr_value(vevent, "status") + return value is not None and value.lower() == "cancelled" + + class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): """Class to utilize the calendar dav client object to get next event.""" @@ -101,6 +110,8 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): vevent = event.vobject_instance.vevent if not self.is_matching(vevent, self.search): continue + if _is_cancelled(vevent): + continue event_list.append( CalendarEvent( summary=get_attr_value(vevent, "summary") or "", @@ -196,6 +207,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): self.is_matching(vevent, self.search) and (not self.is_all_day(vevent) or self.include_all_day) and not self.is_over(vevent) + and not _is_cancelled(vevent) ) ), None, diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 1f6b26367842..ab1fcd1cd3f4 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -1174,7 +1174,6 @@ END:VCALENDAR""" pytest.param("TENTATIVE", "tentative", id="tentative"), pytest.param("CONFIRMED", "confirmed", id="confirmed"), pytest.param("Tentative", "tentative", id="mixed_case"), - pytest.param("CANCELLED", None, id="cancelled_is_not_reported"), pytest.param("X-VENDOR-SPECIFIC", None, id="unsupported_value"), ], ) @@ -1193,6 +1192,54 @@ async def test_get_events_with_status( assert events[0]["status"] == expected_status +def _mock_calendar_holding(name: str, vevents: list[str]) -> Mock: + """Return a mock calendar holding exactly the given VEVENTs.""" + calendar = _mock_calendar(name) + calendar.search = MagicMock( + return_value=[ + Event(None, f"{idx}.ics", vevent, calendar, str(idx)) + for idx, vevent in enumerate(vevents) + ] + ) + return calendar + + +async def test_cancelled_event_is_not_returned( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test that an event called off is not returned by the API.""" + events = await _get_api_events_for_vevent( + hass, + hass_client, + ICS_WITH_STATUS.format(status="CANCELLED"), + "status-event-uid", + ) + + assert events == [] + + +@pytest.mark.parametrize("tz", [UTC]) +@pytest.mark.parametrize( + ("calendars"), + [[_mock_calendar_holding("Example", [ICS_WITH_STATUS.format(status="CANCELLED")])]], +) +@pytest.mark.freeze_time(_local_datetime(17, 30)) +async def test_cancelled_event_is_not_the_ongoing_event( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] +) -> None: + """Test that an event called off does not turn the entity on. + + The event would be ongoing at this time were it not cancelled, so this + covers the state path rather than the API one. + """ + await setup_platform_cb() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == STATE_OFF + + @pytest.mark.parametrize( ("calendars"), [